diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c8907f94..65451b45 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -1,4 +1,5 @@ import app.futured.kmptemplate.gradle.configuration.ProjectSettings +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) @@ -14,6 +15,9 @@ plugins { kotlin { jvmToolchain(ProjectSettings.Kotlin.JvmToolchainVersion) + compilerOptions { + jvmTarget = JvmTarget.fromTarget(ProjectSettings.Android.KotlinJvmTargetNum) + } } android { @@ -89,10 +93,6 @@ android { targetCompatibility = ProjectSettings.Android.JavaCompatibility } - kotlinOptions { - jvmTarget = ProjectSettings.Android.KotlinJvmTargetNum - } - lint { textReport = true // Write a text report to the console (Useful for CI logs) xmlReport = true // Write XML report @@ -111,7 +111,7 @@ dependencies { implementation(projects.shared.app) implementation(projects.shared.feature) implementation(projects.shared.platform) - implementation(projects.shared.arkitektDecompose) + implementation(projects.shared.arkitektDecompose.architecture) implementation(projects.shared.resources) implementation(platform(libs.androidx.compose.bom)) diff --git a/gradle.properties b/gradle.properties index b2273c21..a2ef9632 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,9 +1,9 @@ #Gradle org.gradle.jvmargs=-Xmx6g -Xms256m -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dkotlin.daemon.jvm.options=-XX:MaxMetaspaceSize=1g org.gradle.parallel=true -org.gradle.caching=true +org.gradle.caching=false org.gradle.configureondemand=true -org.gradle.configuration-cache=true +org.gradle.configuration-cache=false # Use this flag carefully in case some of the plugins are not fully compatible. org.gradle.configuration-cache.problems=warn diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9eb23aa0..b1cf473a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] agp = "8.9.0" -kotlin = "2.1.10" -ksp = "2.1.10-1.0.31" # Must be compatible with: `kotlin` +kotlin = "2.2.0" +ksp = "2.2.0-2.0.2" # Must be compatible with: `kotlin` desugarLibs = "2.1.5" androidxLifecycle = "2.8.6" androidxComposeBom = "2024.10.00" @@ -9,26 +9,26 @@ jetbrainsComposeRuntime = "1.7.0" # Needs to align with version in Compose BOM a androidxActivity = "1.9.2" decompose = "3.1.0" essenty = "2.1.0" -koin = "3.5.6" -koinAnnotations = "1.4.0" # Must be compatible with: `ksp` -kotlinx-coroutines = "1.8.1" +koin = "4.1.0" +koinAnnotations = "2.1.0" # Must be compatible with: `ksp` +kotlinx-coroutines = "1.10.2" kotlinx-immutableCollections = "0.3.8" kotlinx-dateTime = "0.6.1" ktlintGradlePlugin = "12.2.0" ktlint = "1.5.0" detektGradlePlugin = "1.23.8" composeLint = "1.3.1" -apollo = "4.0.0" -ktorfit = "2.4.0" # Must be compatible with: `ksp` -ktor = "3.1.0" # Must be compatible with: `ktorfit` +apollo = "4.3.1" +ktorfit = "2.6.1" # Must be compatible with: `ksp` +ktor = "3.2.1" # Must be compatible with: `ktorfit` kotlinx-serialization = "1.8.0" # Must be compatible with: `kotlin` timber = "5.0.1" -kermit = "2.0.2" -skie = "0.10.1" # Must be compatible with: `kotlin` +kermit = "2.0.6" +skie = "0.10.4" # Must be compatible with: `kotlin` buildkonfig = "0.15.2" nsExceptionKt = "1.0.0-BETA-7" datastore = "1.1.1" -moko-resources = "0.24.5" +moko-resources = "0.25.0" baselineProfile = "1.3.0" junit = "1.2.1" espressoCore = "3.6.1" diff --git a/settings.gradle.kts b/settings.gradle.kts index 2e05113b..e85f6f62 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,7 +19,7 @@ rootProject.name = "KMP_Futured_template" enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") include(":androidApp") -include(":shared:arkitekt-decompose") +include(":shared:arkitekt-decompose:architecture") include(":shared:arkitekt-decompose:annotation") include(":shared:arkitekt-decompose:processor") include(":shared:arkitekt-cr-usecases") diff --git a/shared/app/build.gradle.kts b/shared/app/build.gradle.kts index 1021f87e..56167a1e 100644 --- a/shared/app/build.gradle.kts +++ b/shared/app/build.gradle.kts @@ -47,7 +47,7 @@ kotlin { isStatic = true export(projects.shared.platform) - export(projects.shared.arkitektDecompose) + export(projects.shared.arkitektDecompose.architecture) export(projects.shared.feature) export(projects.shared.resources) @@ -88,7 +88,7 @@ kotlin { iosMain { dependencies { api(projects.shared.platform) - api(projects.shared.arkitektDecompose) + api(projects.shared.arkitektDecompose.architecture) api(projects.shared.feature) api(projects.shared.resources) diff --git a/shared/arkitekt-cr-usecases/build.gradle.kts b/shared/arkitekt-cr-usecases/build.gradle.kts index 180f5570..a08d01d7 100644 --- a/shared/arkitekt-cr-usecases/build.gradle.kts +++ b/shared/arkitekt-cr-usecases/build.gradle.kts @@ -21,6 +21,10 @@ kotlin { iosArm64() iosSimulatorArm64() + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + } + sourceSets { commonMain { dependencies { 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..56a04a46 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,11 +1,21 @@ package app.futured.arkitekt.crusecases -import app.futured.arkitekt.crusecases.scope.FlowUseCaseExecutionScope +import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler +import app.futured.arkitekt.crusecases.scope.CoroutineScopeOwner +import app.futured.arkitekt.crusecases.scope.FlowUseCaseConfig import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlin.coroutines.cancellation.CancellationException /** - * Base [Flow] use case meant to use in [FlowUseCaseExecutionScope] implementations + * Base [Flow] use case meant to use in [CoroutineScopeOwner] implementations */ abstract class FlowUseCase { @@ -20,3 +30,110 @@ abstract class FlowUseCase { */ abstract fun build(args: ARGS): Flow } + +/** + * Asynchronously executes use case and consumes data from flow on UI thread. + * By default all previous pending executions are canceled, this can be changed + * by [config]. When suspend function in use case finishes, onComplete is called + * on UI thread. This version is gets initial arguments by [args]. + * + * In case that an error is thrown during the execution of [FlowUseCase] then + * [UseCaseErrorHandler.globalOnErrorLogger] is called with the error as an argument. + * + * @param args Arguments used for initial use case initialization. + * @param config [FlowUseCaseConfig] used to process results of internal + * Flow and to set configuration options. + **/ +context(coroutineScopeOwner: CoroutineScopeOwner) +fun FlowUseCase.execute( + args: ARGS, + config: FlowUseCaseConfig.Builder.() -> Unit, +) { + val flowUseCaseConfig = FlowUseCaseConfig.Builder().run { + config.invoke(this) + return@run build() + } + + if (flowUseCaseConfig.disposePrevious) { + job?.cancel() + } + + job = build(args) + .flowOn(coroutineScopeOwner.getWorkerDispatcher()) + .onStart { flowUseCaseConfig.onStart() } + .onEach { flowUseCaseConfig.onNext(it) } + .onCompletion { error -> + when { + error is CancellationException -> { + // ignore this exception + } + + error != null -> { + UseCaseErrorHandler.globalOnErrorLogger(error) + flowUseCaseConfig.onError(error) + } + + else -> flowUseCaseConfig.onComplete() + } + } + .catch { /* handled in onCompletion */ } + .launchIn(coroutineScopeOwner.viewModelScope) +} + +context(coroutineScopeOwner: CoroutineScopeOwner) +fun FlowUseCase.execute(config: FlowUseCaseConfig.Builder.() -> Unit) = + execute(Unit, config) + +context(coroutineScopeOwner: CoroutineScopeOwner) +fun FlowUseCase.executeMapped(config: FlowUseCaseConfig.Builder.() -> Unit) = + executeMapped(Unit, config) + +/** + * Asynchronously executes use case and consumes data from flow on UI thread. + * By default all previous pending executions are canceled, this can be changed + * by [config]. When suspend function in use case finishes, onComplete is called + * on UI thread. This version is gets initial arguments by [args]. + * + * In case that an error is thrown during the execution of [FlowUseCase] then + * [UseCaseErrorHandler.globalOnErrorLogger] is called with the error as an argument. + * + * @param args Arguments used for initial use case initialization. + * @param config [FlowUseCaseConfig] used to process results of internal + * Flow and to set configuration options. + **/ +context(coroutineScopeOwner: CoroutineScopeOwner) +fun FlowUseCase.executeMapped( + args: ARGS, + config: FlowUseCaseConfig.Builder.() -> Unit, +) { + val flowUseCaseConfig = FlowUseCaseConfig.Builder().run { + config.invoke(this) + return@run build() + } + + if (flowUseCaseConfig.disposePrevious) { + job?.cancel() + } + + job = build(args) + .flowOn(coroutineScopeOwner.getWorkerDispatcher()) + .onStart { flowUseCaseConfig.onStart() } + .mapNotNull { flowUseCaseConfig.onMap?.invoke(it) } + .onEach { flowUseCaseConfig.onNext(it) } + .onCompletion { error -> + when { + error is CancellationException -> { + // ignore this exception + } + + error != null -> { + UseCaseErrorHandler.globalOnErrorLogger(error) + flowUseCaseConfig.onError(error) + } + + else -> flowUseCaseConfig.onComplete() + } + } + .catch { /* handled in onCompletion */ } + .launchIn(coroutineScopeOwner.viewModelScope) +} 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..c76eb920 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,10 +1,17 @@ package app.futured.arkitekt.crusecases -import app.futured.arkitekt.crusecases.scope.SingleUseCaseExecutionScope +import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler +import app.futured.arkitekt.crusecases.scope.CoroutineScopeOwner +import app.futured.arkitekt.crusecases.scope.UseCaseConfig +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException /** - * Base Coroutine use case meant to use in [SingleUseCaseExecutionScope] implementations + * Base Coroutine use case meant to use in [CoroutineScopeOwner] implementations */ abstract class UseCase { @@ -18,3 +25,95 @@ abstract class UseCase { */ abstract suspend fun build(args: ARGS): T } + +/** + * Asynchronously executes use case and saves it's Deferred. By default, all previous + * pending executions are canceled, this can be changed by the [config]. + * This version is used for use cases without initial arguments. + * + * @param config [UseCaseConfig] used to process results of internal + * Coroutine and to set configuration options. + */ +context(coroutineScopeOwner: CoroutineScopeOwner) +fun UseCase.execute(config: UseCaseConfig.Builder.() -> Unit) = + execute(Unit, config) + +/** + * Asynchronously executes use case and saves it's Deferred. By default, all previous + * pending executions are canceled, this can be changed by the [config]. + * This version gets initial arguments by [args]. + * + * In case that an error is thrown during the execution of [UseCase] then [UseCaseErrorHandler.globalOnErrorLogger] + * is called with the error as an argument. + * + * @param args Arguments used for initial use case initialization. + * @param config [UseCaseConfig] used to process results of internal + * Coroutine and to set configuration options. + */ +context(coroutineScopeOwner: CoroutineScopeOwner) +fun UseCase.execute( + args: ARGS, + config: UseCaseConfig.Builder.() -> Unit, +) { + val useCaseConfig = UseCaseConfig.Builder().run { + config.invoke(this) + return@run build() + } + if (useCaseConfig.disposePrevious) { + deferred?.cancel() + } + + useCaseConfig.onStart() + deferred = coroutineScopeOwner.viewModelScope + .async(context = coroutineScopeOwner.getWorkerDispatcher(), start = CoroutineStart.LAZY) { + build(args) + } + .also { + coroutineScopeOwner.viewModelScope.launch(Dispatchers.Main) { + try { + useCaseConfig.onSuccess(it.await()) + } catch (cancellation: CancellationException) { + // do nothing - this is normal way of suspend function interruption + } catch (error: Throwable) { + UseCaseErrorHandler.globalOnErrorLogger(error) + useCaseConfig.onError(error) + } + } + } +} + +/** + * Synchronously executes use case and saves it's Deferred. By default all previous + * pending executions are canceled, this can be changed by the [cancelPrevious]. + * This version gets initial arguments by [args]. + * + * [UseCaseErrorHandler.globalOnErrorLogger] is not used in this version of the execute + * method since it is recommended to call all execute methods with [Result] return type + * from [UseCaseExecutionScope.launchWithHandler] method, + * where [UseCaseErrorHandler.globalOnErrorLogger] is used. + * + * @param args Arguments used for initial use case initialization. + * @return [Result] that encapsulates either a successful result with [Success] or a failed result with [Error] + */ +@Suppress("TooGenericExceptionCaught") +context(coroutineScopeOwner: CoroutineScopeOwner) +suspend fun UseCase.execute( + args: ARGS, + cancelPrevious: Boolean = true, +): Result { + if (cancelPrevious) { + deferred?.cancel() + } + + return try { + val newDeferred = coroutineScopeOwner.viewModelScope.async(coroutineScopeOwner.getWorkerDispatcher(), CoroutineStart.LAZY) { + build(args) + }.also { deferred = it } + + Result.success(newDeferred.await()) + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + Result.failure(exception) + } +} 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..6c6ce7ee 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 @@ -1,7 +1,10 @@ package app.futured.arkitekt.crusecases.scope +import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException interface CoroutineScopeOwner { /** @@ -15,4 +18,39 @@ interface CoroutineScopeOwner { * Provides Dispatcher for background tasks. This may be overridden for testing purposes. */ fun getWorkerDispatcher() = Dispatchers.Default + + /** + * Launch suspend [block] in [viewModelScope]. + * + * Encapsulates this call with try catch block and when an exception is thrown + * then it is logged in [UseCaseErrorHandler.globalOnErrorLogger] and handled by [defaultErrorHandler]. + * + * If exception is [CancellationException] then [defaultErrorHandler] is not called and + * [UseCaseErrorHandler.globalOnErrorLogger] is called only if the root cause of this exception is not + * [CancellationException] (e.g. when [Result.getOrCancel] is used). + */ + @Suppress("TooGenericExceptionCaught") + fun launchWithHandler(block: suspend CoroutineScope.() -> Unit) { + viewModelScope.launch { + try { + block() + } catch (exception: CancellationException) { + val rootCause = exception.cause + if (rootCause != null && rootCause !is CancellationException) { + UseCaseErrorHandler.globalOnErrorLogger(exception) + } + } catch (exception: Throwable) { + UseCaseErrorHandler.globalOnErrorLogger(exception) + defaultErrorHandler(exception) + } + } + } + + /** + * This method is called when coroutine launched with [launchWithHandler] throws an exception and + * this exception isn't [CancellationException]. By default, it rethrows this exception. + */ + fun defaultErrorHandler(exception: Throwable): Unit = throw exception } + +fun Result.getOrCancel(): T = this.getOrElse { throw CancellationException("Result was not Success") } diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseConfig.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseConfig.kt new file mode 100644 index 00000000..8526182b --- /dev/null +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseConfig.kt @@ -0,0 +1,91 @@ +package app.futured.arkitekt.crusecases.scope + +/** + * Holds references to lambdas and some basic configuration + * used to process results of Flow use case. + * Use [FlowUseCaseConfig.Builder] to construct this object. + */ +class FlowUseCaseConfig private constructor( + val onStart: () -> Unit, + val onNext: (M) -> Unit, + val onError: (Throwable) -> Unit, + val onComplete: () -> Unit, + val disposePrevious: Boolean, + val onMap: ((T) -> M)? = null, +) { + /** + * Constructs references to lambdas and some basic configuration + * used to process results of Flow use case. + */ + class Builder { + private var onStart: (() -> Unit)? = null + private var onNext: ((M) -> Unit)? = null + private var onMap: ((T) -> M)? = null + private var onError: ((Throwable) -> Unit)? = null + private var onComplete: (() -> Unit)? = null + private var disposePrevious = true + + /** + * Set lambda that is called right before + * internal Job of Flow is launched. + * @param onStart Lambda called right before Flow Job is launched. + */ + fun onStart(onStart: () -> Unit) { + this.onStart = onStart + } + + /** + * Set lambda that is called when internal Flow emits new value + * @param onNext Lambda called for every new emitted value + */ + fun onNext(onNext: (M) -> Unit) { + this.onNext = onNext + } + + /** + * Set lambda that is called when internal Flow emits new value + * @param onNext Lambda called for every new emitted value + */ + fun onMap(onMap: (T) -> M) { + this.onMap = onMap + } + + /** + * Set lambda that is called when some exception on + * internal Flow occurs + * @param onError Lambda called when exception occurs + */ + fun onError(onError: (Throwable) -> Unit) { + this.onError = onError + } + + /** + * Set lambda that is called when internal Flow is completed + * without errors + * @param onComplete Lambda called when Flow is completed + * without errors + */ + fun onComplete(onComplete: () -> Unit) { + this.onComplete = onComplete + } + + /** + * Set whether currently running Job of internal Flow + * should be canceled when execute is called repeatedly. + * @param disposePrevious True if currently running + * Job of internal Flow should be canceled + */ + fun disposePrevious(disposePrevious: Boolean) { + this.disposePrevious = disposePrevious + } + + fun build(): FlowUseCaseConfig = FlowUseCaseConfig( + onStart ?: { }, + onNext ?: { }, + onError ?: { throw it }, + onComplete ?: { }, + disposePrevious, + onMap, + ) + } +} 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 deleted file mode 100644 index 4eecbecb..00000000 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/FlowUseCaseExecutionScope.kt +++ /dev/null @@ -1,208 +0,0 @@ -package app.futured.arkitekt.crusecases.scope - -import app.futured.arkitekt.crusecases.FlowUseCase -import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlin.coroutines.cancellation.CancellationException - -interface FlowUseCaseExecutionScope : CoroutineScopeOwner { - - fun FlowUseCase.execute(config: FlowUseCaseConfig.Builder.() -> Unit) = - execute(Unit, config) - - fun FlowUseCase.executeMapped(config: FlowUseCaseConfig.Builder.() -> Unit) = - executeMapped(Unit, config) - - /** - * Asynchronously executes use case and consumes data from flow on UI thread. - * By default all previous pending executions are canceled, this can be changed - * by [config]. When suspend function in use case finishes, onComplete is called - * on UI thread. This version is gets initial arguments by [args]. - * - * In case that an error is thrown during the execution of [FlowUseCase] then - * [UseCaseErrorHandler.globalOnErrorLogger] is called with the error as an argument. - * - * @param args Arguments used for initial use case initialization. - * @param config [FlowUseCaseConfig] used to process results of internal - * Flow and to set configuration options. - **/ - fun FlowUseCase.executeMapped( - args: ARGS, - config: FlowUseCaseConfig.Builder.() -> Unit, - ) { - val flowUseCaseConfig = FlowUseCaseConfig.Builder().run { - config.invoke(this) - return@run build() - } - - if (flowUseCaseConfig.disposePrevious) { - job?.cancel() - } - - job = build(args) - .flowOn(getWorkerDispatcher()) - .onStart { flowUseCaseConfig.onStart() } - .mapNotNull { flowUseCaseConfig.onMap?.invoke(it) } - .onEach { flowUseCaseConfig.onNext(it) } - .onCompletion { error -> - when { - error is CancellationException -> { - // ignore this exception - } - - error != null -> { - UseCaseErrorHandler.globalOnErrorLogger(error) - flowUseCaseConfig.onError(error) - } - - else -> flowUseCaseConfig.onComplete() - } - } - .catch { /* handled in onCompletion */ } - .launchIn(viewModelScope) - } - - /** - * Asynchronously executes use case and consumes data from flow on UI thread. - * By default all previous pending executions are canceled, this can be changed - * by [config]. When suspend function in use case finishes, onComplete is called - * on UI thread. This version is gets initial arguments by [args]. - * - * In case that an error is thrown during the execution of [FlowUseCase] then - * [UseCaseErrorHandler.globalOnErrorLogger] is called with the error as an argument. - * - * @param args Arguments used for initial use case initialization. - * @param config [FlowUseCaseConfig] used to process results of internal - * Flow and to set configuration options. - **/ - fun FlowUseCase.execute( - args: ARGS, - config: FlowUseCaseConfig.Builder.() -> Unit, - ) { - val flowUseCaseConfig = FlowUseCaseConfig.Builder().run { - config.invoke(this) - return@run build() - } - - if (flowUseCaseConfig.disposePrevious) { - job?.cancel() - } - - job = build(args) - .flowOn(getWorkerDispatcher()) - .onStart { flowUseCaseConfig.onStart() } - .onEach { flowUseCaseConfig.onNext(it) } - .onCompletion { error -> - when { - error is CancellationException -> { - // ignore this exception - } - - error != null -> { - UseCaseErrorHandler.globalOnErrorLogger(error) - flowUseCaseConfig.onError(error) - } - - else -> flowUseCaseConfig.onComplete() - } - } - .catch { /* handled in onCompletion */ } - .launchIn(viewModelScope) - } - - /** - * Holds references to lambdas and some basic configuration - * used to process results of Flow use case. - * Use [FlowUseCaseConfig.Builder] to construct this object. - */ - class FlowUseCaseConfig private constructor( - val onStart: () -> Unit, - val onNext: (M) -> Unit, - val onError: (Throwable) -> Unit, - val onComplete: () -> Unit, - val disposePrevious: Boolean, - val onMap: ((T) -> M)? = null, - ) { - /** - * Constructs references to lambdas and some basic configuration - * used to process results of Flow use case. - */ - class Builder { - private var onStart: (() -> Unit)? = null - private var onNext: ((M) -> Unit)? = null - private var onMap: ((T) -> M)? = null - private var onError: ((Throwable) -> Unit)? = null - private var onComplete: (() -> Unit)? = null - private var disposePrevious = true - - /** - * Set lambda that is called right before - * internal Job of Flow is launched. - * @param onStart Lambda called right before Flow Job is launched. - */ - fun onStart(onStart: () -> Unit) { - this.onStart = onStart - } - - /** - * Set lambda that is called when internal Flow emits new value - * @param onNext Lambda called for every new emitted value - */ - fun onNext(onNext: (M) -> Unit) { - this.onNext = onNext - } - - /** - * Set lambda that is called when internal Flow emits new value - * @param onNext Lambda called for every new emitted value - */ - fun onMap(onMap: (T) -> M) { - this.onMap = onMap - } - - /** - * Set lambda that is called when some exception on - * internal Flow occurs - * @param onError Lambda called when exception occurs - */ - fun onError(onError: (Throwable) -> Unit) { - this.onError = onError - } - - /** - * Set lambda that is called when internal Flow is completed - * without errors - * @param onComplete Lambda called when Flow is completed - * without errors - */ - fun onComplete(onComplete: () -> Unit) { - this.onComplete = onComplete - } - - /** - * Set whether currently running Job of internal Flow - * should be canceled when execute is called repeatedly. - * @param disposePrevious True if currently running - * Job of internal Flow should be canceled - */ - fun disposePrevious(disposePrevious: Boolean) { - this.disposePrevious = disposePrevious - } - - fun build(): FlowUseCaseConfig = FlowUseCaseConfig( - onStart ?: { }, - onNext ?: { }, - onError ?: { throw it }, - onComplete ?: { }, - disposePrevious, - onMap, - ) - } - } -} 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 deleted file mode 100644 index c5c63f37..00000000 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/SingleUseCaseExecutionScope.kt +++ /dev/null @@ -1,170 +0,0 @@ -package app.futured.arkitekt.crusecases.scope - -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.async -import kotlinx.coroutines.launch - -interface SingleUseCaseExecutionScope : CoroutineScopeOwner { - - /** - * Asynchronously executes use case and saves it's Deferred. By default, all previous - * pending executions are canceled, this can be changed by the [config]. - * This version is used for use cases without initial arguments. - * - * @param config [UseCaseConfig] used to process results of internal - * Coroutine and to set configuration options. - */ - fun UseCase.execute(config: UseCaseConfig.Builder.() -> Unit) = - execute(Unit, config) - - /** - * Asynchronously executes use case and saves it's Deferred. By default, all previous - * pending executions are canceled, this can be changed by the [config]. - * This version gets initial arguments by [args]. - * - * In case that an error is thrown during the execution of [UseCase] then [UseCaseErrorHandler.globalOnErrorLogger] - * is called with the error as an argument. - * - * @param args Arguments used for initial use case initialization. - * @param config [UseCaseConfig] used to process results of internal - * Coroutine and to set configuration options. - */ - fun UseCase.execute( - args: ARGS, - config: UseCaseConfig.Builder.() -> Unit, - ) { - val useCaseConfig = UseCaseConfig.Builder().run { - config.invoke(this) - return@run build() - } - if (useCaseConfig.disposePrevious) { - deferred?.cancel() - } - - useCaseConfig.onStart() - deferred = viewModelScope - .async(context = getWorkerDispatcher(), start = CoroutineStart.LAZY) { - build(args) - } - .also { - viewModelScope.launch(Dispatchers.Main) { - try { - useCaseConfig.onSuccess(it.await()) - } catch (cancellation: CancellationException) { - // do nothing - this is normal way of suspend function interruption - } catch (error: Throwable) { - UseCaseErrorHandler.globalOnErrorLogger(error) - useCaseConfig.onError(error) - } - } - } - } - - /** - * Synchronously executes use case and saves it's Deferred. By default all previous - * pending executions are canceled, this can be changed by the [cancelPrevious]. - * This version gets initial arguments by [args]. - * - * [UseCaseErrorHandler.globalOnErrorLogger] is not used in this version of the execute - * method since it is recommended to call all execute methods with [Result] return type - * from [UseCaseExecutionScope.launchWithHandler] method, - * where [UseCaseErrorHandler.globalOnErrorLogger] is used. - * - * @param args Arguments used for initial use case initialization. - * @return [Result] that encapsulates either a successful result with [Success] or a failed result with [Error] - */ - @Suppress("TooGenericExceptionCaught") - suspend fun UseCase.execute( - args: ARGS, - cancelPrevious: Boolean = true, - ): Result { - if (cancelPrevious) { - deferred?.cancel() - } - - return try { - val newDeferred = viewModelScope.async(getWorkerDispatcher(), CoroutineStart.LAZY) { - build(args) - }.also { deferred = it } - - Result.success(newDeferred.await()) - } catch (exception: CancellationException) { - throw exception - } catch (exception: Throwable) { - Result.failure(exception) - } - } - - /** - * Holds references to lambdas and some basic configuration - * used to process results of Coroutine use case. - * Use [UseCaseConfig.Builder] to construct this object. - */ - class UseCaseConfig private constructor( - val onStart: () -> Unit, - val onSuccess: (T) -> Unit, - val onError: (Throwable) -> Unit, - val disposePrevious: Boolean, - ) { - /** - * Constructs references to lambdas and some basic configuration - * used to process results of Coroutine use case. - */ - class Builder { - private var onStart: (() -> Unit)? = null - private var onSuccess: ((T) -> Unit)? = null - private var onError: ((Throwable) -> Unit)? = null - private var disposePrevious = true - - /** - * Set lambda that is called right before - * the internal Coroutine is created - * @param onStart Lambda called right before Coroutine is - * created - */ - fun onStart(onStart: () -> Unit) { - this.onStart = onStart - } - - /** - * Set lambda that is called when internal Coroutine - * finished without exceptions - * @param onSuccess Lambda called when Coroutine finished - */ - fun onSuccess(onSuccess: (T) -> Unit) { - this.onSuccess = onSuccess - } - - /** - * Set lambda that is called when exception on - * internal Coroutine occurs - * @param onError Lambda called when exception occurs - */ - fun onError(onError: (Throwable) -> Unit) { - this.onError = onError - } - - /** - * Set whether currently active Job of internal Coroutine - * should be canceled when execute is called repeatedly. - * Default value is true. - * @param disposePrevious True if active Job of internal - * Coroutine should be canceled. Default value is true. - */ - fun disposePrevious(disposePrevious: Boolean) { - this.disposePrevious = disposePrevious - } - - fun build(): UseCaseConfig = UseCaseConfig( - onStart ?: { }, - onSuccess ?: { }, - onError ?: { throw it }, - disposePrevious, - ) - } - } -} diff --git a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseConfig.kt b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseConfig.kt new file mode 100644 index 00000000..9e42eabc --- /dev/null +++ b/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseConfig.kt @@ -0,0 +1,71 @@ +package app.futured.arkitekt.crusecases.scope + +/** + * Holds references to lambdas and some basic configuration + * used to process results of Coroutine use case. + * Use [UseCaseConfig.Builder] to construct this object. + */ +class UseCaseConfig private constructor( + val onStart: () -> Unit, + val onSuccess: (T) -> Unit, + val onError: (Throwable) -> Unit, + val disposePrevious: Boolean, +) { + /** + * Constructs references to lambdas and some basic configuration + * used to process results of Coroutine use case. + */ + class Builder { + private var onStart: (() -> Unit)? = null + private var onSuccess: ((T) -> Unit)? = null + private var onError: ((Throwable) -> Unit)? = null + private var disposePrevious = true + + /** + * Set lambda that is called right before + * the internal Coroutine is created + * @param onStart Lambda called right before Coroutine is + * created + */ + fun onStart(onStart: () -> Unit) { + this.onStart = onStart + } + + /** + * Set lambda that is called when internal Coroutine + * finished without exceptions + * @param onSuccess Lambda called when Coroutine finished + */ + fun onSuccess(onSuccess: (T) -> Unit) { + this.onSuccess = onSuccess + } + + /** + * Set lambda that is called when exception on + * internal Coroutine occurs + * @param onError Lambda called when exception occurs + */ + fun onError(onError: (Throwable) -> Unit) { + this.onError = onError + } + + /** + * Set whether currently active Job of internal Coroutine + * should be canceled when execute is called repeatedly. + * Default value is true. + * @param disposePrevious True if active Job of internal + * Coroutine should be canceled. Default value is true. + */ + fun disposePrevious(disposePrevious: Boolean) { + this.disposePrevious = disposePrevious + } + + fun build(): UseCaseConfig = UseCaseConfig( + onStart ?: { }, + onSuccess ?: { }, + onError ?: { throw it }, + disposePrevious, + ) + } +} +//} 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 deleted file mode 100644 index f611db35..00000000 --- a/shared/arkitekt-cr-usecases/src/commonMain/kotlin/app/futured/arkitekt/crusecases/scope/UseCaseExecutionScope.kt +++ /dev/null @@ -1,50 +0,0 @@ -package app.futured.arkitekt.crusecases.scope - -import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch - -/** - * Combines [SingleUseCaseExecutionScope] and [FlowUseCaseExecutionScope] under one interface. - * The [UseCaseExecutionScope] would be usually implemented in class that wishes to execute UseCases. - */ -interface UseCaseExecutionScope : - SingleUseCaseExecutionScope, - FlowUseCaseExecutionScope { - - /** - * Launch suspend [block] in [viewModelScope]. - * - * Encapsulates this call with try catch block and when an exception is thrown - * then it is logged in [UseCaseErrorHandler.globalOnErrorLogger] and handled by [defaultErrorHandler]. - * - * If exception is [CancellationException] then [defaultErrorHandler] is not called and - * [UseCaseErrorHandler.globalOnErrorLogger] is called only if the root cause of this exception is not - * [CancellationException] (e.g. when [Result.getOrCancel] is used). - */ - @Suppress("TooGenericExceptionCaught") - fun launchWithHandler(block: suspend CoroutineScope.() -> Unit) { - viewModelScope.launch { - try { - block() - } catch (exception: CancellationException) { - val rootCause = exception.cause - if (rootCause != null && rootCause !is CancellationException) { - UseCaseErrorHandler.globalOnErrorLogger(exception) - } - } catch (exception: Throwable) { - UseCaseErrorHandler.globalOnErrorLogger(exception) - defaultErrorHandler(exception) - } - } - } - - /** - * This method is called when coroutine launched with [launchWithHandler] throws an exception and - * this exception isn't [CancellationException]. By default, it rethrows this exception. - */ - fun defaultErrorHandler(exception: Throwable): Unit = throw exception -} - -fun Result.getOrCancel(): T = this.getOrElse { throw CancellationException("Result was not Success") } 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..44d981a8 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 @@ -1,6 +1,6 @@ package app.futured.arkitekt.crusecases.base -import app.futured.arkitekt.crusecases.scope.UseCaseExecutionScope +import app.futured.arkitekt.crusecases.scope.CoroutineScopeOwner import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher @@ -10,7 +10,7 @@ import kotlinx.coroutines.test.setMain import kotlin.test.AfterTest import kotlin.test.BeforeTest -abstract class BaseUseCaseExecutionScopeTest : UseCaseExecutionScope { +abstract class BaseUseCaseExecutionScopeTest : CoroutineScopeOwner { private val testDispatcher = StandardTestDispatcher() override val viewModelScope = TestScope(testDispatcher) diff --git a/shared/arkitekt-decompose/build.gradle.kts b/shared/arkitekt-decompose/architecture/build.gradle.kts similarity index 100% rename from shared/arkitekt-decompose/build.gradle.kts rename to shared/arkitekt-decompose/architecture/build.gradle.kts diff --git a/shared/arkitekt-decompose/src/androidMain/kotlin/app/futured/arkitekt/decompose/event/EventsEffect.kt b/shared/arkitekt-decompose/architecture/src/androidMain/kotlin/app/futured/arkitekt/decompose/event/EventsEffect.kt similarity index 100% rename from shared/arkitekt-decompose/src/androidMain/kotlin/app/futured/arkitekt/decompose/event/EventsEffect.kt rename to shared/arkitekt-decompose/architecture/src/androidMain/kotlin/app/futured/arkitekt/decompose/event/EventsEffect.kt diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ArkitektComponentContext.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ArkitektComponentContext.kt similarity index 100% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ArkitektComponentContext.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ArkitektComponentContext.kt diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/coroutines/ValueStateFlow.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/coroutines/ValueStateFlow.kt similarity index 100% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/coroutines/ValueStateFlow.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/coroutines/ValueStateFlow.kt diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/DecomposeValueExt.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/DecomposeValueExt.kt similarity index 100% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/DecomposeValueExt.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/DecomposeValueExt.kt diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/FlowExt.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/FlowExt.kt similarity index 100% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/FlowExt.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/FlowExt.kt diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/NavigationExt.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/NavigationExt.kt similarity index 93% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/NavigationExt.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/NavigationExt.kt index 14065135..6b611252 100644 --- a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/NavigationExt.kt +++ b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/ext/NavigationExt.kt @@ -2,7 +2,6 @@ package app.futured.arkitekt.decompose.ext import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.StackNavigator -import com.arkivanov.decompose.router.stack.bringToFront /** * The same as [StackNavigation.bringToFront] but does not recreate [configuration] if it's class is already on stack and diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/navigation/NavigationActions.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/navigation/NavigationActions.kt similarity index 100% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/navigation/NavigationActions.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/navigation/NavigationActions.kt diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt similarity index 96% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt index 9635ac10..25ab4cba 100644 --- a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt +++ b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/BaseComponent.kt @@ -1,6 +1,6 @@ package app.futured.arkitekt.decompose.presentation -import app.futured.arkitekt.crusecases.scope.UseCaseExecutionScope +import app.futured.arkitekt.crusecases.scope.CoroutineScopeOwner import com.arkivanov.decompose.GenericComponentContext import com.arkivanov.essenty.lifecycle.doOnDestroy import kotlinx.coroutines.CoroutineScope @@ -26,7 +26,7 @@ import kotlinx.coroutines.launch * @param defaultState The default Component state. */ abstract class BaseComponent(componentContext: GenericComponentContext<*>, private val defaultState: VS) : - UseCaseExecutionScope { + CoroutineScopeOwner { /** * An internal state of the component of type [VS]. diff --git a/shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/UiEvent.kt b/shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/UiEvent.kt similarity index 100% rename from shared/arkitekt-decompose/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/UiEvent.kt rename to shared/arkitekt-decompose/architecture/src/commonMain/kotlin/app/futured/arkitekt/decompose/presentation/UiEvent.kt diff --git a/shared/feature/build.gradle.kts b/shared/feature/build.gradle.kts index 4d2e1fe3..3b0b8a5e 100644 --- a/shared/feature/build.gradle.kts +++ b/shared/feature/build.gradle.kts @@ -35,6 +35,10 @@ kotlin { iosArm64() iosSimulatorArm64() + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + } + sourceSets { commonMain { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") @@ -51,7 +55,7 @@ kotlin { implementation(projects.shared.network.graphql) implementation(projects.shared.network.rest) implementation(projects.shared.persistence) - implementation(projects.shared.arkitektDecompose) + implementation(projects.shared.arkitektDecompose.architecture) implementation(projects.shared.arkitektDecompose.annotation) implementation(projects.shared.resources) diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/TimeStampUseCase.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/TimeStampUseCase.kt new file mode 100644 index 00000000..6a636e48 --- /dev/null +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/domain/TimeStampUseCase.kt @@ -0,0 +1,19 @@ +package app.futured.kmptemplate.feature.domain + +import app.futured.arkitekt.crusecases.FlowUseCase +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.koin.core.annotation.Factory + +@Factory +internal class TimeStampUseCase() : FlowUseCase() { + override fun build(args: Unit): Flow = flow { + for (i in 0..1000) { + emit(Clock.System.now()) + delay(1000) + } + } +} diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/tools/LifecycleExecution.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/tools/LifecycleExecution.kt new file mode 100644 index 00000000..1bb02546 --- /dev/null +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/tools/LifecycleExecution.kt @@ -0,0 +1,104 @@ +package app.futured.kmptemplate.feature.tools + +import app.futured.arkitekt.crusecases.FlowUseCase +import app.futured.arkitekt.crusecases.error.UseCaseErrorHandler +import app.futured.arkitekt.crusecases.scope.CoroutineScopeOwner +import app.futured.arkitekt.crusecases.scope.FlowUseCaseConfig +import com.arkivanov.essenty.lifecycle.Lifecycle +import com.arkivanov.essenty.lifecycle.LifecycleOwner +import com.arkivanov.essenty.lifecycle.subscribe +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlin.coroutines.cancellation.CancellationException + +@OptIn(ExperimentalCoroutinesApi::class) +context(coroutineScopeOwner: CoroutineScopeOwner, componentContext: LifecycleOwner) +fun FlowUseCase.executeWithLifecycle( + args: ARGS, + minActiveState: Lifecycle.State = Lifecycle.State.RESUMED, + config: FlowUseCaseConfig.Builder.() -> Unit, +) = executeWithLifecycleInternal(args = args, minActiveState = minActiveState, config = config) + +@OptIn(ExperimentalCoroutinesApi::class) +context(coroutineScopeOwner: CoroutineScopeOwner, componentContext: LifecycleOwner) +fun FlowUseCase.executeWithLifecycle( + minActiveState: Lifecycle.State = Lifecycle.State.RESUMED, + config: FlowUseCaseConfig.Builder.() -> Unit, +) = executeWithLifecycleInternal(args = Unit, minActiveState = minActiveState, config = config) + +@OptIn(ExperimentalCoroutinesApi::class) +context(coroutineScopeOwner: CoroutineScopeOwner, componentContext: LifecycleOwner) +private fun FlowUseCase.executeWithLifecycleInternal( + args: ARGS, + minActiveState: Lifecycle.State = Lifecycle.State.RESUMED, + config: FlowUseCaseConfig.Builder.() -> Unit, +) { + val flowUseCaseConfig = FlowUseCaseConfig.Builder().run { + config.invoke(this) + return@run build() + } + + if (flowUseCaseConfig.disposePrevious) { + job?.cancel() + } + + val enableExecution = MutableStateFlow(false) + + componentContext.lifecycle.subscribe( + onCreate = { + if (minActiveState == Lifecycle.State.CREATED) { + enableExecution.value = true + } + }, + onDestroy = { + if (minActiveState == Lifecycle.State.CREATED) { + enableExecution.value = false + } + }, + onStart = { + if (minActiveState == Lifecycle.State.STARTED) { + enableExecution.value = true + } + }, + onStop = { + if (minActiveState == Lifecycle.State.STARTED) { + enableExecution.value = false + } + }, + onResume = { + if (minActiveState == Lifecycle.State.RESUMED) { + enableExecution.value = true + } + }, + onPause = { + if (minActiveState == Lifecycle.State.RESUMED) { + enableExecution.value = false + } + }, + ) + val targetFlow = build(args) + + job = enableExecution + .flatMapLatest { + if (it) targetFlow else emptyFlow() + } + .flowOn(coroutineScopeOwner.getWorkerDispatcher()) + .onStart { flowUseCaseConfig.onStart() } + .onEach { flowUseCaseConfig.onNext(it) } + .onCompletion { error -> + when { + error is CancellationException -> { + // ignore this exception + } + + error != null -> { + UseCaseErrorHandler.globalOnErrorLogger(error) + flowUseCaseConfig.onError(error) + } + + else -> flowUseCaseConfig.onComplete() + } + } + .catch { /* handled in onCompletion */ } + .launchIn(coroutineScopeOwner.viewModelScope) +} diff --git a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/firstScreen/FirstComponent.kt b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/firstScreen/FirstComponent.kt index 8175949a..b320e5de 100644 --- a/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/firstScreen/FirstComponent.kt +++ b/shared/feature/src/commonMain/kotlin/app/futured/kmptemplate/feature/ui/firstScreen/FirstComponent.kt @@ -1,9 +1,9 @@ package app.futured.kmptemplate.feature.ui.firstScreen +import app.futured.arkitekt.crusecases.execute import app.futured.factorygenerator.annotation.GenerateFactory -import app.futured.kmptemplate.feature.domain.CounterUseCase -import app.futured.kmptemplate.feature.domain.CounterUseCaseArgs -import app.futured.kmptemplate.feature.domain.SyncDataUseCase +import app.futured.kmptemplate.feature.domain.* +import app.futured.kmptemplate.feature.tools.executeWithLifecycle import app.futured.kmptemplate.feature.ui.base.AppComponentContext import app.futured.kmptemplate.feature.ui.base.ScreenComponent import app.futured.kmptemplate.resources.MR @@ -13,7 +13,7 @@ import com.arkivanov.essenty.lifecycle.doOnCreate import dev.icerock.moko.resources.format import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update -import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import org.koin.core.annotation.Factory import org.koin.core.annotation.InjectedParam import kotlin.time.Duration.Companion.milliseconds @@ -25,6 +25,7 @@ internal class FirstComponent( @InjectedParam override val navigation: FirstScreenNavigation, private val syncDataUseCase: SyncDataUseCase, private val counterUseCase: CounterUseCase, + private val timeStampUseCase: TimeStampUseCase, ) : ScreenComponent( componentContext = componentContext, defaultState = FirstViewState(), @@ -46,33 +47,49 @@ internal class FirstComponent( doOnCreate { syncData() observeCounter() - updateCreatedAtTimestamp() + runTimestamp() + } + } + + private fun runTimestamp() { + timeStampUseCase.executeWithLifecycle { + onNext { + logger.d { "Collect: $it" } + updateCreatedAtTimestamp(it) + } + onError { + logger.e { "Collect error: $it" } + } } } override fun onNext() = navigateToSecond() - private fun syncData() = syncDataUseCase.execute { - onSuccess { person -> - componentState.update { it.copy(randomPerson = MR.strings.first_screen_random_person.format(person.name.orEmpty())) } - } - onError { error -> - componentState.update { it.copy(randomPerson = MR.strings.first_screen_random_person.format("Failed to fetch")) } - logger.e(error) { error.message.toString() } + private fun syncData() { + syncDataUseCase.execute { + onSuccess { person -> + componentState.update { it.copy(randomPerson = MR.strings.first_screen_random_person.format(person.name.orEmpty())) } + } + onError { error -> + componentState.update { it.copy(randomPerson = MR.strings.first_screen_random_person.format("Failed to fetch")) } + logger.e(error) { error.message.toString() } + } } } - private fun observeCounter() = counterUseCase.execute(CounterUseCaseArgs(interval = 200.milliseconds)) { - onNext { count -> - updateCount(count) + private fun observeCounter() { + counterUseCase.execute(CounterUseCaseArgs(interval = 1000.milliseconds)) { + onNext { count -> + updateCount(count) - if (count == COUNTER_ALERT_AT_SECONDS) { - logger.d { "Counter reached 10" } - sendUiEvent(FirstUiEvent.ShowToast(MR.strings.first_screen_counter_alert.format(COUNTER_ALERT_AT_SECONDS))) + if (count == COUNTER_ALERT_AT_SECONDS) { + logger.d { "Counter reached 10" } + sendUiEvent(FirstUiEvent.ShowToast(MR.strings.first_screen_counter_alert.format(COUNTER_ALERT_AT_SECONDS))) + } + } + onError { error -> + logger.e(error) { "Counter error" } } - } - onError { error -> - logger.e(error) { "Counter error" } } } @@ -80,9 +97,9 @@ internal class FirstComponent( componentState.update { it.copy(counter = MR.strings.first_screen_counter.format(count)) } } - private fun updateCreatedAtTimestamp() { + private fun updateCreatedAtTimestamp(now: Instant) { componentState.update { - it.copy(createdAt = MR.strings.first_screen_created_at.format(Clock.System.now().desc("Hms"))) + it.copy(createdAt = MR.strings.first_screen_created_at.format(now.desc("Hms"))) } } }