diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d9294fb..c5629740 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,11 +20,6 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Create local properties - env: - LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} - run: echo "$LOCAL_PROPERTIES" > local.properties && cat local.properties - - name: set up JDK uses: actions/setup-java@v3 with: @@ -40,11 +35,16 @@ jobs: with: xcode-version: latest + - name: Create local properties + env: + LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} + run: echo "$LOCAL_PROPERTIES" > local.properties + - name: Run detekt run: ./gradlew detektAll - name: Build - run: ./gradlew assembleRelease --stacktrace + run: ./gradlew assemble --stacktrace - name: Unit tests run: ./gradlew allTests --stacktrace diff --git a/.github/workflows/debugger-linux.yml b/.github/workflows/debugger-linux.yml index d48ce689..263ba50a 100644 --- a/.github/workflows/debugger-linux.yml +++ b/.github/workflows/debugger-linux.yml @@ -3,8 +3,6 @@ name: debugger-linux on: push: branches: [ master ] - pull_request: - branches: [ master ] concurrency: group: "publish-linux" @@ -13,7 +11,7 @@ concurrency: jobs: publish-windows: runs-on: ubuntu-latest - + environment: publishing steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/debugger-macos.yml b/.github/workflows/debugger-macos.yml index aaef9c67..ce922446 100644 --- a/.github/workflows/debugger-macos.yml +++ b/.github/workflows/debugger-macos.yml @@ -3,8 +3,6 @@ name: debugger-macos on: push: branches: [ master ] - pull_request: - branches: [ master ] concurrency: group: "publish-macos" @@ -13,7 +11,7 @@ concurrency: jobs: publish-windows: runs-on: macos-latest - + environment: publishing steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/debugger-win.yml b/.github/workflows/debugger-win.yml index e1455138..345ae3f0 100644 --- a/.github/workflows/debugger-win.yml +++ b/.github/workflows/debugger-win.yml @@ -3,8 +3,6 @@ name: debugger-windows on: push: branches: [ master ] - pull_request: - branches: [ master ] concurrency: group: "publish-win" @@ -13,7 +11,7 @@ concurrency: jobs: publish-windows: runs-on: windows-latest - + environment: publishing steps: - uses: actions/checkout@v3 @@ -36,7 +34,7 @@ jobs: LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} run: echo "$LOCAL_PROPERTIES" > local.properties - - name: Create macOS debugger distributable + - name: Create platform debugger distributable run: ./gradlew debugger:app:packageDistributionForCurrentOS - name: Upload a Build Artifact diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 6ddad843..2be595a9 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -36,6 +36,8 @@ jobs: uses: gradle/wrapper-validation-action@v1 - name: Create local properties + with: + properties: ${{ secrets.LOCAL_PROPERTIES }} env: LOCAL_PROPERTIES: ${{ secrets.LOCAL_PROPERTIES }} run: echo "$LOCAL_PROPERTIES" > local.properties diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a38d2bc8..55a53a73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,7 @@ concurrency: jobs: publish: runs-on: macos-latest + environment: publishing steps: - uses: actions/checkout@v3 diff --git a/README.md b/README.md index c855ce00..9250a82a 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,12 @@ class CounterContainer( ) { val store = store(initial = Loading) { - // makes the store fully async and parallel + actionShareBehavior = ActionShareBehavior.Distribute() + + // makes the store fully async, parallel and thread-safe parallelIntents = true coroutineContext = Dispatchers.Default - actionShareBehavior = ActionShareBehavior.Distribute() + atomicStateUpdates = true // enables debugging features such as logging and remote connection debuggable = true @@ -201,6 +203,7 @@ Powerful DSL allows to hook into store events and amend any store's logic with r ```kotlin val counterPlugin = plugin { + onStart { } onStop { } diff --git a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt index d6f59f8d..9f830449 100644 --- a/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt +++ b/app/src/main/kotlin/pro/respawn/flowmvi/sample/compose/CounterContainer.kt @@ -47,7 +47,7 @@ class CounterContainer( private val cacheDir = context.cacheDir.resolve("state").path override val store = store(CounterState.Loading) { - name = "CounterContainer" + name = "ComposeCounter" debuggable = BuildConfig.DEBUG if (debuggable) { enableRemoteDebugging() diff --git a/build.gradle.kts b/build.gradle.kts index 961c8d2f..175409a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,7 +51,8 @@ allprojects { } subprojects { - if (name in setOf("app", "debugger")) return@subprojects + // TODO: Migrate to applying dokka plugin per-project in conventions + if (name in setOf("app", "debugger", "server")) return@subprojects apply(plugin = rootProject.libs.plugins.dokka.id) dependencies { diff --git a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt index ce2af196..b848a7f9 100644 --- a/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt +++ b/buildSrc/src/main/kotlin/ConfigureMultiplatform.kt @@ -4,7 +4,9 @@ import org.gradle.api.Project import org.gradle.kotlin.dsl.getValue import org.gradle.kotlin.dsl.getting import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl +@OptIn(ExperimentalWasmDsl::class) fun Project.configureMultiplatform( ext: KotlinMultiplatformExtension, jvm: Boolean = true, @@ -15,25 +17,37 @@ fun Project.configureMultiplatform( tvOs: Boolean = true, macOs: Boolean = true, watchOs: Boolean = true, - explicitApi: Boolean = true, + windows: Boolean = true, + wasmJs: Boolean = true, + wasmWasi: Boolean = false, // TODO: Coroutines do not support wasmWasi yet ) = ext.apply { val libs by versionCatalog - if (explicitApi) explicitApi() + explicitApi() applyDefaultHierarchyTemplate() withSourcesJar(true) if (linux) { linuxX64() linuxArm64() - mingwX64() } + if (windows) mingwX64() + if (js) js(IR) { browser() nodejs() binaries.library() } + if (wasmJs) wasmJs { + moduleName = this@configureMultiplatform.name + nodejs() + browser() + binaries.library() + } + + if (wasmWasi) wasmWasi() + if (android) androidTarget { publishLibraryVariants("release") } @@ -61,13 +75,7 @@ fun Project.configureMultiplatform( yield(watchosDeviceArm64()) yield(watchosSimulatorArm64()) } - }.forEach { - it.binaries.framework { - binaryOption("bundleId", Config.artifactId) - binaryOption("bundleVersion", Config.versionName) - baseName = Config.artifactId - } - } + }.toList() // for now, do nothing, but iterate the lazy sequence sourceSets.apply { if (jvm) { diff --git a/buildSrc/src/main/kotlin/ConfigurePublication.kt b/buildSrc/src/main/kotlin/ConfigurePublication.kt index e7953a40..bc2a6490 100644 --- a/buildSrc/src/main/kotlin/ConfigurePublication.kt +++ b/buildSrc/src/main/kotlin/ConfigurePublication.kt @@ -16,7 +16,7 @@ import org.gradle.plugins.signing.Sign * Configures Maven publishing to sonatype for this project */ fun Project.publishMultiplatform() { - val properties by rootProject.localProperties + val properties = localProperties() val isReleaseBuild = requireNotNull(properties["release"]).toString().toBooleanStrict() val javadocTask = tasks.named("emptyJavadocJar") // TODO: dokka does not support kmp javadocs yet @@ -43,7 +43,7 @@ fun Project.publishMultiplatform() { * Publish the android artifact */ fun Project.publishAndroid(ext: LibraryExtension) = with(ext) { - val properties by rootProject.localProperties + val properties = localProperties() publishing { singleVariant(Config.publishingVariant) { withSourcesJar() diff --git a/buildSrc/src/main/kotlin/Util.kt b/buildSrc/src/main/kotlin/Util.kt index ef32f68c..7461b4b2 100644 --- a/buildSrc/src/main/kotlin/Util.kt +++ b/buildSrc/src/main/kotlin/Util.kt @@ -49,14 +49,11 @@ fun List.toJavaArrayString() = buildString { fun String.toBase64() = Base64.getEncoder().encodeToString(toByteArray()) -val Project.localProperties - get() = lazy { - Properties().apply { - val file = File(rootProject.rootDir.absolutePath, "local.properties") - require(file.exists()) { "Please create root local.properties file" } - load(FileInputStream(file)) - } - } +fun Project.localProperties() = Properties().apply { + val file = File(rootProject.rootDir.absolutePath, "local.properties") + require(file.exists()) { "Please create root local.properties file" } + load(FileInputStream(file)) +} fun stabilityLevel(version: String): Int { Config.stabilityLevels.forEachIndexed { index, postfix -> diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts index 777a2fa8..c2515c96 100644 --- a/compose/build.gradle.kts +++ b/compose/build.gradle.kts @@ -8,6 +8,11 @@ plugins { android { configureAndroidLibrary(this) + namespace = "${Config.namespace}.compose" + + buildFeatures { + compose = true + } } kotlin { @@ -20,13 +25,14 @@ kotlin { watchOs = false, tvOs = false, linux = false, - js = false, + js = true, + wasmJs = true, + windows = false, ) sourceSets { androidMain.dependencies { implementation(libs.compose.foundation) implementation(libs.compose.preview) - implementation(libs.compose.lifecycle.viewmodel) implementation(libs.compose.lifecycle.runtime) api(projects.android) } @@ -37,18 +43,11 @@ kotlin { } jvmMain.dependencies { implementation(compose.desktop.common) + implementation(libs.compose.lifecycle.runtime) } } } -android { - namespace = "${Config.namespace}.compose" - - buildFeatures { - compose = true - } -} - dependencies { debugImplementation(libs.compose.tooling) } diff --git a/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/android/AndroidInterop.kt b/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/android/AndroidInterop.kt new file mode 100644 index 00000000..e9c0bcbd --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/android/AndroidInterop.kt @@ -0,0 +1,43 @@ +package pro.respawn.flowmvi.compose.android + +import androidx.compose.runtime.Stable +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle +import pro.respawn.flowmvi.compose.api.SubscriptionMode + +/** + * Converts this Android [LifecycleOwner] to a [SubscriberLifecycle]. + */ +@Stable +public fun Lifecycle.asSubscriberOwner(): SubscriberLifecycle = SubscriberLifecycle { mode, block -> + repeatOnLifecycle(mode.asLifecycleState, block) +} + +/** + * Converts this [SubscriptionMode] to a [Lifecycle.State] + */ +@Stable +public val SubscriptionMode.asLifecycleState: Lifecycle.State + get() = when (this) { + SubscriptionMode.Immediate -> Lifecycle.State.CREATED + SubscriptionMode.Started -> Lifecycle.State.STARTED + SubscriptionMode.Visible -> Lifecycle.State.RESUMED + } + +/** + * Converts the [Lifecycle.State] to a [SubscriptionMode] + * + * [Lifecycle.State.DESTROYED] and [Lifecycle.State.INITIALIZED] **cannot** be used as valid subscription modes as + * it is not supported by Android + */ +@Stable +public val Lifecycle.State.asSubscriptionMode: SubscriptionMode + get() = when (this) { + Lifecycle.State.CREATED -> SubscriptionMode.Immediate + Lifecycle.State.STARTED -> SubscriptionMode.Started + Lifecycle.State.RESUMED -> SubscriptionMode.Visible + Lifecycle.State.DESTROYED, + Lifecycle.State.INITIALIZED -> error("Android lifecycle does not support $this as subscription mode") + } diff --git a/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.android.kt b/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.android.kt new file mode 100644 index 00000000..66804f46 --- /dev/null +++ b/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.android.kt @@ -0,0 +1,10 @@ +package pro.respawn.flowmvi.compose.dsl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalLifecycleOwner +import pro.respawn.flowmvi.compose.android.asSubscriberOwner +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle + +internal actual val PlatformLifecycle: SubscriberLifecycle? + @Composable @ReadOnlyComposable get() = LocalLifecycleOwner.current.lifecycle.asSubscriberOwner() diff --git a/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.android.kt b/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.android.kt index 0c6e5f98..cf3e693a 100644 --- a/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.android.kt +++ b/compose/src/androidMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.android.kt @@ -3,25 +3,19 @@ package pro.respawn.flowmvi.compose.dsl import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle -import androidx.lifecycle.repeatOnLifecycle +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import pro.respawn.flowmvi.android.subscribe -import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.api.FlowMVIDSL import pro.respawn.flowmvi.api.ImmutableStore import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.compose.android.asSubscriberOwner +import pro.respawn.flowmvi.compose.android.asSubscriptionMode import pro.respawn.flowmvi.dsl.subscribe /** @@ -39,30 +33,18 @@ import pro.respawn.flowmvi.dsl.subscribe * @see ImmutableStore.subscribe * @see subscribe */ -@OptIn(DelicateStoreApi::class) @Suppress("NOTHING_TO_INLINE", "ComposableParametersOrdering") @Composable @FlowMVIDSL public inline fun ImmutableStore.subscribe( lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, noinline consume: suspend CoroutineScope.(action: A) -> Unit, -): State { - val owner = LocalLifecycleOwner.current - val state = remember(this) { mutableStateOf(state) } - val block by rememberUpdatedState(consume) - LaunchedEffect(this@subscribe, lifecycleState, owner) { - withContext(Dispatchers.Main.immediate) { - owner.repeatOnLifecycle(lifecycleState) { - subscribe( - store = this@subscribe, - consume = { block(it) }, - render = { state.value = it } - ).join() - } - } - } - return state -} +): State = subscribe( + lifecycle = lifecycleOwner.lifecycle.asSubscriberOwner(), + mode = lifecycleState.asSubscriptionMode, + consume = consume +) /** * A function to subscribe to the store that follows the system lifecycle. @@ -77,42 +59,13 @@ public inline fun ImmutableStore ImmutableStore.subscribe( lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, -): State { - val owner = LocalLifecycleOwner.current - val state = remember(this) { mutableStateOf(state) } - LaunchedEffect(this@subscribe, lifecycleState, owner) { - withContext(Dispatchers.Main.immediate) { - owner.repeatOnLifecycle(lifecycleState) { - subscribe( - store = this@subscribe, - render = { state.value = it } - ).join() - } - } - } - return state -} - -/** - * Alias for [pro.respawn.flowmvi.compose.dsl.subscribe] with [Lifecycle.State.STARTED]. - **/ -@FlowMVIDSL -@Composable -public actual inline fun ImmutableStore.subscribe(): State = - subscribe(Lifecycle.State.STARTED) - -/** - * Alias for [pro.respawn.flowmvi.compose.dsl.subscribe] with [Lifecycle.State.STARTED]. - **/ -@FlowMVIDSL -@Composable -@Suppress("NOTHING_TO_INLINE", "ComposableParametersOrdering") -public actual inline fun ImmutableStore.subscribe( - noinline consume: suspend CoroutineScope.(action: A) -> Unit -): State = subscribe(Lifecycle.State.STARTED, consume) + lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, +): State = subscribe( + lifecycle = lifecycleOwner.lifecycle.asSubscriberOwner(), + mode = lifecycleState.asSubscriptionMode +) diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/api/SubscriberLifecycle.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/api/SubscriberLifecycle.kt new file mode 100644 index 00000000..3154a8f3 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/api/SubscriberLifecycle.kt @@ -0,0 +1,23 @@ +package pro.respawn.flowmvi.compose.api + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.CoroutineScope + +/** + * A subscriber lifecycle is the lifecycle of the UI element used to subscribe to the store. This is usually the + * "screen"'s lifecycle. The lifecycle implementation must follow the [SubscriptionMode] contract as described in the + * documentation. + */ +@Stable +public fun interface SubscriberLifecycle { + + /** + * Repeat the execution of the [block] using the specified [mode]. + * + * This means that if the [mode] is no longer valid i.e. screen is not [SubscriptionMode.Visible], the + * [block] parameter must be cancelled, and invoked again when the condition is satisfied again. + * + * @see SubscriptionMode + */ + public suspend fun repeatOnLifecycle(mode: SubscriptionMode, block: suspend CoroutineScope.() -> Unit) +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/api/SubscriptionMode.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/api/SubscriptionMode.kt new file mode 100644 index 00000000..80fb7809 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/api/SubscriptionMode.kt @@ -0,0 +1,32 @@ +package pro.respawn.flowmvi.compose.api + +/** + * Subscription mode of the UI element with a dedicated lifecycle. + * An implementation of the [SubscriberLifecycle] must follow the contract outlined for each mode. + */ +public enum class SubscriptionMode { + + /** + * Subscribe using a composable function's lifecycle without considering if the UI is fully or partially visible. + */ + Immediate, + + /** + * Subscribe when the UI is visible, but may be covered by other elements such as Dialogs, modals, pop-ups etc. + * Should unsubscribe when the composable may still be alive but is no longer fully or partially visible. + * + * On Android, corresponds to the `onStart` activity lifecycle callback, + * thus the mode is no longer active in `onStop`. + */ + Started, + + /** + * Subscribe when the UI is fully visible and is not fully or partially covered by anything. + * + * A [SubscriberLifecycle] following this mode must unsubscribe when the composable is still in the composition, + * but is fully or **partially** covered by something else, such as modals, pop-ups, windows etc. + * + * On Android, corresponds to the `onResume` lifecycle event, with the mode being no longer active in `onPause`. + */ + Visible, +} diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ImmediateLifecycle.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ImmediateLifecycle.kt new file mode 100644 index 00000000..bcd2c9bd --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/ImmediateLifecycle.kt @@ -0,0 +1,11 @@ +package pro.respawn.flowmvi.compose.dsl + +import kotlinx.coroutines.coroutineScope +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle +import pro.respawn.flowmvi.compose.api.SubscriptionMode + +/** + * A no-op [SubscriberLifecycle] implementation that does not follow the system lifecycle in any way and ignores + * [SubscriptionMode] + */ +public val ImmediateLifecycle: SubscriberLifecycle = SubscriberLifecycle { _, block -> coroutineScope(block) } diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.kt new file mode 100644 index 00000000..15784ff1 --- /dev/null +++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.kt @@ -0,0 +1,60 @@ +package pro.respawn.flowmvi.compose.dsl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle + +private const val MissingLifecycleError = """ +Neither LocalSubscriberLifecycle nor PlatformLifecycle were provided for this function. +Please use ProvideSubscriberLifecycle() if you want to use composition locals for subscription. +""" + +@get:Composable +@get:ReadOnlyComposable +internal expect val PlatformLifecycle: SubscriberLifecycle? + +/** + * A local composition [SubscriberLifecycle] instance. May return `null` if no lifecycle was provided. + * Can be provided with [ProvideSubscriberLifecycle]. + */ +public val LocalSubscriberLifecycle: ProvidableCompositionLocal = staticCompositionLocalOf { + null +} + +/** + * Tries to obtain a [LocalSubscriberLifecycle], then a platform lifecycle implementation, and if not found, throws + * an [IllegalArgumentException] + */ +@Composable +@ReadOnlyComposable +public fun requireLifecycle(): SubscriberLifecycle = requireNotNull( + LocalSubscriberLifecycle.current ?: PlatformLifecycle +) { MissingLifecycleError } + +/** + * Tries to obtain a [LocalSubscriberLifecycle], if not found, tries to fetch a platform lifecycle. If still not found, + * uses a **default lifecycle that does not follow the system lifecycle** - [ImmediateLifecycle]. + * Use [requireLifecycle] if you want to prevent this function from falling back to a no-op lifecycle implementation. + */ +@FlowMVIDSL +public val DefaultLifecycle: SubscriberLifecycle + @Composable @ReadOnlyComposable get() = LocalSubscriberLifecycle.current + ?: PlatformLifecycle + ?: ImmediateLifecycle + +/** + * Provides a [LocalSubscriberLifecycle] in the scope of [content] + */ +@FlowMVIDSL +@Composable +public fun ProvideSubscriberLifecycle( + lifecycleOwner: SubscriberLifecycle, + content: @Composable () -> Unit +): Unit = CompositionLocalProvider( + LocalSubscriberLifecycle provides lifecycleOwner, + content = content, +) diff --git a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.kt b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.kt index 8ed9e48b..188c2d79 100644 --- a/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.kt +++ b/compose/src/commonMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.kt @@ -1,14 +1,27 @@ +@file:OptIn(DelicateStoreApi::class) + package pro.respawn.flowmvi.compose.dsl import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pro.respawn.flowmvi.api.DelicateStoreApi import pro.respawn.flowmvi.api.FlowMVIDSL import pro.respawn.flowmvi.api.ImmutableStore import pro.respawn.flowmvi.api.MVIAction import pro.respawn.flowmvi.api.MVIIntent import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle +import pro.respawn.flowmvi.compose.api.SubscriptionMode +import pro.respawn.flowmvi.dsl.subscribe +import pro.respawn.flowmvi.util.immediateOrDefault /** * A function to subscribe to the store that follows the system lifecycle. @@ -18,27 +31,80 @@ import pro.respawn.flowmvi.api.Store * * Store's subscribers will **not** wait until the store is launched when they subscribe to the store. * Such subscribers will not receive state updates or actions. Don't forget to launch the store. * + * [lifecycle] is an instance of the lifecycle wrapper used to execute the subscription event. + * * The provided implementation must follow the contract outlined in [SubscriberLifecycle]. This instance can be + * provided automatically on Android, but must be implemented manually on other platforms for now. + * * If you have provided a lifecycle via [LocalSubscriberLifecycle], use [requireLifecycle]. + * * If you don't want to provide a lifecycle, use [DefaultLifecycle] to fall back to a no-op implementation if needed + * + * + * @param mode the subscription mode that should be reached in order to subscribe to the store. At specified moments + * in the UI lifecycle (Activity, Composable, Window etc), the store will subscribe and unsubscribe from the store. * @param consume a lambda to consume actions with. - * @return the [State] that contains the [Store.state]. - * @see Store.subscribe + * @return the [State] that contains the current state. + * @see ImmutableStore.subscribe + * @see subscribe */ +@Suppress("ComposableParametersOrdering") @Composable @FlowMVIDSL -public expect inline fun ImmutableStore.subscribe( +public inline fun ImmutableStore.subscribe( + lifecycle: SubscriberLifecycle, + mode: SubscriptionMode = SubscriptionMode.Started, noinline consume: suspend CoroutineScope.(action: A) -> Unit, -): State +): State { + val state = remember(this) { mutableStateOf(state) } + val block by rememberUpdatedState(consume) + LaunchedEffect(this@subscribe, mode, lifecycle) { + withContext(Dispatchers.Main.immediateOrDefault) { + lifecycle.repeatOnLifecycle(mode) { + subscribe( + store = this@subscribe, + consume = { block(it) }, + render = { state.value = it } + ).join() + } + } + } + return state +} /** * A function to subscribe to the store that follows the system lifecycle. * - * * This function will not collect [MVIAction]s. * * This function will assign the store a new subscriber when invoked, then populate the returned [State] with new states. * * Store's subscribers will **not** wait until the store is launched when they subscribe to the store. * Such subscribers will not receive state updates or actions. Don't forget to launch the store. - * upon leaving that state, the function will unsubscribe. - * @return the [State] that contains the [Store.state]. - * @see Store.subscribe + * + * [lifecycle] is an instance of the lifecycle wrapper used to execute the subscription event. + * * The provided implementation must follow the contract outlined in [SubscriberLifecycle]. This instance can be + * provided automatically on Android, but must be implemented manually on other platforms for now. + * * If you have provided a lifecycle via [LocalSubscriberLifecycle], use [requireLifecycle]. + * * If you don't want to provide a lifecycle, use [DefaultLifecycle] to fall back to a no-op implementation if needed + * + * + * @param mode the subscription mode that should be reached in order to subscribe to the store. At specified moments + * in the UI lifecycle (Activity, Composable, Window etc), the store will subscribe and unsubscribe from the store. + * @return the [State] that contains the current state. + * @see ImmutableStore.subscribe + * @see subscribe */ @Composable @FlowMVIDSL -public expect inline fun ImmutableStore.subscribe(): State +public inline fun ImmutableStore.subscribe( + lifecycle: SubscriberLifecycle, + mode: SubscriptionMode = SubscriptionMode.Started, +): State { + val state = remember(this) { mutableStateOf(state) } + LaunchedEffect(this@subscribe, mode, lifecycle) { + withContext(Dispatchers.Main.immediateOrDefault) { + lifecycle.repeatOnLifecycle(mode) { + subscribe( + store = this@subscribe, + render = { state.value = it } + ).join() + } + } + } + return state +} diff --git a/compose/src/jsMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.js.kt b/compose/src/jsMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.js.kt new file mode 100644 index 00000000..72d31285 --- /dev/null +++ b/compose/src/jsMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.js.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.compose.dsl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle + +internal actual val PlatformLifecycle: SubscriberLifecycle? @Composable @ReadOnlyComposable get() = null diff --git a/compose/src/jvmMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.jvm.kt b/compose/src/jvmMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.jvm.kt new file mode 100644 index 00000000..72d31285 --- /dev/null +++ b/compose/src/jvmMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.jvm.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.compose.dsl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle + +internal actual val PlatformLifecycle: SubscriberLifecycle? @Composable @ReadOnlyComposable get() = null diff --git a/compose/src/jvmMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.jvm.kt b/compose/src/jvmMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.jvm.kt deleted file mode 100644 index da6c58c4..00000000 --- a/compose/src/jvmMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.jvm.kt +++ /dev/null @@ -1,74 +0,0 @@ -@file:Suppress("NOTHING_TO_INLINE") - -package pro.respawn.flowmvi.compose.dsl - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import kotlinx.coroutines.CoroutineScope -import pro.respawn.flowmvi.api.DelicateStoreApi -import pro.respawn.flowmvi.api.FlowMVIDSL -import pro.respawn.flowmvi.api.ImmutableStore -import pro.respawn.flowmvi.api.MVIAction -import pro.respawn.flowmvi.api.MVIIntent -import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.dsl.subscribe - -/** - * A function to subscribe to the store that follows the system lifecycle. - * - * * This function will assign the store a new subscriber when invoked, then populate the returned [State] with new states. - * * Provided [consume] parameter will be used to consume actions that come from the store. - * * Store's subscribers will **not** wait until the store is launched when they subscribe to the store. - * Such subscribers will not receive state updates or actions. Don't forget to launch the store. - * - * @param consume a lambda to consume actions with. - * @return the [State] that contains the [ImmutableStore.state]. - * @see ImmutableStore.subscribe - */ -@OptIn(DelicateStoreApi::class) -@FlowMVIDSL -@Composable -public actual inline fun ImmutableStore.subscribe( - noinline consume: suspend CoroutineScope.(action: A) -> Unit -): State { - val state = remember(this) { mutableStateOf(state) } - val block by rememberUpdatedState(consume) - LaunchedEffect(this@subscribe) { - subscribe( - store = this@subscribe, - render = { state.value = it }, - consume = { block(it) } - ).join() - } - return state -} - -/** - * A function to subscribe to the store that follows the system lifecycle. - * - * * This function will not collect [MVIAction]s. - * * This function will assign the store a new subscriber when invoked, then populate the returned [State] with new states. - * * Store's subscribers will **not** wait until the store is launched when they subscribe to the store. - * Such subscribers will not receive state updates or actions. Don't forget to launch the store. - * upon leaving that state, the function will unsubscribe. - * @return the [State] that contains the [ImmutableStore.state]. - * @see ImmutableStore.subscribe - */ -@OptIn(DelicateStoreApi::class) -@FlowMVIDSL -@Composable -public actual inline fun ImmutableStore.subscribe(): State { - val state = remember(this) { mutableStateOf(state) } - LaunchedEffect(this@subscribe) { - subscribe( - store = this@subscribe, - render = { state.value = it } - ).join() - } - return state -} diff --git a/compose/src/nativeMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.native.kt b/compose/src/nativeMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.native.kt new file mode 100644 index 00000000..72d31285 --- /dev/null +++ b/compose/src/nativeMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.native.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.compose.dsl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle + +internal actual val PlatformLifecycle: SubscriberLifecycle? @Composable @ReadOnlyComposable get() = null diff --git a/compose/src/nativeMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.native.kt b/compose/src/nativeMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.native.kt deleted file mode 100644 index f609a108..00000000 --- a/compose/src/nativeMain/kotlin/pro/respawn/flowmvi/compose/dsl/SubscribeDsl.native.kt +++ /dev/null @@ -1,72 +0,0 @@ -package pro.respawn.flowmvi.compose.dsl - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import kotlinx.coroutines.CoroutineScope -import pro.respawn.flowmvi.api.DelicateStoreApi -import pro.respawn.flowmvi.api.FlowMVIDSL -import pro.respawn.flowmvi.api.ImmutableStore -import pro.respawn.flowmvi.api.MVIAction -import pro.respawn.flowmvi.api.MVIIntent -import pro.respawn.flowmvi.api.MVIState -import pro.respawn.flowmvi.dsl.subscribe - -/** - * A function to subscribe to the store that follows the system lifecycle. - * - * * This function will assign the store a new subscriber when invoked, then populate the returned [State] with new states. - * * Provided [consume] parameter will be used to consume actions that come from the store. - * * Store's subscribers will **not** wait until the store is launched when they subscribe to the store. - * Such subscribers will not receive state updates or actions. Don't forget to launch the store. - * - * @param consume a lambda to consume actions with. - * @return the [State] that contains the [ImmutableStore.state]. - * @see ImmutableStore.subscribe - */ -@OptIn(DelicateStoreApi::class) -@FlowMVIDSL -@Composable -public actual inline fun ImmutableStore.subscribe( - noinline consume: suspend CoroutineScope.(action: A) -> Unit -): State { - val state = remember(this) { mutableStateOf(state) } - val block by rememberUpdatedState(consume) - LaunchedEffect(this@subscribe) { - subscribe( - store = this@subscribe, - render = { state.value = it }, - consume = { block(it) } - ).join() - } - return state -} - -/** - * A function to subscribe to the store that follows the system lifecycle. - * - * * This function will not collect [MVIAction]s. - * * This function will assign the store a new subscriber when invoked, then populate the returned [State] with new states. - * * Store's subscribers will **not** wait until the store is launched when they subscribe to the store. - * Such subscribers will not receive state updates or actions. Don't forget to launch the store. - * upon leaving that state, the function will unsubscribe. - * @return the [State] that contains the [ImmutableStore.state]. - * @see ImmutableStore.subscribe - */ -@OptIn(DelicateStoreApi::class) -@FlowMVIDSL -@Composable -public actual inline fun ImmutableStore.subscribe(): State { - val state = remember(this) { mutableStateOf(state) } - LaunchedEffect(this@subscribe) { - subscribe( - store = this@subscribe, - render = { state.value = it } - ).join() - } - return state -} diff --git a/compose/src/wasmJsMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.wasmJs.kt b/compose/src/wasmJsMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.wasmJs.kt new file mode 100644 index 00000000..72d31285 --- /dev/null +++ b/compose/src/wasmJsMain/kotlin/pro/respawn/flowmvi/compose/dsl/LocalSubscriberLifecycle.wasmJs.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.compose.dsl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle + +internal actual val PlatformLifecycle: SubscriberLifecycle? @Composable @ReadOnlyComposable get() = null diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 9b75ace4..d4e9f8ae 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,7 +1,9 @@ @Suppress("DSL_SCOPE_VIOLATION") plugins { id("pro.respawn.shared-library") - alias(libs.plugins.kotest) + + // TODO: https://github.com/kotest/kotest/issues/3598 + // alias(libs.plugins.kotest) } android { diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreBuilder.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreBuilder.kt index 2cc0d642..93c5c97b 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreBuilder.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/dsl/StoreBuilder.kt @@ -116,6 +116,21 @@ public class StoreBuilder @Published @FlowMVIDSL public var intentCapacity: Int = Channel.UNLIMITED + /** + * Enables transaction serialization for state updates, making state updates atomic and suspendable. + * + * * Serializes both state reads and writes using a mutex. + * * Synchronizes state updates, allowing only **one** client to read and/or update the state at a time. + * All other clients attempt to get the state will wait on a FIFO queue and suspend the parent coroutine. + * * This property disables state transactions for the whole store. + * For one-time usage of non-atomic updates, see [useState]. + * * Has a small performance impact because of coroutine context switching and mutex usage. + * + * `true` by default + */ + @FlowMVIDSL + public var atomicStateUpdates: Boolean = true + /** * Install an existing [StorePlugin]. See the other overload to build the plugin on the fly. * This installs a prebuilt plugin. @@ -168,6 +183,7 @@ public class StoreBuilder @Published plugins = plugins, coroutineContext = coroutineContext, logger = logger, + atomicStateUpdates = atomicStateUpdates, ).let(::StoreImpl) } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt index 601b0f12..4af5e063 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/PipelineModule.kt @@ -58,10 +58,10 @@ internal inline fun T.launchPipe private val handler = PipelineExceptionHandler() private val pipelineName = CoroutineName(toString()) override val coroutineContext: CoroutineContext = parent.coroutineContext + pipelineName + job + handler + this - override suspend fun updateState(transform: suspend S.() -> S) = onTransformState(transform) - override suspend fun action(action: A) = onAction(action) + override suspend fun updateState(transform: suspend S.() -> S) = catch { onTransformState(transform) } + override suspend fun action(action: A) = catch { onAction(action) } override fun send(action: A) { - launch { onAction(action) } + launch { action(action) } } }.run { onStart() diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt index 243b3ca6..a392cefb 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/modules/StateModule.kt @@ -11,25 +11,40 @@ import pro.respawn.flowmvi.api.StateProvider import pro.respawn.flowmvi.api.StateReceiver import pro.respawn.flowmvi.util.withReentrantLock -internal fun stateModule(initial: S): StateModule = StateModuleImpl(initial) +internal fun stateModule( + initial: S, + atomic: Boolean, +): StateModule = if (atomic) AtomicStateModule(initial) else DefaultStateModule(initial) internal interface StateModule : StateReceiver, StateProvider -private class StateModuleImpl(initial: S) : StateModule { +private abstract class AbstractStateModule(initial: S) : StateModule { - private val _states = MutableStateFlow(initial) - private val stateMutex = Mutex() + @Suppress("PropertyName", "VariableNaming") + protected val _states = MutableStateFlow(initial) + final override val states: StateFlow = _states.asStateFlow() @DelicateStoreApi - override val state by _states::value + final override val state by _states::value + + final override fun useState(block: S.() -> S) = _states.update(block) +} - override fun useState(block: S.() -> S) = _states.update(block) +private class AtomicStateModule(initial: S) : AbstractStateModule(initial) { - override val states: StateFlow = _states.asStateFlow() + private val stateMutex = Mutex() + + override suspend fun withState( + block: suspend S.() -> Unit + ) = stateMutex.withReentrantLock { block(states.value) } + + override suspend fun updateState( + transform: suspend S.() -> S + ) = stateMutex.withReentrantLock { _states.update { transform(it) } } +} - override suspend fun withState(block: suspend S.() -> Unit) = - stateMutex.withReentrantLock { block(states.value) } +private class DefaultStateModule(initial: S) : AbstractStateModule(initial) { - override suspend fun updateState(transform: suspend S.() -> S) = - stateMutex.withReentrantLock { _states.update { transform(it) } } + override suspend fun updateState(transform: suspend S.() -> S) = _states.update { transform(it) } + override suspend fun withState(block: suspend S.() -> Unit) = _states.value.block() } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreConfiguration.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreConfiguration.kt index c75ff62b..15568956 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreConfiguration.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/store/StoreConfiguration.kt @@ -22,4 +22,5 @@ internal data class StoreConfiguration( RecoverModule, StorePlugin by compositePlugin(config.plugins), SubscribersModule by subscribersModule(), - StateModule by stateModule(config.initial), + StateModule by stateModule(config.initial, config.atomicStateUpdates), IntentModule by intentModule(config.parallelIntents, config.intentCapacity, config.onOverflow), ActionModule by actionModule(config.actionShareBehavior) { @@ -49,38 +49,35 @@ internal class StoreImpl( override fun start(scope: CoroutineScope): Job = launchPipeline( name = name, parent = scope + config.coroutineContext, - onAction = { catch { onAction(it)?.let { this@StoreImpl.action(it) } } }, + onAction = { action -> onAction(action)?.let { this@StoreImpl.action(it) } }, onTransformState = { transform -> - catch { - this@StoreImpl.updateState { - onState(this, transform()) ?: this - } - } + this@StoreImpl.updateState { onState(this, transform()) ?: this } }, onStop = { checkNotNull(launchJob.getAndSet(null)) { "Store is closed but was not started" } onStop(it) - } - ) { - check(launchJob.getAndSet(coroutineContext.job) == null) { "Store is already started" } - launch intents@{ - coroutineScope { - // run onStart plugins first to not let subscribers appear before the store is started fully - catch { onStart() } - launch { - observeSubscribers( - onSubscribe = { catch { onSubscribe(it) } }, - onUnsubscribe = { catch { onUnsubscribe(it) } } - ) - } - launch { - awaitIntents { - catch { if (onIntent(it) != null && config.debuggable) throw UnhandledIntentException() } + }, + onStart = { + check(launchJob.getAndSet(coroutineContext.job) == null) { "Store is already started" } + launch intents@{ + coroutineScope { + // run onStart plugins first to not let subscribers appear before the store is started fully + catch { onStart() } + launch { + observeSubscribers( + onSubscribe = { catch { onSubscribe(it) } }, + onUnsubscribe = { catch { onUnsubscribe(it) } } + ) + } + launch { + awaitIntents { + catch { if (onIntent(it) != null && config.debuggable) throw UnhandledIntentException() } + } } } } } - } + ) override suspend fun PipelineContext.recover(e: Exception) { withContext(this@StoreImpl) { diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ImmediateDispatcher.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ImmediateDispatcher.kt new file mode 100644 index 00000000..bdfebb59 --- /dev/null +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/ImmediateDispatcher.kt @@ -0,0 +1,26 @@ +package pro.respawn.flowmvi.util + +import kotlinx.atomicfu.atomic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainCoroutineDispatcher + +private var isImmediateSupported: Boolean by atomic(true) + +/** + * Obtain a [MainCoroutineDispatcher.immediate], and if not supported by the current platform, fall back to a + * default [MainCoroutineDispatcher]. + */ +@Suppress("UnusedReceiverParameter") +public val MainCoroutineDispatcher.immediateOrDefault: MainCoroutineDispatcher + get() { + if (isImmediateSupported) { + try { + return Dispatchers.Main.immediate + } catch (ignored: UnsupportedOperationException) { + } catch (ignored: NotImplementedError) { + } + + isImmediateSupported = false + } + return Dispatchers.Main + } diff --git a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/TypeExt.kt b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/TypeExt.kt index 23c2b60e..acb1698e 100644 --- a/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/TypeExt.kt +++ b/core/src/commonMain/kotlin/pro/respawn/flowmvi/util/TypeExt.kt @@ -1,6 +1,7 @@ package pro.respawn.flowmvi.util import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIState import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -26,4 +27,4 @@ public inline fun Any?.typed(): T? = this as? T /** * Get the name of the class, removing the "State" suffix, if present. */ -public inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") +public inline fun nameByType(): String? = T::class.simpleName?.removeSuffix("State") diff --git a/core/src/jvmMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.jvm.kt b/core/src/jvmMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.jvm.kt index 5ae8029a..814a7804 100644 --- a/core/src/jvmMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.jvm.kt +++ b/core/src/jvmMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.jvm.kt @@ -5,7 +5,7 @@ package pro.respawn.flowmvi.logging */ public actual val PlatformStoreLogger: StoreLogger by lazy { StoreLogger { level, tag, message -> - val msg = "${if (tag != null) "$tag: " else ""}${message()}" + val msg = "${level.asSymbol} ${if (tag != null) "$tag: " else ""}${message()}" when (level) { StoreLogLevel.Trace, StoreLogLevel.Debug, StoreLogLevel.Info -> println(msg) StoreLogLevel.Warn, StoreLogLevel.Error -> System.err.println(msg) diff --git a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt index 7f1a0b6a..8ba7673d 100644 --- a/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt +++ b/core/src/jvmTest/kotlin/pro/respawn/flowmvi/util/TestStore.kt @@ -8,7 +8,7 @@ import pro.respawn.flowmvi.dsl.LambdaIntent import pro.respawn.flowmvi.dsl.reduceLambdas import pro.respawn.flowmvi.dsl.store import pro.respawn.flowmvi.plugins.TimeTravel -import pro.respawn.flowmvi.plugins.platformLoggingPlugin +import pro.respawn.flowmvi.plugins.loggingPlugin import pro.respawn.flowmvi.plugins.timeTravel internal typealias TestTimeTravel = TimeTravel, TestAction> @@ -24,8 +24,9 @@ internal fun testStore( debuggable = false name = "TestStore" actionShareBehavior = behavior + atomicStateUpdates = false timeTravel(timeTravel) - install(platformLoggingPlugin(name)) + install(loggingPlugin()) configure() reduceLambdas() } diff --git a/core/src/wasmJsMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.FlowMVI.core.wasmJs.kt b/core/src/wasmJsMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.FlowMVI.core.wasmJs.kt new file mode 100644 index 00000000..3cb95bfe --- /dev/null +++ b/core/src/wasmJsMain/kotlin/pro/respawn/flowmvi/logging/PlatformStoreLogger.FlowMVI.core.wasmJs.kt @@ -0,0 +1,23 @@ +@file:Suppress("TrimMultilineRawString", "UnusedParameter") +package pro.respawn.flowmvi.logging + +private fun log(message: String): Unit = js("""{ console.log(message); }""") + +private fun info(message: String): Unit = js("""{ console.info(message); }""") + +private fun warn(message: String): Unit = js("""{ console.warn(message); }""") + +private fun error(message: String): Unit = js("""{ console.error(message); }""") + +/** + * A [StoreLogger] instance for each supported platform + */ +public actual val PlatformStoreLogger: StoreLogger = StoreLogger { level, tag, message -> + val template = "${if (tag == null) "" else "$tag: "}${message()}()" + when (level) { + StoreLogLevel.Trace, StoreLogLevel.Debug -> log(template) + StoreLogLevel.Info -> info(template) + StoreLogLevel.Warn -> warn(template) + StoreLogLevel.Error -> error(template) + } +} diff --git a/core/src/wasmJsMain/kotlin/pro/respawn/flowmvi/util/ConcurrentHashMap.FlowMVI.core.wasmJs.kt b/core/src/wasmJsMain/kotlin/pro/respawn/flowmvi/util/ConcurrentHashMap.FlowMVI.core.wasmJs.kt new file mode 100644 index 00000000..1d943e8c --- /dev/null +++ b/core/src/wasmJsMain/kotlin/pro/respawn/flowmvi/util/ConcurrentHashMap.FlowMVI.core.wasmJs.kt @@ -0,0 +1,3 @@ +package pro.respawn.flowmvi.util + +internal actual fun concurrentMutableMap(): MutableMap = SynchronizedHashMap() diff --git a/debugger/app/build.gradle.kts b/debugger/app/build.gradle.kts index 783fb9a9..d6f799b4 100644 --- a/debugger/app/build.gradle.kts +++ b/debugger/app/build.gradle.kts @@ -16,15 +16,19 @@ kotlin { sourceSets { val desktopMain by getting + configurations.all { + exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-android") + } commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.animation) + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.desktop.components.splitPane) implementation(compose.animationGraphics) implementation(compose.ui) - @OptIn(ExperimentalComposeLibrary::class) implementation(compose.components.resources) implementation(libs.bundles.serialization) @@ -34,14 +38,16 @@ kotlin { implementation(libs.uuid) implementation(libs.bundles.koin) implementation(libs.kotlin.io) - implementation(projects.core) + implementation(projects.core) + implementation(projects.essenty.essentyCompose) implementation(projects.debugger.server) implementation(projects.debugger.debuggerCommon) implementation(projects.compose) } desktopMain.apply { dependencies { + implementation(libs.kotlin.coroutines.swing) implementation(compose.desktop.currentOs) } } diff --git a/debugger/app/desktop-rules.pro b/debugger/app/desktop-rules.pro index 54ede6bb..4d159c93 100644 --- a/debugger/app/desktop-rules.pro +++ b/debugger/app/desktop-rules.pro @@ -5,3 +5,5 @@ -dontwarn io.netty.handler.** -dontwarn io.netty.util.** -dontwarn kotlinx.coroutines.android.** +-keep class kotlinx.coroutines.internal.MainDispatcherFactory { *; } +-keep class kotlinx.coroutines.swing.SwingDispatcherFactory { *; } diff --git a/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/LifecycleController.kt b/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/LifecycleController.kt new file mode 100644 index 00000000..fb5eca31 --- /dev/null +++ b/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/LifecycleController.kt @@ -0,0 +1,35 @@ +package pro.respawn.flowmvi.debugger.app + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.window.WindowState +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.create +import com.arkivanov.essenty.lifecycle.destroy +import com.arkivanov.essenty.lifecycle.pause +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.essenty.lifecycle.stop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@Composable +fun LifecycleController(lifecycleRegistry: LifecycleRegistry, windowState: WindowState) { + val info = LocalWindowInfo.current + LaunchedEffect(lifecycleRegistry, windowState) { + snapshotFlow(windowState::isMinimized).onEach { isMinimized -> + if (isMinimized) lifecycleRegistry.stop() else lifecycleRegistry.resume() + }.launchIn(this) + + snapshotFlow(info::isWindowFocused).onEach { isFocused -> + if (isFocused) lifecycleRegistry.resume() else lifecycleRegistry.pause() + }.launchIn(this) + } + + DisposableEffect(lifecycleRegistry) { + lifecycleRegistry.create() + onDispose(lifecycleRegistry::destroy) + } +} diff --git a/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/Main.kt b/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/Main.kt index 1c7b6ab4..17e8497c 100644 --- a/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/Main.kt +++ b/debugger/app/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/app/Main.kt @@ -6,25 +6,40 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource import org.koin.compose.KoinContext -import org.koin.core.context.KoinContext +import pro.respawn.flowmvi.compose.dsl.ProvideSubscriberLifecycle import pro.respawn.flowmvi.debugger.app.di.koin import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.TimelineScreen +import pro.respawn.flowmvi.debugger.server.ui.theme.Resources import pro.respawn.flowmvi.debugger.server.ui.theme.RespawnTheme +import pro.respawn.flowmvi.essenty.compose.asSubscriberLifecycle -fun main() = application { - val state = rememberWindowState( - width = 1200.dp, - height = 800.dp, - position = WindowPosition.Aligned(Alignment.Center), - ) - Window( - onCloseRequest = ::exitApplication, - title = "FlowMVI Debugger", - state = state, - ) { - KoinContext(koin) { - RespawnTheme { TimelineScreen() } +@OptIn(ExperimentalResourceApi::class) +fun main() { + val registry = LifecycleRegistry() + application { + val state = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center), + ) + Window( + onCloseRequest = ::exitApplication, + icon = painterResource(Resources.projectIcon), + title = "FlowMVI Debugger", + state = state, + ) { + LifecycleController(registry, state) + KoinContext(koin) { + RespawnTheme { + ProvideSubscriberLifecycle(registry.asSubscriberLifecycle) { + TimelineScreen() + } + } + } } } } diff --git a/debugger/debugger-client/build.gradle.kts b/debugger/debugger-client/build.gradle.kts index 8beb176d..1d180a6e 100644 --- a/debugger/debugger-client/build.gradle.kts +++ b/debugger/debugger-client/build.gradle.kts @@ -8,7 +8,9 @@ plugins { kotlin { configureMultiplatform( this, - watchOs = false, // not supported by all needed ktor artifacts? + // not supported by all needed ktor artifacts? + watchOs = false, + wasmJs = false, ) } android { diff --git a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt index a8d42d7b..3ffb088a 100644 --- a/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt +++ b/debugger/debugger-client/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/client/DebugClientStore.kt @@ -17,6 +17,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withTimeoutOrNull @@ -43,6 +45,7 @@ internal fun debugClientStore( host: String, port: Int, reconnectionDelay: Duration, + logEvents: Boolean = false, ) = store(EmptyState) { val id = uuid4() val session = MutableStateFlow(null) @@ -52,7 +55,7 @@ internal fun debugClientStore( parallelIntents = false // ensure the order of events matches server's expectations actionShareBehavior = Disabled onOverflow = BufferOverflow.DROP_OLDEST // drop old events in the queue - enableLogging() + if (logEvents) enableLogging() val log = this@store.logger recover { log(StoreLogLevel.Error, this@store.name, it) @@ -62,9 +65,11 @@ internal fun debugClientStore( init { launchConnectionLoop( reconnectionDelay, - onError = { - session.value = null - log(StoreLogLevel.Error, this@store.name, it) + onError = { e -> + session.update { + it?.close() + null + } }, ) { log(StoreLogLevel.Debug) { "Starting connection at $host:$port/$id" } @@ -74,14 +79,17 @@ internal fun debugClientStore( port = port, path = "/$id", ).apply { + session.update { + it?.close() + this + } sendSerialized(StoreConnected(clientName, id)) - session.value = this log(StoreLogLevel.Debug, this@store.name) { "Established connection to ${call.request.url}" } awaitEvents { + log(StoreLogLevel.Debug, this@store.name) { "Received event: $it" } when (it) { is ServerEvent.Stop -> close() } - log(StoreLogLevel.Debug, this@store.name) { "Received event: $it" } } } } @@ -91,7 +99,6 @@ internal fun debugClientStore( withTimeoutOrNull(reconnectionDelay) { session.filterNotNull().first().apply { sendSerialized(intent) - log(StoreLogLevel.Debug, this@store.name) { "Sent event $intent to ${call.request.url}" } } } } @@ -102,13 +109,14 @@ private inline fun CoroutineScope.launchConnectionLoop( crossinline onError: suspend (Exception) -> Unit, crossinline connect: suspend () -> Unit, ) = launch { - while (true) { + while (isActive) { try { supervisorScope { connect() awaitCancellation() } } catch (e: CancellationException) { + onError(e) throw e } catch (expected: Exception) { onError(expected) @@ -118,5 +126,5 @@ private inline fun CoroutineScope.launchConnectionLoop( } private suspend inline fun DefaultClientWebSocketSession.awaitEvents(onEvent: (ServerEvent) -> Unit) { - while (true) onEvent(receiveDeserialized()) + while (isActive) onEvent(receiveDeserialized()) } diff --git a/debugger/debugger-common/src/wasmJsMain/kotlin/pro/respawn/flowmvi/debugger/DebuggerDefaults.wasmJs.kt b/debugger/debugger-common/src/wasmJsMain/kotlin/pro/respawn/flowmvi/debugger/DebuggerDefaults.wasmJs.kt new file mode 100644 index 00000000..52630e3e --- /dev/null +++ b/debugger/debugger-common/src/wasmJsMain/kotlin/pro/respawn/flowmvi/debugger/DebuggerDefaults.wasmJs.kt @@ -0,0 +1,5 @@ +package pro.respawn.flowmvi.debugger + +import pro.respawn.flowmvi.debugger.DebuggerDefaults.LocalHost + +internal actual val DefaultHost: String = LocalHost diff --git a/debugger/debugger-plugin/build.gradle.kts b/debugger/debugger-plugin/build.gradle.kts index 938d7858..6bbb0b46 100644 --- a/debugger/debugger-plugin/build.gradle.kts +++ b/debugger/debugger-plugin/build.gradle.kts @@ -13,6 +13,8 @@ kotlin { tvOs = false, linux = false, js = false, + wasmJs = false, + windows = false, ) } diff --git a/debugger/server/build.gradle.kts b/debugger/server/build.gradle.kts index 2f5a86a4..0b5a224d 100644 --- a/debugger/server/build.gradle.kts +++ b/debugger/server/build.gradle.kts @@ -1,12 +1,8 @@ -import org.jetbrains.compose.ExperimentalComposeLibrary - plugins { id(libs.plugins.kotlinMultiplatform.id) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.serialization) } -val props by localProperties - kotlin { jvm { compilations.all { @@ -16,7 +12,6 @@ kotlin { sourceSets { commonMain.dependencies { - @OptIn(ExperimentalComposeLibrary::class) implementation(compose.components.resources) implementation(compose.runtime) implementation(compose.foundation) @@ -41,6 +36,7 @@ kotlin { implementation(projects.debugger.debuggerCommon) } jvmMain.dependencies { + implementation(libs.kotlin.coroutines.swing) implementation(compose.desktop.common) } } diff --git a/debugger/server/src/commonMain/composeResources/drawable/icon_192.png b/debugger/server/src/commonMain/composeResources/drawable/icon_192.png new file mode 100644 index 00000000..33d90941 Binary files /dev/null and b/debugger/server/src/commonMain/composeResources/drawable/icon_192.png differ diff --git a/debugger/app/src/commonMain/resources/font/comfortaa.ttf b/debugger/server/src/commonMain/composeResources/font/comfortaa.ttf similarity index 100% rename from debugger/app/src/commonMain/resources/font/comfortaa.ttf rename to debugger/server/src/commonMain/composeResources/font/comfortaa.ttf diff --git a/debugger/app/src/commonMain/resources/font/montserrat.ttf b/debugger/server/src/commonMain/composeResources/font/montserrat.ttf similarity index 100% rename from debugger/app/src/commonMain/resources/font/montserrat.ttf rename to debugger/server/src/commonMain/composeResources/font/montserrat.ttf diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt index 1514d152..a37ffcb9 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServer.kt @@ -11,10 +11,10 @@ import io.ktor.server.websocket.receiveDeserialized import io.ktor.server.websocket.sendSerialized import io.ktor.server.websocket.webSocket import kotlinx.atomicfu.atomic +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import pro.respawn.flowmvi.api.Container import pro.respawn.flowmvi.debugger.model.ClientEvent import pro.respawn.flowmvi.debugger.model.ClientEvent.StoreDisconnected @@ -45,22 +45,18 @@ internal object DebugServer : Container val storeId = call.parameters.getOrFail("id").asUUID with(store) { try { - supervisorScope { - subscribe { - actions - .filterIsInstance() - .filter { it.client == storeId } - .collect { sendSerialized(it.event) } - } - launch { - while (true) { - val event = receiveDeserialized() - logger.invoke(StoreLogLevel.Debug) { "received event $event" } - intent(EventReceived(event, storeId)) - } - } + subscribe { + actions + .filterIsInstance() + .filter { it.client == storeId } + .collect { sendSerialized(it.event) } + } + while (true) { + val event = receiveDeserialized() + intent(EventReceived(event, storeId)) } } finally { + logger(StoreLogLevel.Debug) { "Store $storeId disconnected" } intent(EventReceived(StoreDisconnected(storeId), storeId)) } } @@ -70,7 +66,7 @@ internal object DebugServer : Container .also { server = it } .start() - fun stop() { + suspend fun stop() = withContext(Dispatchers.IO) { store.intent(StopRequested) server?.stop() server = null diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt index da51e2f5..57d940b0 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/DebugServerContract.kt @@ -36,7 +36,11 @@ internal sealed interface ServerState : MVIState { data class Running( val clients: PersistentMap = persistentMapOf(), val eventLog: PersistentList = persistentListOf(), - ) : ServerState + ) : ServerState { + + override fun toString() = + "Running(clients=${clients.count { it.value.isConnected }}, logSize = ${eventLog.size})" + } } internal sealed interface ServerIntent : MVIIntent { diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/Main.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/Main.kt deleted file mode 100644 index c063a62f..00000000 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/Main.kt +++ /dev/null @@ -1 +0,0 @@ -package pro.respawn.flowmvi.debugger.server diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt index a0f37a43..ea9aabdf 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineContract.kt @@ -30,6 +30,7 @@ internal data class TimelineFilters( internal data class StoreItem( val id: Uuid, val name: String, + val isConnected: Boolean, ) @Immutable diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineScreen.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineScreen.kt index d726683a..e630ba1b 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineScreen.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineScreen.kt @@ -1,6 +1,7 @@ package pro.respawn.flowmvi.debugger.server.ui.screens.timeline import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items @@ -27,7 +29,11 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource import pro.respawn.flowmvi.api.IntentReceiver +import pro.respawn.flowmvi.compose.api.SubscriptionMode +import pro.respawn.flowmvi.compose.dsl.DefaultLifecycle import pro.respawn.flowmvi.compose.dsl.subscribe import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.TimelineAction.CopyToClipboard import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.TimelineAction.ScrollToItem @@ -41,6 +47,8 @@ import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.widgets.StoreEven import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.widgets.TimelineMenuBar import pro.respawn.flowmvi.debugger.server.ui.widgets.DynamicTwoPaneLayout import pro.respawn.flowmvi.debugger.server.ui.widgets.RTextInput +import pro.respawn.flowmvi.server.generated.resources.Res +import pro.respawn.flowmvi.server.generated.resources.icon_192 import java.time.format.DateTimeFormatter /** @@ -53,7 +61,7 @@ fun TimelineScreen() { val listState = rememberLazyListState() val clipboard = LocalClipboardManager.current with(store) { - val state by subscribe { + val state by subscribe(DefaultLifecycle, SubscriptionMode.Started) { when (it) { is ScrollToItem -> listState.animateScrollToItem(it.index) is CopyToClipboard -> clipboard.setText(AnnotatedString(it.text)) @@ -67,24 +75,27 @@ fun TimelineScreen() { } } -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalResourceApi::class) @Composable private fun IntentReceiver.TimelineScreenContent( state: TimelineState, - listState: LazyListState + listState: LazyListState, ) { val timestampFormatter = remember { DateTimeFormatter.ISO_DATE_TIME } when (state) { - is TimelineState.ConfiguringServer -> { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - RTextInput(state.host, onTextChange = { intent(HostChanged(it)) }, label = "Host") - RTextInput(state.port, onTextChange = { intent(PortChanged(it)) }, label = "Port") - TextButton(onClick = { intent(StartServerClicked) }, enabled = state.canStart) { Text("Connect") } - } + is TimelineState.ConfiguringServer -> Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(Res.drawable.icon_192), + modifier = Modifier.padding(64.dp).size(120.dp), + contentDescription = null, + ) + RTextInput(state.host, onTextChange = { intent(HostChanged(it)) }, label = "Host") + RTextInput(state.port, onTextChange = { intent(PortChanged(it)) }, label = "Port") + TextButton(onClick = { intent(StartServerClicked) }, enabled = state.canStart) { Text("Connect") } } is TimelineState.Error -> Column { Text("An error has occurred", fontSize = 32.sp) diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineStore.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineStore.kt index 0a803495..85ed140a 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineStore.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/TimelineStore.kt @@ -78,7 +78,7 @@ internal fun timelineStore(scope: CoroutineScope) = store .toPersistentList(), stores = state.clients .asSequence() - .map { StoreItem(it.key, it.value.name) } + .map { StoreItem(it.key, it.value.name, it.value.isConnected) } .toPersistentList(), ).also { val hasFocusedItem = it.focusedEvent != null diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/StoreSelectorDropDown.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/StoreSelectorDropDown.kt index 0354b3af..ff2f247a 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/StoreSelectorDropDown.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/StoreSelectorDropDown.kt @@ -1,6 +1,10 @@ package pro.respawn.flowmvi.debugger.server.ui.screens.timeline.widgets import androidx.compose.animation.AnimatedVisibility +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.SignalWifi4Bar +import androidx.compose.material.icons.rounded.SignalWifiOff +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,6 +30,9 @@ internal fun IntentReceiver.StoreSelectorDropDown( ) { AnimatedVisibility(visible = stores.isNotEmpty(), modifier = modifier) { var expanded by remember { mutableStateOf(false) } + val connectedColor = MaterialTheme.colorScheme.primary + val errorColor = MaterialTheme.colorScheme.error + RDropDownMenu( expanded = expanded, onExpand = { expanded = !expanded }, @@ -36,17 +43,16 @@ internal fun IntentReceiver.StoreSelectorDropDown( }, actions = rememberDropDownActions(stores) { buildList { - add( - DropDownActions.Action("All stores") { - intent(TimelineIntent.StoreFilterSelected(null)) - } - ) + DropDownActions.Action("All stores") { + intent(TimelineIntent.StoreFilterSelected(null)) + }.let(::add) stores.forEach { - add( - DropDownActions.Action(text = it.name) { - intent(TimelineIntent.StoreFilterSelected(it)) - } - ) + DropDownActions.Action( + text = it.name, + icon = if (it.isConnected) Icons.Rounded.SignalWifi4Bar else Icons.Rounded.SignalWifiOff, + iconTint = if (it.isConnected) connectedColor else errorColor, + onClick = { intent(TimelineIntent.StoreFilterSelected(it)) }, + ).let(::add) } } }, diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/TimelineMenuBar.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/TimelineMenuBar.kt index 9fe3eb1b..03641a0a 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/TimelineMenuBar.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/screens/timeline/widgets/TimelineMenuBar.kt @@ -6,9 +6,8 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ExitToApp import androidx.compose.material.icons.rounded.Done -import androidx.compose.material.icons.rounded.ExitToApp -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -24,7 +23,7 @@ import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.EventType import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.TimelineIntent import pro.respawn.flowmvi.debugger.server.ui.screens.timeline.TimelineState -@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun IntentReceiver.TimelineMenuBar( state: TimelineState.DisplayingTimeline, @@ -38,7 +37,7 @@ internal fun IntentReceiver.TimelineMenuBar( ), modifier = Modifier.padding(8.dp) ) { - Icon(Icons.Rounded.ExitToApp, contentDescription = null) + Icon(Icons.AutoMirrored.Rounded.ExitToApp, contentDescription = null) } OutlinedButton( onClick = { intent(TimelineIntent.AutoScrollToggled) }, diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Resources.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Resources.kt new file mode 100644 index 00000000..bea64fa6 --- /dev/null +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Resources.kt @@ -0,0 +1,12 @@ +@file:Suppress("UndocumentedPublicProperty", "UndocumentedPublicClass") + +package pro.respawn.flowmvi.debugger.server.ui.theme + +import org.jetbrains.compose.resources.ExperimentalResourceApi +import pro.respawn.flowmvi.server.generated.resources.Res +import pro.respawn.flowmvi.server.generated.resources.icon_192 + +object Resources { + @OptIn(ExperimentalResourceApi::class) + val projectIcon = Res.drawable.icon_192 +} diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Type.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Type.kt index b729c242..8b8757e8 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Type.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/theme/Type.kt @@ -1,4 +1,5 @@ @file:Suppress("UndocumentedPublicClass", "UndocumentedPublicProperty") +@file:OptIn(ExperimentalResourceApi::class) package pro.respawn.flowmvi.debugger.server.ui.theme @@ -8,6 +9,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.Typography import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -16,165 +18,178 @@ import androidx.compose.ui.text.platform.Font import androidx.compose.ui.text.style.Hyphens import androidx.compose.ui.text.style.LineBreak import androidx.compose.ui.unit.sp +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.Font +import pro.respawn.flowmvi.server.generated.resources.Res +import pro.respawn.flowmvi.server.generated.resources.comfortaa +import pro.respawn.flowmvi.server.generated.resources.montserrat -val Comfortaa = Font("font/comfortaa.ttf").toFontFamily() -val Montserrat = Font("font/montserrat.ttf").toFontFamily() +val Comfortaa @Composable get() = Font(Res.font.comfortaa).toFontFamily() +val Montserrat @Composable get() = Font(Res.font.montserrat).toFontFamily() -inline val FontFamily.Companion.Montserrat get() = pro.respawn.flowmvi.debugger.server.ui.theme.Montserrat -inline val FontFamily.Companion.Comfortaa get() = pro.respawn.flowmvi.debugger.server.ui.theme.Comfortaa +inline val FontFamily.Companion.Montserrat @Composable get() = pro.respawn.flowmvi.debugger.server.ui.theme.Montserrat +inline val FontFamily.Companion.Comfortaa @Composable get() = pro.respawn.flowmvi.debugger.server.ui.theme.Comfortaa private const val FontFeatures = "dlig, liga, kern, zero, locl, size" // region Typography -internal val AppTypography = Typography( - displayLarge = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W700, - fontSize = 48.sp, - lineHeight = 58.sp, - letterSpacing = 5.sp, - lineBreak = LineBreak.Heading, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - displayMedium = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W600, - fontSize = 40.sp, - lineHeight = 54.sp, - letterSpacing = 4.sp, - lineBreak = LineBreak.Heading, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - displaySmall = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W600, - fontSize = 32.sp, - lineHeight = 42.sp, - letterSpacing = 3.sp, - lineBreak = LineBreak.Heading, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - headlineLarge = TextStyle( - fontFamily = Montserrat, - fontWeight = FontWeight.W500, - fontSize = 30.sp, - lineHeight = 38.sp, - letterSpacing = 2.sp, - lineBreak = LineBreak.Simple, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - headlineMedium = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W500, - fontSize = 26.sp, - lineHeight = 34.sp, - letterSpacing = 1.5.sp, - lineBreak = LineBreak.Simple, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - headlineSmall = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W500, - fontSize = 23.sp, - lineHeight = 32.sp, - letterSpacing = 1.sp, - lineBreak = LineBreak.Simple, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - titleLarge = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W500, - fontSize = 22.sp, - lineHeight = 30.sp, - letterSpacing = 0.9.sp, - lineBreak = LineBreak.Simple, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - titleMedium = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W500, - fontSize = 19.sp, - lineHeight = 28.sp, - letterSpacing = 0.8.sp, - lineBreak = LineBreak.Simple, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - titleSmall = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W500, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.7.sp, - lineBreak = LineBreak.Simple, - hyphens = Hyphens.None, - fontFeatureSettings = FontFeatures, - ), - bodyLarge = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W400, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.7.sp, - lineBreak = LineBreak.Paragraph, - hyphens = Hyphens.Auto, - fontFeatureSettings = FontFeatures, - ), - bodyMedium = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W400, - fontSize = 14.sp, - lineHeight = 21.sp, - letterSpacing = 0.6.sp, - lineBreak = LineBreak.Paragraph, - hyphens = Hyphens.Auto, - fontFeatureSettings = FontFeatures, - ), - bodySmall = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W400, - fontSize = 12.sp, - lineHeight = 18.sp, - letterSpacing = 0.5.sp, - lineBreak = LineBreak.Paragraph, - hyphens = Hyphens.Auto, - fontFeatureSettings = FontFeatures, - ), - labelLarge = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W300, - fontSize = 14.sp, - lineHeight = 20.sp, - letterSpacing = 0.5.sp, - lineBreak = LineBreak.Simple, - fontFeatureSettings = FontFeatures, - ), - labelMedium = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W300, - fontSize = 12.sp, - lineHeight = 18.sp, - letterSpacing = 0.4.sp, - lineBreak = LineBreak.Simple, - fontFeatureSettings = FontFeatures, - ), - labelSmall = TextStyle( - fontFamily = Comfortaa, - fontWeight = FontWeight.W300, - fontSize = 11.sp, - lineHeight = 16.sp, - letterSpacing = 0.4.sp, - lineBreak = LineBreak.Simple, - fontFeatureSettings = FontFeatures, - ), -) +internal val AppTypography + @Composable + get() = run { + val comfortaa = Comfortaa + val montserrat = Montserrat + remember { + Typography( + displayLarge = TextStyle( + fontFamily = montserrat, + fontWeight = FontWeight.W700, + fontSize = 48.sp, + lineHeight = 58.sp, + letterSpacing = 5.sp, + lineBreak = LineBreak.Heading, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + displayMedium = TextStyle( + fontFamily = montserrat, + fontWeight = FontWeight.W600, + fontSize = 40.sp, + lineHeight = 54.sp, + letterSpacing = 4.sp, + lineBreak = LineBreak.Heading, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + displaySmall = TextStyle( + fontFamily = montserrat, + fontWeight = FontWeight.W600, + fontSize = 32.sp, + lineHeight = 42.sp, + letterSpacing = 3.sp, + lineBreak = LineBreak.Heading, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + headlineLarge = TextStyle( + fontFamily = montserrat, + fontWeight = FontWeight.W500, + fontSize = 30.sp, + lineHeight = 38.sp, + letterSpacing = 2.sp, + lineBreak = LineBreak.Simple, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + headlineMedium = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W500, + fontSize = 26.sp, + lineHeight = 34.sp, + letterSpacing = 1.5.sp, + lineBreak = LineBreak.Simple, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + headlineSmall = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W500, + fontSize = 23.sp, + lineHeight = 32.sp, + letterSpacing = 1.sp, + lineBreak = LineBreak.Simple, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + titleLarge = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W500, + fontSize = 22.sp, + lineHeight = 30.sp, + letterSpacing = 0.9.sp, + lineBreak = LineBreak.Simple, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + titleMedium = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W500, + fontSize = 19.sp, + lineHeight = 28.sp, + letterSpacing = 0.8.sp, + lineBreak = LineBreak.Simple, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + titleSmall = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W500, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.7.sp, + lineBreak = LineBreak.Simple, + hyphens = Hyphens.None, + fontFeatureSettings = FontFeatures, + ), + bodyLarge = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W400, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.7.sp, + lineBreak = LineBreak.Paragraph, + hyphens = Hyphens.Auto, + fontFeatureSettings = FontFeatures, + ), + bodyMedium = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W400, + fontSize = 14.sp, + lineHeight = 21.sp, + letterSpacing = 0.6.sp, + lineBreak = LineBreak.Paragraph, + hyphens = Hyphens.Auto, + fontFeatureSettings = FontFeatures, + ), + bodySmall = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W400, + fontSize = 12.sp, + lineHeight = 18.sp, + letterSpacing = 0.5.sp, + lineBreak = LineBreak.Paragraph, + hyphens = Hyphens.Auto, + fontFeatureSettings = FontFeatures, + ), + labelLarge = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W300, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.5.sp, + lineBreak = LineBreak.Simple, + fontFeatureSettings = FontFeatures, + ), + labelMedium = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W300, + fontSize = 12.sp, + lineHeight = 18.sp, + letterSpacing = 0.4.sp, + lineBreak = LineBreak.Simple, + fontFeatureSettings = FontFeatures, + ), + labelSmall = TextStyle( + fontFamily = comfortaa, + fontWeight = FontWeight.W300, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + lineBreak = LineBreak.Simple, + fontFeatureSettings = FontFeatures, + ), + ) + } + } // endregion @Preview diff --git a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/widgets/RDropDownMenu.kt b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/widgets/RDropDownMenu.kt index 7cd8db56..51e10c42 100644 --- a/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/widgets/RDropDownMenu.kt +++ b/debugger/server/src/commonMain/kotlin/pro/respawn/flowmvi/debugger/server/ui/widgets/RDropDownMenu.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Box import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable @@ -13,6 +14,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.window.PopupProperties @Composable @@ -30,6 +32,8 @@ internal data class DropDownActions( data class Action( val text: String, val tint: Color? = null, + val icon: ImageVector? = null, + val iconTint: Color = Color.Unspecified, val badged: Boolean = false, val onClick: () -> Unit, ) @@ -62,6 +66,9 @@ internal fun RDropDownMenu( actions.actions.forEach { action -> DropdownMenuItem( text = { Text(text = action.text) }, + leadingIcon = icon@{ + Icon(action.icon ?: return@icon, contentDescription = null, tint = action.iconTint) + }, onClick = { action.onClick() onExpand() diff --git a/docs/_navbar.md b/docs/_navbar.md index 63c1f404..05f04b53 100644 --- a/docs/_navbar.md +++ b/docs/_navbar.md @@ -5,7 +5,6 @@ * [Saving State](savedstate.md) * [Remote Debugging](debugging.md) * [FAQ](faq.md) -* [Roadmap](roadmap.md) * [Javadocs](https://opensource.respawn.pro/FlowMVI/javadocs/index.html) * [Contributing](CONTRIBUTING.md) * [Migrating from v1](migration.md) diff --git a/docs/debugging.md b/docs/debugging.md index 54717cdf..15388f09 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -70,14 +70,15 @@ be picked up by the compiler. We'll have to resort to "notepad-style coding" for ### 1.3 Set up store configuration injection -Set up config injection using a factory pattern using your DI framework. Example below is for Koin: +?> If you're building a small pet project, you may omit this complicated setup and just use conditional +installation if you know the risks you are taking. + +Set up config injection using a factory pattern using your DI framework: ```kotlin interface StoreConfiguration { operator fun StoreBuilder.invoke(name: String) - - fun saver(serializer: KSerializer, fileName: String): Saver } inline fun StoreBuilder.configure( @@ -134,18 +135,15 @@ internal class CounterContainer( } ``` -?> If you're building a small pet project, you may omit this complicated setup and just use conditional -installation if you know the risks you are taking. - ## Step 2: Connect the client on Android ?> You can skip this step if you don't target Android -On all platforms except the android, we can just use the default host and port for debugging (localhost). But if you +On all platforms except Android, we can just use the default host and port for debugging (localhost). But if you use an external device or an emulator on Android, you need to configure the host yourself. -For emulators, the plugin will use the emulator host by default `10.0.2.2`. We will need to allow cleartext traffic on -that hosts and our local network hosts +For emulators, the plugin will use the emulator host by default (`10.0.2.2`). We will need to allow cleartext traffic on +that host and our local network hosts In your `common-arch` module we created earlier, or in the `app` module, create a network security configuration **for debug builds only**. @@ -209,7 +207,6 @@ The setup is a little bit more complicated, but in short, it involves: ## Next Steps Right now the debugging setup includes only the essentials. -Check out the [roadmap](roadmap.md) to learn what we will be cooking up next in the future releases. Feel free to create an issue for a feature you want to be added. diff --git a/docs/index.html b/docs/index.html index 11a0d1c6..b5809347 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,8 +6,25 @@ + + + + + + + + + + + + + + + + + @@ -20,7 +37,8 @@ --code-inline-color: var(--theme-color); --theme-color: #00d46a; --selection-color: rgba(0, 212, 106, 0.3); - --code-font-family: "Monaspace", "Inconsolata", monospace; + --code-font-family: "Monaspace Argon", "Inconsolata", monospace; + --base-font-family: "Montserrat", sans-serif; } /* custom theme overrides for versioned plugin*/ .app-name { diff --git a/docs/quickstart.md b/docs/quickstart.md index ae98b0e3..8b3ff9b2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -269,6 +269,13 @@ val store = store(Loading) { // set // UNLIMITED, CONFLATED, RENDEZVOUS, BUFFERED var intentCapacity = Channel.UNLIMITED + // Enables transaction serialization for state updates, making state updates atomic and suspendable. + // Synchronizes state updates, allowing only **one** client to read and/or update the state at a time. + // All other clients attempt to get the state will wait on a FIFO queue and suspend the parent coroutine. + // For one-time usage of non-atomic updates, see [useState]. + // Has a small performance impact because of coroutine context switching and mutex usage when enabled. + var atomicStateUpdates = true + // Install a prebuilt plugin. The order of plugins matters! // Plugins will preserve the order of installation and will proceed according to this order. // Installation of the same plugin multiple times is not allowed. diff --git a/docs/roadmap.md b/docs/roadmap.md deleted file mode 100644 index 8666d1ad..00000000 --- a/docs/roadmap.md +++ /dev/null @@ -1,17 +0,0 @@ -## Roadmap for v2.x: - -- [x] ~~Docs for DI~~ -- [ ] Docs for how to implement analytics -- [ ] Docs on how to manage states -- [ ] Docs on reusing and injecting store configurations -- [x] ~~Add tests for plugins~~ -- [x] ~~Job manager plugin that can be accessed to cancel / start long-running jobs~~ -- [x] ~~Undo/Redo plugin~~ -- [x] ~~Implement params support for DI~~ -- [x] ~~Implement a better native logging with expect/actual~~ -- [x] ~~Composable store configurations, reusing some part of the store configuration~~ -- [x] ~~DSL for composing stores to seamlessly split responsibilities~~ -- [x] ~~More debug checks and optimizations~~ -- [x] ~~Compose Multiplatform support~~ -- [ ] Better iOS support -- [ ] Better JS support diff --git a/essenty/build.gradle.kts b/essenty/build.gradle.kts new file mode 100644 index 00000000..7fea74a6 --- /dev/null +++ b/essenty/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("maven-publish") + signing +} + +kotlin { + configureMultiplatform( + ext = this, + tvOs = false, + watchOs = false, + linux = false, + windows = false + ) +} + +android { + configureAndroidLibrary(this) + namespace = "${Config.namespace}.essenty" +} + +dependencies { + commonMainApi(projects.core) + + commonMainApi(libs.essenty.lifecycle) + commonMainApi(libs.essenty.instancekeeper) +} + +publishMultiplatform() diff --git a/essenty/essenty-compose/build.gradle.kts b/essenty/essenty-compose/build.gradle.kts new file mode 100644 index 00000000..5446f218 --- /dev/null +++ b/essenty/essenty-compose/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id(libs.plugins.kotlinMultiplatform.id) + id(libs.plugins.androidLibrary.id) + alias(libs.plugins.jetbrainsCompose) + id("maven-publish") + signing +} + +android { + configureAndroidLibrary(this) + namespace = "${Config.namespace}.essenty.compose" + + buildFeatures { + compose = true + } +} + +kotlin { + configureMultiplatform( + ext = this, + tvOs = false, + watchOs = false, + linux = false, + windows = false, + ) + sourceSets { + androidMain.dependencies { + implementation(libs.compose.foundation) + implementation(libs.compose.preview) + implementation(libs.compose.lifecycle.runtime) + api(projects.android) + } + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + api(projects.core) + api(projects.compose) + + api(libs.essenty.lifecycle) + api(libs.essenty.lifecycle.coroutines) + api(libs.essenty.instancekeeper) + } + jvmMain.dependencies { + implementation(compose.desktop.common) + implementation(libs.compose.lifecycle.runtime) + } + } +} + +publishMultiplatform() + +dependencies { +} diff --git a/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/CompositionLocals.kt b/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/CompositionLocals.kt new file mode 100644 index 00000000..e5e60e21 --- /dev/null +++ b/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/CompositionLocals.kt @@ -0,0 +1,20 @@ +package pro.respawn.flowmvi.essenty.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import com.arkivanov.essenty.lifecycle.LifecycleOwner +import pro.respawn.flowmvi.compose.dsl.LocalSubscriberLifecycle +import pro.respawn.flowmvi.compose.dsl.requireLifecycle + +/** + * Provides a local Essenty lifecycle [owner] through a [LocalSubscriberLifecycle]. + * Can be used in conjunction with [requireLifecycle] afterwards + */ +@Composable +public fun ProvideSubscriberLifecycle( + owner: LifecycleOwner, + content: @Composable () -> Unit +): Unit = CompositionLocalProvider( + LocalSubscriberLifecycle provides owner.lifecycle.asSubscriberLifecycle, + content = content, +) diff --git a/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/Lifecycle.kt b/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/Lifecycle.kt new file mode 100644 index 00000000..ef7c59d6 --- /dev/null +++ b/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/Lifecycle.kt @@ -0,0 +1,41 @@ +package pro.respawn.flowmvi.essenty.compose + +import androidx.compose.runtime.Stable +import com.arkivanov.essenty.lifecycle.Lifecycle +import com.arkivanov.essenty.lifecycle.LifecycleOwner +import com.arkivanov.essenty.lifecycle.coroutines.repeatOnLifecycle +import pro.respawn.flowmvi.compose.api.SubscriberLifecycle +import pro.respawn.flowmvi.compose.api.SubscriptionMode + +/** + * Convert this [LifecycleOwner] to a [SubscriberLifecycle]. + */ +@Stable +public val Lifecycle.asSubscriberLifecycle: SubscriberLifecycle + get() = SubscriberLifecycle { mode, block -> repeatOnLifecycle(mode.asEssentyLifecycle, block = block) } + +/** + * Convert this [SubscriptionMode] to an Essenty [Lifecycle.State] + */ +@Stable +public val SubscriptionMode.asEssentyLifecycle: Lifecycle.State + get() = when (this) { + SubscriptionMode.Immediate -> Lifecycle.State.CREATED + SubscriptionMode.Started -> Lifecycle.State.STARTED + SubscriptionMode.Visible -> Lifecycle.State.RESUMED + } + +/** + * Convert this Essenty [Lifecycle.State] to a [SubscriptionMode]. + * + * [Lifecycle.State.DESTROYED] and [Lifecycle.State.INITIALIZED] are not supported by + * Essenty as valid subscription modes and wll throw an [IllegalStateException] + */ +public val Lifecycle.State.asSubscriptionMode: SubscriptionMode + get() = when (this) { + Lifecycle.State.CREATED -> SubscriptionMode.Immediate + Lifecycle.State.STARTED -> SubscriptionMode.Started + Lifecycle.State.RESUMED -> SubscriptionMode.Visible + Lifecycle.State.DESTROYED, + Lifecycle.State.INITIALIZED -> error("Essenty does not provide support for using $name as subscriber lifecycle") + } diff --git a/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/SubscribeDsl.kt b/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/SubscribeDsl.kt new file mode 100644 index 00000000..55dfddb3 --- /dev/null +++ b/essenty/essenty-compose/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/compose/SubscribeDsl.kt @@ -0,0 +1,54 @@ +package pro.respawn.flowmvi.essenty.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import com.arkivanov.essenty.lifecycle.Lifecycle +import com.arkivanov.essenty.lifecycle.LifecycleOwner +import kotlinx.coroutines.CoroutineScope +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.ImmutableStore +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.compose.dsl.requireLifecycle +import pro.respawn.flowmvi.compose.dsl.subscribe +import pro.respawn.flowmvi.dsl.subscribe + +/** + * An alias for [subscribe] that uses the provided Essenty [LifecycleOwner] for subscription. + * + * In case you provide a lifecycle using [ProvideSubscriberLifecycle], use [requireLifecycle] as an argument. + * + * See the parent function documentation for more details on how the composable subscribes to the store. + * @see subscribe + */ +@Suppress("ComposableParametersOrdering") +@Composable +@FlowMVIDSL +public inline fun ImmutableStore.subscribe( + lifecycleOwner: LifecycleOwner, + lifecycleState: Lifecycle.State = Lifecycle.State.STARTED, + noinline consume: suspend CoroutineScope.(action: A) -> Unit, +): State = subscribe( + lifecycle = lifecycleOwner.lifecycle.asSubscriberLifecycle, + mode = lifecycleState.asSubscriptionMode, + consume = consume +) + +/** + * An alias for [subscribe] that uses the provided Essenty [LifecycleOwner] for subscription. + * + * In case you provide a lifecycle using [ProvideSubscriberLifecycle], use [requireLifecycle] as an argument. + * + * See the parent function documentation for more details on how the composable subscribes to the store. + * @see subscribe + */ +@Composable +@FlowMVIDSL +public inline fun ImmutableStore.subscribe( + lifecycleOwner: LifecycleOwner, + lifecycleState: Lifecycle.State = Lifecycle.State.CREATED, +): State = subscribe( + lifecycle = lifecycleOwner.lifecycle.asSubscriberLifecycle, + mode = lifecycleState.asSubscriptionMode +) diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedScope.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedScope.kt new file mode 100644 index 00000000..2e71d19a --- /dev/null +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedScope.kt @@ -0,0 +1,40 @@ +package pro.respawn.flowmvi.essenty.dsl + +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.InstanceKeeperOwner +import com.arkivanov.essenty.instancekeeper.getOrCreate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import pro.respawn.flowmvi.essenty.internal.RetainedScope +import pro.respawn.flowmvi.util.immediateOrDefault +import kotlin.coroutines.CoroutineContext + +private const val DefaultScopeKey = "CoroutineScope" + +internal fun createRetainedScope( + context: CoroutineContext = Dispatchers.Main.immediateOrDefault +): RetainedScope = object : RetainedScope, CoroutineScope by CoroutineScope(context + SupervisorJob(context[Job])) {} + +/** + * Creates OR obtains a [CoroutineScope] + * instance that is retained across configuration changes using this [InstanceKeeper]. + * + * Uses an [immediateOrDefault] dispatcher as the coroutine context by default. + */ +public fun InstanceKeeper.retainedScope( + context: CoroutineContext = Dispatchers.Main.immediateOrDefault, + key: String = DefaultScopeKey, +): CoroutineScope = getOrCreate(key) { createRetainedScope(context) } + +/** + * Creates OR obtains a [CoroutineScope] + * instance that is retained across configuration changes using this [InstanceKeeper]. + * + * Uses an [immediateOrDefault] dispatcher as the coroutine context by default. + */ +public fun InstanceKeeperOwner.retainedScope( + context: CoroutineContext = Dispatchers.Main.immediateOrDefault, + key: String = DefaultScopeKey, +): CoroutineScope = instanceKeeper.retainedScope(context, key) diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedStore.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedStore.kt new file mode 100644 index 00000000..2cff6ef8 --- /dev/null +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedStore.kt @@ -0,0 +1,72 @@ +@file:Suppress("Indentation") // conflict between detekt <> ide +package pro.respawn.flowmvi.essenty.dsl + +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.InstanceKeeperOwner +import com.arkivanov.essenty.instancekeeper.getOrCreate +import kotlinx.coroutines.CoroutineScope +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.essenty.internal.retained +import pro.respawn.flowmvi.util.nameByType + +// keeper + +/** + * Creates and retains a new [Store] instance provided using [factory] using this [InstanceKeeper]. + * + * * Uses the State class name as a key for the instance keeper by default. + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + */ +@FlowMVIDSL +public inline fun InstanceKeeper.retainedStore( + scope: CoroutineScope? = retainedScope(), + key: Any = "${requireNotNull(nameByType())}Store", + @BuilderInference factory: () -> Store, +): Store = getOrCreate(key) { retained(factory(), scope) } + +/** + * Creates and retains a new [Store] instance provided using [factory] using this [InstanceKeeper]. + * + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + */ +@FlowMVIDSL +public inline fun InstanceKeeper.retainedStore( + key: Any, + scope: CoroutineScope? = retainedScope(), + factory: () -> Store, +): Store = getOrCreate(key) { retained(factory(), scope) } + +// keeper owner + +/** + * Creates and retains a new [Store] instance provided using [factory] using this [InstanceKeeper]. + * + * * Uses the State class name as a key for the instance keeper by default. + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + */ +@FlowMVIDSL +public inline fun InstanceKeeperOwner.retainedStore( + key: Any, + scope: CoroutineScope? = retainedScope(), + factory: () -> Store, +): Store = instanceKeeper.retainedStore(key, scope, factory) + +/** + * Creates and retains a new [Store] instance provided using [factory] using this [InstanceKeeper]. + * + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + */ +@FlowMVIDSL +public inline fun InstanceKeeperOwner.retainedStore( + scope: CoroutineScope? = retainedScope(), + key: Any = "${requireNotNull(nameByType())}Store", + @BuilderInference factory: () -> Store, +): Store = instanceKeeper.retainedStore(key, scope, factory) diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedStoreBuilder.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedStoreBuilder.kt new file mode 100644 index 00000000..2891f622 --- /dev/null +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/dsl/RetainedStoreBuilder.kt @@ -0,0 +1,94 @@ +package pro.respawn.flowmvi.essenty.dsl + +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.InstanceKeeperOwner +import com.arkivanov.essenty.instancekeeper.getOrCreate +import kotlinx.coroutines.CoroutineScope +import pro.respawn.flowmvi.api.FlowMVIDSL +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Store +import pro.respawn.flowmvi.dsl.BuildStore +import pro.respawn.flowmvi.dsl.store +import pro.respawn.flowmvi.essenty.internal.retain +import pro.respawn.flowmvi.util.nameByType + +// keeper + +/** + * Creates and retains a new [Store] instance built using [builder] using this [InstanceKeeper]. + * + * * Uses [name] as both the store name and the instance keeper's key parameter. + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + * + * See [store] for more details. + */ +@FlowMVIDSL +public inline fun InstanceKeeper.retainedStore( + initial: S, + name: String, + scope: CoroutineScope? = retainedScope(), + @BuilderInference builder: BuildStore, +): Store = getOrCreate(name) { + store(initial) { + this.name = name + builder() + }.retain(scope) +} + +/** + * Creates and retains a new [Store] instance built using [builder] using this [InstanceKeeper]. + * + * * Uses [name] as both the store name and the instance keeper's key parameter. By default, the store's name will be + * derived from the [S] parameter's class name, such as 'CounterState' -> 'CounterStore'. + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + * + * See [store] for more details. + */ +@FlowMVIDSL +public inline fun InstanceKeeper.retainedStore( + initial: S, + scope: CoroutineScope? = retainedScope(), + name: String = "${requireNotNull(nameByType())}Store", + @BuilderInference builder: BuildStore, +): Store = retainedStore(initial, name, scope, builder) + +// owner + +/** + * Creates and retains a new [Store] instance built using [builder] using this [InstanceKeeper]. + * + * * Uses [name] as both the store name and the instance keeper's key parameter. + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + * + * See [store] for more details. + */ +@FlowMVIDSL +public inline fun InstanceKeeperOwner.retainedStore( + initial: S, + name: String, + scope: CoroutineScope? = retainedScope(), + @BuilderInference builder: BuildStore, +): Store = instanceKeeper.retainedStore(initial, name, scope, builder) + +/** + * Creates and retains a new [Store] instance built using [builder] using this [InstanceKeeper]. + * + * * Uses [name] as both the store name and the instance keeper's key parameter. By default, the store's name will be + * derived from the [S] parameter's class name, such as 'CounterState' -> 'CounterStore'. + * * By default, uses a [retainedScope] instance to launch the store automatically. + * Provide `null` to not launch the store after creation. + * + * See [store] for more details. + */ +@FlowMVIDSL +public inline fun InstanceKeeperOwner.retainedStore( + initial: S, + scope: CoroutineScope? = retainedScope(), + name: String = "${requireNotNull(nameByType())}Store", + @BuilderInference builder: BuildStore, +): Store = instanceKeeper.retainedStore(initial, name, scope, builder) diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/Dsl.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/Dsl.kt new file mode 100644 index 00000000..c2f06cad --- /dev/null +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/Dsl.kt @@ -0,0 +1,22 @@ +package pro.respawn.flowmvi.essenty.internal + +import kotlinx.coroutines.CoroutineScope +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Store + +@PublishedApi +internal fun retained( + store: Store, + scope: CoroutineScope?, +): RetainedStore = object : Store by store, RetainedStore { + init { + if (scope != null) start(scope) + } +} + +@PublishedApi +internal fun Store.retain( + scope: CoroutineScope? +): RetainedStore = retained(this, scope) diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/RetainedScope.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/RetainedScope.kt new file mode 100644 index 00000000..8d0ae133 --- /dev/null +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/RetainedScope.kt @@ -0,0 +1,10 @@ +package pro.respawn.flowmvi.essenty.internal + +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel + +internal interface RetainedScope : CoroutineScope, InstanceKeeper.Instance { + + override fun onDestroy(): Unit = cancel() +} diff --git a/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/RetainedStore.kt b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/RetainedStore.kt new file mode 100644 index 00000000..ce5c306f --- /dev/null +++ b/essenty/src/commonMain/kotlin/pro/respawn/flowmvi/essenty/internal/RetainedStore.kt @@ -0,0 +1,12 @@ +package pro.respawn.flowmvi.essenty.internal + +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import pro.respawn.flowmvi.api.MVIAction +import pro.respawn.flowmvi.api.MVIIntent +import pro.respawn.flowmvi.api.MVIState +import pro.respawn.flowmvi.api.Store + +internal interface RetainedStore : Store, InstanceKeeper.Instance { + + override fun onDestroy(): Unit = close() +} diff --git a/gradle.properties b/gradle.properties index f647f915..26921be4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,3 +28,5 @@ kotlin.experimental.tryK2=false android.lint.useK2Uast=true ksp.useKSP2=false nl.littlerobots.vcu.resolver=true +org.jetbrains.compose.experimental.jscanvas.enabled=true +org.jetbrains.compose.experimental.wasm.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be312267..fc9361b8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] activity = "1.8.2" apiresult = "1.0.3" -compose = "1.6.2" +compose = "1.6.3" compose-compiler = "1.5.10" -compose-plugin = "1.5.12" +compose-plugin = "1.6.0" composeDetektPlugin = "1.3.0" core-ktx = "1.12.0" coroutines = "1.8.0" @@ -11,7 +11,7 @@ datetime = "0.6.0-RC.2" dependencyAnalysisPlugin = "1.29.0" detekt = "1.23.5" detektFormattingPlugin = "1.23.5" -dokka = "1.9.10" +dokka = "1.9.20" fragment = "1.6.2" gradleAndroid = "8.3.0" gradleDoctorPlugin = "0.9.1" @@ -24,16 +24,18 @@ kotest = "5.8.0" kotest-plugin = "5.8.0" # @pin kotlin = "1.9.22" +kotlin-collections = "0.3.7" kotlin-io = "0.3.1" kotlinx-atomicfu = "0.23.2" ktor = "3.0.0-beta-1" lifecycle = "2.7.0" material = "1.11.0" serialization = "1.6.3" -turbine = "1.0.0" +turbine = "1.1.0" uuid = "0.8.2" versionCatalogUpdatePlugin = "0.8.4" -kotlin-collections = "0.3.7" +decompose = "3.0.0-alpha08" +essenty = "2.0.0-alpha07" [libraries] android-gradle = { module = "com.android.tools.build:gradle", version.ref = "gradleAndroid" } @@ -50,6 +52,12 @@ compose-material = { module = "androidx.compose.material:material", version.ref compose-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } compose-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } + +# decompose +essenty-instancekeeper = { module = "com.arkivanov.essenty:instance-keeper", version.ref = "essenty" } +essenty-lifecycle = { module = "com.arkivanov.essenty:lifecycle", version.ref = "essenty" } +essenty-lifecycle-coroutines = { module = "com.arkivanov.essenty:lifecycle-coroutines", version.ref = "essenty" } + # detekt detekt-compose = { module = "ru.kode:detekt-rules-compose", version.ref = "composeDetektPlugin" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detektFormattingPlugin" } @@ -76,8 +84,10 @@ kotest-junit = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotes kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" } # kotlin kotlin-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinx-atomicfu" } +kotlin-collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlin-collections" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } +kotlin-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines" } kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "datetime" } kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -85,7 +95,6 @@ kotlin-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "k kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "serialization" } kotlin-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } -kotlin-collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlin-collections" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-common", version.ref = "kotlin" } # ktor client ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } diff --git a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt index d3255cc3..f9d1c698 100644 --- a/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt +++ b/savedstate/src/androidMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/SavedStateSaver.kt @@ -4,6 +4,7 @@ package pro.respawn.flowmvi.savedstate.dsl import android.os.Parcelable import androidx.lifecycle.SavedStateHandle +import pro.respawn.flowmvi.api.MVIState import pro.respawn.flowmvi.savedstate.api.Saver import pro.respawn.flowmvi.savedstate.api.ThrowRecover import pro.respawn.flowmvi.util.nameByType @@ -30,8 +31,8 @@ public fun SavedStateHandleSaver( * * The [key] parameter is derived from the simple class name of the state by default. */ -public inline fun ParcelableSaver( +public inline fun ParcelableSaver( handle: SavedStateHandle, key: String = nameByType() ?: "State", noinline recover: suspend (e: Exception) -> T? = ThrowRecover, -): Saver = SavedStateHandleSaver(handle, key, recover) +): Saver where T : Parcelable, T : MVIState = SavedStateHandleSaver(handle, key, recover) diff --git a/savedstate/src/wasmJsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.wasmJs.kt b/savedstate/src/wasmJsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.wasmJs.kt new file mode 100644 index 00000000..1c08d21e --- /dev/null +++ b/savedstate/src/wasmJsMain/kotlin/pro/respawn/flowmvi/savedstate/dsl/Compress.wasmJs.kt @@ -0,0 +1,7 @@ +package pro.respawn.flowmvi.savedstate.dsl + +import kotlinx.io.files.Path + +internal actual suspend fun writeCompressed(data: String, to: Path) = write(data, to) + +internal actual suspend fun readCompressed(from: Path): String? = read(from) diff --git a/settings.gradle.kts b/settings.gradle.kts index 151aeb04..4a45158b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,6 +43,8 @@ include(":android-compose") include(":android-view") include(":compose") include(":savedstate") +include(":essenty") +include(":essenty:essenty-compose") include(":debugger:app") include(":debugger:debugger-client") include(":debugger:debugger-plugin")