diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b840bda..d8780c6 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -33,7 +33,7 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Check with gradle - run: ./gradlew runUnitTests :examples:lce:assembleDebug :examples:welcome:welcome:assembleDebug :examples:multi:navbar:assembleDebug :examples:multi:parallel:assembleDebug :examples:lifecycle:assembleDebug --no-daemon --no-configuration-cache + run: ./gradlew runUnitTests :examples:lce:assembleDebug :examples:welcome:welcome:assembleDebug :examples:multi:navbar:assembleDebug :examples:multi:parallel:assembleDebug :examples:lifecycle:assembleDebug :examples:di:app:assembleDebug :examples:book:app:assembleDebug :examples:book:book:demo:assembleDebug --no-daemon --no-configuration-cache - name: Upload problems report uses: actions/upload-artifact@v4 if: failure() diff --git a/.gitignore b/.gitignore index 0846040..cdbd6ea 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,7 @@ *.iml .gradle /local.properties -/.idea/artifacts -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml -/.idea/copilot -/.idea/*.xd +/.idea .DS_Store /build /.kotlin diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee..0000000 --- a/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml deleted file mode 100644 index ba530bd..0000000 --- a/.idea/appInsightsSettings.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b589d56..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/copyright/Apache.xml b/.idea/copyright/Apache.xml deleted file mode 100644 index e7e1510..0000000 --- a/.idea/copyright/Apache.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/copyright/profiles_settings.xml b/.idea/copyright/profiles_settings.xml deleted file mode 100644 index 2102cda..0000000 --- a/.idea/copyright/profiles_settings.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index bcdc4a4..0000000 --- a/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml deleted file mode 100644 index bf3356d..0000000 --- a/.idea/deploymentTargetSelector.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 2b26efb..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 103e00c..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index 6d0ee1c..0000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml deleted file mode 100644 index f8051a6..0000000 --- a/.idea/migrations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index b2c751a..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 16660f1..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/studiobot.xml b/.idea/studiobot.xml deleted file mode 100644 index 539e3b8..0000000 --- a/.idea/studiobot.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index 2398ba4..9af699e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Please checkout the Medium article on pattern/library usage. + [Gestures and view-states](#gestures-and-view-states) + [View implementation](#view-implementation) + [Adopting foreign state-flow](#adopting-foreign-state-flow) + + [Common flow API](#common-child-flow-api) - [Running state-machines in parallel (composition)](#running-state-machines-in-parallel-composition) * [MultiMachineState](#multimachinestate) * [ProxyMachineContainer](#proxymachinecontainer) @@ -135,6 +136,7 @@ val commonMain by getting { - [Navbar](examples/multi/navbar) - several machines running in proxy state, one of them active at a time - [Mixed](examples/multi/mixed) - two machines of different gesture/UI system mixed in one state - [Lifecycle](examples/lifecycle) - track your Android app lifecycle to pause pending operations when the app is suspended +- [DI](examples/di) - late child flow binding, allows for a dynamic module ([read more](#common-child-flow-api)) - [Contacts](https://github.com/Android-Developer-Basic/Contacts) - more or less real world KMP/CMP application with network and database ## The basic task - Load-Content-Error @@ -1174,6 +1176,42 @@ or to advance to `Complete` state as described in [Common Api](#common-api). The this interface by switching host machine to email or complete states in corresponding `backToEmailEntry` and `complete` functions. +#### Common child flow API + +For your convenience there are couple of ready-made interfaces to adopt child flow. +The interfaces are located in a separate libraries: + +```groovy +dependencies { + // If you use it for state machines only + implementation "com.motorro.commonstatemachine:commonflow-data:x.x.x" + // If you go with compose + implementation "com.motorro.commonstatemachine:commonflow-compose:x.x.x" +} +``` + +The libraries include the following interfaces: + +- [CommonFlowHost](commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowHost.kt) - + the interface the proxy should provide to the child flow. Child uses this flow to signal its termination. +- [CommonFlowDataApi](commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowDataApi.kt) - + contains methods to initiate the flow and to adapt it to the hosting flow. +- [CommonFlowUiApi](commonflow/compose/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/compose/CommonFlowUiApi.kt) - + An interface with the single `Screen` method to inject and put to the composition + +For more details on multiplatform compose viewmodel follow [this link](https://kotlinlang.org/docs/multiplatform/compose-viewmodel.html) + +Check the [example](examples/di) that shows the use of this flow: + +- The app has two states: `Content` and `Auth`. +- `Content` requires authenticated user. +- We have a basic [authentication flow](examples/di/api) flow defined. +- We have two implementation modules [Login](examples/di/login) and [Social](examples/di/social) that authenticate users + using different authentication "providers". +- The [app](examples/di/app) module has two build variants to support each. +- The concrete sub-flow is provided with the late DI binding using `Hilt` + + ## Running state-machines in parallel (composition) In case you want several state-machines to run in parallel producing a single combined UI state or you diff --git a/build.gradle.kts b/build.gradle.kts index 988edfb..1e74ba5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,29 +100,38 @@ tasks.register("runCoroutinesTests") { description = "Run unit tests for the coroutines extension layer." } -tasks.register("runRegisterUnitTests") { - dependsOn(":examples:welcome:commonregister:allTests") - description = "Run unit tests for the common register layer." +tasks.register("runCommonflowTests") { + dependsOn(":commonflow:viewmodel:allTests") + description = "Run unit tests for the commonflow library." } -tasks.register("runLoginUnitTests") { - dependsOn(":examples:welcome:login:testDebugUnitTest") - description = "Run unit tests for the login module." -} - -tasks.register("runLceUnitTests") { +tasks.register("runLceExampleUnitTests") { dependsOn(":examples:lce:testDebugUnitTest") description = "Run unit tests for LCE app." } -tasks.register("runWelcomeUnitTests") { - dependsOn(":examples:welcome:welcome:testDebugUnitTest") // Note: double colon might be a typo, usually ':examples:welcome:testDebugUnitTest' +tasks.register("runWelcomeExampleUnitTests") { + dependsOn(":examples:welcome:commonregister:allTests") + dependsOn(":examples:welcome:login:testDebugUnitTest") + dependsOn(":examples:welcome:welcome:testDebugUnitTest") description = "Run unit tests for welcome app." } -tasks.register("runTimerUnitTests") { +tasks.register("runTimerExampleUnitTests") { dependsOn(":examples:timer:testAndroidHostTest") - description = "Run unit tests for welcome app." // Description seems to be a copy-paste from runWelcomeUnitTests + description = "Run unit tests for timer library." +} + +tasks.register("runDiExampleUnitTests") { + dependsOn(":examples:di:login:testDebugUnitTest") + dependsOn(":examples:di:social:testDebugUnitTest") + description = "Run unit tests for di app." +} + +tasks.register("runBooksExampleUnitTests") { + dependsOn(":examples:books:book:testDebugUnitTest") + dependsOn(":examples:books:app:testDebugUnitTest") + description = "Run unit tests for books app." } tasks.register("displayVersion") { @@ -136,11 +145,12 @@ tasks.register("runUnitTests") { dependsOn( "runStateMachineTests", "runCoroutinesTests", - "runLoginUnitTests", - "runRegisterUnitTests", - "runLceUnitTests", - "runWelcomeUnitTests", - "runTimerUnitTests" + "runCommonflowTests", + "runLceExampleUnitTests", + "runWelcomeExampleUnitTests", + "runTimerExampleUnitTests", + "runDiExampleUnitTests", + "runBooksExampleUnitTests" ) group = "verification" description = "Run unit tests for all modules." diff --git a/commonflow/compose/build.gradle.kts b/commonflow/compose/build.gradle.kts new file mode 100644 index 0000000..3c027d0 --- /dev/null +++ b/commonflow/compose/build.gradle.kts @@ -0,0 +1,159 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.compose) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.kotlin.dokka) + id("maven-publish") + id("signing") +} + +val versionName: String by project.extra +val androidMinSdkVersion: Int by project.extra +val androidTargetSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +group = rootProject.group +version = rootProject.version + +println("== Project version: $versionName ==") + +kotlin { + jvmToolchain(17) + + jvm() + android { + namespace = "com.motorro.commonstatemachine.commonflow.compose" + compileSdk = androidCompileSdkVersion + minSdk = androidMinSdkVersion + + withHostTest { + isIncludeAndroidResources = true + } + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + js(IR) { + binaries.library() + useCommonJs() + browser { + testTask(Action { + useMocha { + timeout = "10s" + } + }) + } + } + + wasmJs { + binaries.library() + useCommonJs() + browser { + testTask(Action { + useMocha { + timeout = "10s" + } + }) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "commonflow-compose" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + api(project(":commonstatemachine")) + api(project(":commonflow:data")) + api(libs.composeMultiplatform.runtime) + api(libs.composeMultiplatform.foundation) + } + } +} +val javadocJar by tasks.registering(Jar::class) { + dependsOn(tasks.dokkaGenerate) + group = "documentation" + archiveClassifier.set("javadoc") + from(tasks.dokkaGenerate) +} + +val libId = "commonflow-compose" +val libName = "commonflow-compose" +val libDesc = "Common compose flow for modularized state machines" +val projectUrl: String by project.extra +val projectScm: String by project.extra +val ossrhUsername: String? by rootProject.extra +val ossrhPassword: String? by rootProject.extra +val developerId: String by project.extra +val developerName: String by project.extra +val developerEmail: String by project.extra +val signingKey: String? by rootProject.extra +val signingPassword: String? by rootProject.extra + +publishing { + publications.withType { + artifact(javadocJar) + pom { + name.set(libName) + description.set(libDesc) + url.set(projectUrl) + licenses { + license { + name.set("Apache-2.0") + url.set("https://apache.org/licenses/LICENSE-2.0") + } + } + developers { + developer { + id.set(developerId) + name.set(developerName) + email.set(developerEmail) + } + } + scm { + connection.set(projectScm) + developerConnection.set(projectScm) + url.set(projectUrl) + } + } + } +} + +signing { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications) +} + +val signingTasks = tasks.withType() +tasks.withType().configureEach { + dependsOn(signingTasks) +} \ No newline at end of file diff --git a/commonflow/compose/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/compose/CommonFlowUiApi.kt b/commonflow/compose/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/compose/CommonFlowUiApi.kt new file mode 100644 index 0000000..ff4027d --- /dev/null +++ b/commonflow/compose/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/compose/CommonFlowUiApi.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.commonstatemachine.flow.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Common flow UI API. Used as a common interface between master and child flow using proxy. + * @param G - gesture type + * @param U - UI state type + * @see com.motorro.commonstatemachine.ProxyMachineState + * @see com.motorro.commonstatemachine.flow.data.CommonFlowHost + * @see com.motorro.commonstatemachine.flow.data.CommonFlowDataApi + */ +interface CommonFlowUiApi { + /** + * Provides composition + */ + @Composable + fun Screen( + state: U, + onGesture: (G) -> Unit, + modifier: Modifier = Modifier + ) +} \ No newline at end of file diff --git a/commonflow/data/build.gradle.kts b/commonflow/data/build.gradle.kts new file mode 100644 index 0000000..8012d94 --- /dev/null +++ b/commonflow/data/build.gradle.kts @@ -0,0 +1,154 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.kotlin.dokka) + id("maven-publish") + id("signing") +} + +val versionName: String by project.extra +val androidMinSdkVersion: Int by project.extra +val androidTargetSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +group = rootProject.group +version = rootProject.version + +println("== Project version: $versionName ==") + +kotlin { + jvmToolchain(17) + + jvm() + android { + namespace = "com.motorro.commonstatemachine.commonflow.data" + compileSdk = androidCompileSdkVersion + minSdk = androidMinSdkVersion + + withHostTest { + isIncludeAndroidResources = true + } + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + js(IR) { + binaries.library() + useCommonJs() + browser { + testTask(Action { + useMocha { + timeout = "10s" + } + }) + } + } + + wasmJs { + binaries.library() + useCommonJs() + browser { + testTask(Action { + useMocha { + timeout = "10s" + } + }) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "commonflow-data" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + api(project(":commonstatemachine")) + } + } +} +val javadocJar by tasks.registering(Jar::class) { + dependsOn(tasks.dokkaGenerate) + group = "documentation" + archiveClassifier.set("javadoc") + from(tasks.dokkaGenerate) +} + +val libId = "commonflow-data" +val libName = "commonflow-data" +val libDesc = "Common data flow for modularized state machines" +val projectUrl: String by project.extra +val projectScm: String by project.extra +val ossrhUsername: String? by rootProject.extra +val ossrhPassword: String? by rootProject.extra +val developerId: String by project.extra +val developerName: String by project.extra +val developerEmail: String by project.extra +val signingKey: String? by rootProject.extra +val signingPassword: String? by rootProject.extra + +publishing { + publications.withType { + artifact(javadocJar) + pom { + name.set(libName) + description.set(libDesc) + url.set(projectUrl) + licenses { + license { + name.set("Apache-2.0") + url.set("https://apache.org/licenses/LICENSE-2.0") + } + } + developers { + developer { + id.set(developerId) + name.set(developerName) + email.set(developerEmail) + } + } + scm { + connection.set(projectScm) + developerConnection.set(projectScm) + url.set(projectUrl) + } + } + } +} + +signing { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications) +} + +val signingTasks = tasks.withType() +tasks.withType().configureEach { + dependsOn(signingTasks) +} \ No newline at end of file diff --git a/commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowDataApi.kt b/commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowDataApi.kt new file mode 100644 index 0000000..633dd12 --- /dev/null +++ b/commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowDataApi.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.commonstatemachine.flow.data + +import com.motorro.commonstatemachine.CommonMachineState + +/** + * Common flow data API. Used as a common interface between master and child flow using proxy. + * The proxy provides [CommonFlowHost] to the child flow and the child calls its methods when finished + * or otherwise needed + * @param G - gesture type + * @param U - UI state type + * @param I - input type + * @param R - result type + * @see com.motorro.commonstatemachine.ProxyMachineState + * @see CommonFlowHost + */ +interface CommonFlowDataApi { + /** + * Creates flow + */ + fun init(flowHost: CommonFlowHost, input: I): CommonMachineState + + /** + * Returns default UI state + */ + fun getDefaultUiState(): U + + /** + * Returns back gesture mapping + */ + fun getBackGesture(): G? = null +} \ No newline at end of file diff --git a/commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowHost.kt b/commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowHost.kt new file mode 100644 index 0000000..e669c5f --- /dev/null +++ b/commonflow/data/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/data/CommonFlowHost.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.commonstatemachine.flow.data + +/** + * Common flow host. + * Used as a common interface between master and child flow using proxy. + * @param R - result type + * @see com.motorro.commonstatemachine.ProxyMachineState + * @see CommonFlowDataApi + */ +fun interface CommonFlowHost { + /** + * Completes common flow + * @param result Optional result. + */ + fun onComplete(result: R) +} \ No newline at end of file diff --git a/commonflow/viewmodel/build.gradle.kts b/commonflow/viewmodel/build.gradle.kts new file mode 100644 index 0000000..578552f --- /dev/null +++ b/commonflow/viewmodel/build.gradle.kts @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") +@file:OptIn(ExperimentalWasmDsl::class) + +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.compose) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.kotlin.dokka) + id("maven-publish") + id("signing") +} + +val versionName: String by project.extra +val androidMinSdkVersion: Int by project.extra +val androidTargetSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +group = rootProject.group +version = rootProject.version + +println("== Project version: $versionName ==") + +kotlin { + jvmToolchain(17) + + jvm() + android { + namespace = "com.motorro.commonstatemachine.commonflow.viewmodel" + compileSdk = androidCompileSdkVersion + minSdk = androidMinSdkVersion + + withHostTest { + isIncludeAndroidResources = true + } + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + js(IR) { + binaries.library() + useCommonJs() + browser { + testTask(Action { + useMocha { + timeout = "10s" + } + }) + } + } + + wasmJs { + binaries.library() + useCommonJs() + browser { + testTask(Action { + useMocha { + timeout = "10s" + } + }) + } + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "commonflow-viewmodel" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + api(project(":commonstatemachine")) + api(project(":coroutines")) + api(project(":commonflow:compose")) + api(libs.composeMultiplatform.viewmodel) + implementation(libs.composeMultiplatform.lifecycle) + } + commonTest.dependencies { + implementation(libs.test.kotlin) + implementation(libs.test.kotlin.coroutines) + } + androidMain.dependencies { + api(libs.androidx.appcompat) + implementation(libs.androidx.activity) + implementation(libs.androidx.fragment) + implementation(libs.compose.activity) + } + } +} +val javadocJar by tasks.registering(Jar::class) { + dependsOn(tasks.dokkaGenerate) + group = "documentation" + archiveClassifier.set("javadoc") + from(tasks.dokkaGenerate) +} + +val libId = "commonflow-viewmodel" +val libName = "commonflow-viewmodel" +val libDesc = "Common view components to wrap standard flow" +val projectUrl: String by project.extra +val projectScm: String by project.extra +val ossrhUsername: String? by rootProject.extra +val ossrhPassword: String? by rootProject.extra +val developerId: String by project.extra +val developerName: String by project.extra +val developerEmail: String by project.extra +val signingKey: String? by rootProject.extra +val signingPassword: String? by rootProject.extra + +publishing { + publications.withType { + artifact(javadocJar) + pom { + name.set(libName) + description.set(libDesc) + url.set(projectUrl) + licenses { + license { + name.set("Apache-2.0") + url.set("https://apache.org/licenses/LICENSE-2.0") + } + } + developers { + developer { + id.set(developerId) + name.set(developerName) + email.set(developerEmail) + } + } + scm { + connection.set(projectScm) + developerConnection.set(projectScm) + url.set(projectUrl) + } + } + } +} + +signing { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications) +} + +val signingTasks = tasks.withType() +tasks.withType().configureEach { + dependsOn(signingTasks) +} \ No newline at end of file diff --git a/commonflow/viewmodel/src/androidMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/ViewModelComposition_android.kt b/commonflow/viewmodel/src/androidMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/ViewModelComposition_android.kt new file mode 100644 index 0000000..7de8d39 --- /dev/null +++ b/commonflow/viewmodel/src/androidMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/ViewModelComposition_android.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.commonstatemachine.flow.viewmodel + +import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModelProvider.Factory +import androidx.lifecycle.viewmodel.CreationExtras + +/** + * Builds state machine composition + * Ensure that [AppCompatActivity] has correct view model factory + * @param extrasProducer optional extras producer + * @param factoryProducer optional factory producer + * @param setResult implement to set the activity result or do any result processing + * @param navigationBackHandler optional back navigation handler. Implement to pass back navigation to the view model + * @param content content block + */ +inline fun > ComponentActivity.setStateMachineContent( + noinline extrasProducer: (() -> CreationExtras)? = null, + noinline factoryProducer: (() -> Factory)? = null, + noinline setResult: (R?) -> Unit = { }, + noinline navigationBackHandler: @Composable (Boolean, () -> Unit) -> Unit = { enabled, onBack -> + BackHandler(enabled = enabled) { onBack() } + }, + noinline content: @Composable (U, (G) -> Unit) -> Unit +) { + setContent { + val viewModel: VM by viewModels( + extrasProducer = extrasProducer, + factoryProducer = factoryProducer + ) + CommonFlowComposition( + viewModel = viewModel, + navigationBackHandler = navigationBackHandler, + content = content, + finish = { result -> + if (null != result) { + setResult(result) + } + finish() + } + ) + } +} + +/** + * Builds state machine composable + * Ensure that [Fragment] has correct view model factory + * @param onFinish called when the flow is finished + * @param extrasProducer optional extras producer + * @param factoryProducer optional factory producer + * @param navigationBackHandler optional back navigation handler. Implement to pass back navigation to the view model + * @param content content block + */ +inline fun > Fragment.createStateMachineView( + noinline onFinish: (R?) -> Unit, + noinline extrasProducer: (() -> CreationExtras)? = null, + noinline factoryProducer: (() -> Factory)? = null, + noinline navigationBackHandler: @Composable (Boolean, () -> Unit) -> Unit = { enabled, onBack -> + BackHandler(enabled = enabled) { onBack() } + }, + noinline content: @Composable (U, (G) -> Unit) -> Unit +): View = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + + setContent { + val viewModel: VM by viewModels( + extrasProducer = extrasProducer, + factoryProducer = factoryProducer + ) + CommonFlowComposition( + viewModel = viewModel, + navigationBackHandler = navigationBackHandler, + content = content, + finish = onFinish + ) + } +} diff --git a/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowComposition.kt b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowComposition.kt new file mode 100644 index 0000000..0a6604f --- /dev/null +++ b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowComposition.kt @@ -0,0 +1,44 @@ +package com.motorro.commonstatemachine.flow.viewmodel + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.motorro.commonstatemachine.flow.viewmodel.data.BaseFlowGesture +import com.motorro.commonstatemachine.flow.viewmodel.data.BaseFlowUiState + +/** + * Binds [CommonFlowViewModel] to the screen composition + * @param G - gesture type + * @param U - UI state type + * @param R - result type + * @param viewModel View-model instance + * @param navigationBackHandler Back navigation handler slot. Call the supplied function when back is detected + * @param content Content view. Accepts the UI state and gesture handler as parameters + * @param finish Called when the flow is finished + * @see CommonFlowViewModel + */ +@Composable +@OptIn(ExperimentalComposeUiApi::class) +fun < G: Any, U: Any, R> CommonFlowComposition( + viewModel: CommonFlowViewModel, + navigationBackHandler: @Composable (enabled: Boolean, onBack: () -> Unit) -> Unit = { _, _ -> }, + content: @Composable (U, (G) -> Unit) -> Unit, + finish: (R?) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val process = remember<(G) -> Unit> { + { viewModel.process(BaseFlowGesture.Child(it)) } + } + + navigationBackHandler(uiState.backHandlerEnabled) { viewModel.process(BaseFlowGesture.Back) } + + when (val state = uiState) { + is BaseFlowUiState.Child -> content(state.child, process) + is BaseFlowUiState.Terminated -> LaunchedEffect(Unit) { + finish(state.result) + } + } +} \ No newline at end of file diff --git a/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowViewModel.kt b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowViewModel.kt new file mode 100644 index 0000000..8073c1b --- /dev/null +++ b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowViewModel.kt @@ -0,0 +1,80 @@ +package com.motorro.commonstatemachine.flow.viewmodel + +import androidx.lifecycle.ViewModel +import com.motorro.commonstatemachine.ProxyMachineState +import com.motorro.commonstatemachine.coroutines.FlowStateMachine +import com.motorro.commonstatemachine.flow.data.CommonFlowDataApi +import com.motorro.commonstatemachine.flow.data.CommonFlowHost +import com.motorro.commonstatemachine.flow.viewmodel.data.BaseFlowGesture +import com.motorro.commonstatemachine.flow.viewmodel.data.BaseFlowUiState +import kotlinx.coroutines.flow.StateFlow + +/** + * Wraps child flow and adapts it to the base flow to use in standard views + * @param G - gesture type + * @param U - UI state type + * @param I - input type + * @param R - result type + * @param api - flow data API + * @param init - initial input + * @param closeables the resources to be closed when the [ViewModel] is cleared, right **before** the [onCleared] method is called. + * @see CommonFlowDataApi + * @see CommonFlowHost + */ +open class CommonFlowViewModel( + private val api: CommonFlowDataApi, + private val init: I, + vararg closeables: AutoCloseable +) : ViewModel(*closeables) { + + companion object { + /** + * A cleanup key to associate with the state-machine + */ + const val CLOSABLE_KEY = "CommonFlowStateMachine" + } + + /** + * Child flow proxy + */ + private val stateMachine = FlowStateMachine( + BaseFlowUiState.Child( + child = api.getDefaultUiState(), + backHandlerEnabled = null != api.getBackGesture() + ) + ) { + object : ProxyMachineState, BaseFlowUiState, G, U>(api.getDefaultUiState()) { + + private var terminated = false + private val flowHost = CommonFlowHost { result -> + if (terminated.not()) { + terminated = true + setUiState(BaseFlowUiState.Terminated(result)) + } + } + + override fun init() = api.init(flowHost, init) + override fun mapGesture(parent: BaseFlowGesture): G? = when(parent) { + BaseFlowGesture.Back -> api.getBackGesture() + is BaseFlowGesture.Child -> parent.child + } + override fun mapUiState(child: U): BaseFlowUiState = BaseFlowUiState.Child(child, null != api.getBackGesture()) + } + } + + init { + addCloseable(CLOSABLE_KEY, AutoCloseable { + stateMachine.clear() + }) + } + + /** + * Exported UI state + */ + val uiState: StateFlow> get() = stateMachine.uiState + + /** + * Delegates gesture processing to the state-machine + */ + fun process(gesture: BaseFlowGesture) = stateMachine.process(gesture) +} \ No newline at end of file diff --git a/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/data/BaseFlowGesture.kt b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/data/BaseFlowGesture.kt new file mode 100644 index 0000000..bf2e55e --- /dev/null +++ b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/data/BaseFlowGesture.kt @@ -0,0 +1,16 @@ +package com.motorro.commonstatemachine.flow.viewmodel.data + +/** + * Wraps child gesture to adapt it to the hosting view component + */ +sealed class BaseFlowGesture { + /** + * Back navigation gesture + */ + data object Back : BaseFlowGesture() + + /** + * Child flow gesture + */ + data class Child(val child: G) : BaseFlowGesture() +} diff --git a/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/data/BaseFlowUiState.kt b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/data/BaseFlowUiState.kt new file mode 100644 index 0000000..eda52c1 --- /dev/null +++ b/commonflow/viewmodel/src/commonMain/kotlin/com/motorro/commonstatemachine/flow/viewmodel/data/BaseFlowUiState.kt @@ -0,0 +1,20 @@ +package com.motorro.commonstatemachine.flow.viewmodel.data + +/** + * Wraps child UI state to adapt it to the hosting view component + */ +sealed class BaseFlowUiState { + + abstract val backHandlerEnabled: Boolean + + /** + * Child UI state + */ + data class Child(val child: U, override val backHandlerEnabled: Boolean) : BaseFlowUiState() + /** + * Flow terminated + */ + data class Terminated(val result: R?) : BaseFlowUiState() { + override val backHandlerEnabled: Boolean = false + } +} diff --git a/commonflow/viewmodel/src/commonTest/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowViewModelTest.kt b/commonflow/viewmodel/src/commonTest/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowViewModelTest.kt new file mode 100644 index 0000000..aafe745 --- /dev/null +++ b/commonflow/viewmodel/src/commonTest/kotlin/com/motorro/commonstatemachine/flow/viewmodel/CommonFlowViewModelTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.commonstatemachine.flow.viewmodel + +import com.motorro.commonstatemachine.flow.viewmodel.data.BaseFlowGesture +import com.motorro.commonstatemachine.flow.viewmodel.data.BaseFlowUiState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class CommonFlowViewModelTest { + + private companion object { + const val INIT = 1 + const val RESULT = "result" + } + + private lateinit var dispatcher: TestDispatcher + private lateinit var api: TestDataApi + private lateinit var model: CommonFlowViewModel + + @BeforeTest + fun init() { + dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + + api = TestDataApi() + model = CommonFlowViewModel(api, INIT) + } + + @AfterTest + fun deinit() { + Dispatchers.resetMain() + } + + private fun test(block: suspend TestScope.() -> Unit) = runTest( + dispatcher, + testBody = block + ) + + @Test + fun startsApiAndWrapsUiState() = test { + assertEquals(INIT, api.state.init) + assertEquals( + BaseFlowUiState.Child(TestUiState.RUNNING, true), + model.uiState.value + ) + } + + @Test + fun passesChildGesture() = test { + model.process(BaseFlowGesture.Child(TestGesture.Stop(RESULT))) + assertEquals(BaseFlowUiState.Terminated(RESULT), model.uiState.value) + } + + @Test + fun mapsBackGesture() = test { + model.process(BaseFlowGesture.Back) + assertEquals(BaseFlowUiState.Terminated(null), model.uiState.value) + } +} \ No newline at end of file diff --git a/commonflow/viewmodel/src/commonTest/kotlin/com/motorro/commonstatemachine/flow/viewmodel/TestFlowApi.kt b/commonflow/viewmodel/src/commonTest/kotlin/com/motorro/commonstatemachine/flow/viewmodel/TestFlowApi.kt new file mode 100644 index 0000000..1dc1576 --- /dev/null +++ b/commonflow/viewmodel/src/commonTest/kotlin/com/motorro/commonstatemachine/flow/viewmodel/TestFlowApi.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.commonstatemachine.flow.viewmodel + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.commonstatemachine.flow.data.CommonFlowDataApi +import com.motorro.commonstatemachine.flow.data.CommonFlowHost + +sealed class TestGesture { + data object Back : TestGesture() + data class Stop(val result: String?) : TestGesture() +} + +enum class TestUiState { + LOADING, + RUNNING +} + +class TestFlowState( + private val flowHost: CommonFlowHost, + val init: Int +) : CommonMachineState() { + + var cleared = false + + override fun doStart() { + setUiState(TestUiState.RUNNING) + } + + override fun doProcess(gesture: TestGesture) { + when (gesture) { + is TestGesture.Back -> flowHost.onComplete(null) + is TestGesture.Stop -> flowHost.onComplete(gesture.result) + } + } + + override fun doClear() { + cleared = true + } +} + +class TestDataApi : CommonFlowDataApi { + lateinit var state: TestFlowState + + override fun init(flowHost: CommonFlowHost, input: Int): CommonMachineState { + return TestFlowState(flowHost, input).also { state = it } + } + + override fun getDefaultUiState(): TestUiState = TestUiState.LOADING + override fun getBackGesture(): TestGesture = TestGesture.Back +} \ No newline at end of file diff --git a/examples/androidcore/build.gradle.kts b/examples/androidcore/build.gradle.kts index 798fd47..8609265 100644 --- a/examples/androidcore/build.gradle.kts +++ b/examples/androidcore/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation(platform(libs.compose.bom)) implementation(libs.bundles.compose.core) + implementation(libs.compose.material.icons) implementation(libs.compose.activity) implementation(libs.compose.foundation) implementation(libs.compose.foundation.layouts) diff --git a/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Error.kt b/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Error.kt new file mode 100644 index 0000000..6bed1a0 --- /dev/null +++ b/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Error.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2022 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.androidcore.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import java.io.IOException + +@Composable +fun Error(error: Throwable, onDismiss: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + content = { + Icon( + modifier = Modifier.size(255.dp), + imageVector = Icons.Rounded.Warning, + tint = MaterialTheme.colorScheme.error, + contentDescription = "Error", + ) + + Card(modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .background(MaterialTheme.colorScheme.errorContainer) + ) { + Text( + modifier = Modifier + .padding(16.dp) + .align(Alignment.CenterHorizontally), + text = error.message ?: "Unknown error" + ) + } + Button(modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + onClick = onDismiss + ) { + Text("Dismiss") + } + } + ) +} + +@Preview +@Composable +fun ErrorPreview() { + CommonStateMachineTheme { + Error( + error = IOException("Network error"), + onDismiss = {} + ) + } +} diff --git a/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Loading.kt b/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Loading.kt index c4378e6..387f18b 100644 --- a/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Loading.kt +++ b/examples/androidcore/src/main/java/com/motorro/statemachine/androidcore/compose/Loading.kt @@ -25,9 +25,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable -fun Loading() { +fun Loading(modifier: Modifier = Modifier) { Column( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, content = { diff --git a/examples/books/app/.gitignore b/examples/books/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/books/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/books/app/build.gradle.kts b/examples/books/app/build.gradle.kts new file mode 100644 index 0000000..ffe01d3 --- /dev/null +++ b/examples/books/app/build.gradle.kts @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2022 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt) +} + +val versionCode: String by project.extra +val versionName: String by project.extra +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra +val androidTargetSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + applicationId = "com.motorro.statemachine.books.app" + + minSdk = androidMinSdkVersion + targetSdk = androidTargetSdkVersion + versionCode = versionCode + versionName = versionName + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + namespace = "com.motorro.statemachine.books.app" +} + +dependencies { + implementation(project(":commonstatemachine")) + implementation(project(":commonflow:data")) + implementation(project(":commonflow:compose")) + implementation(project(":commonflow:viewmodel")) + + implementation(project(":examples:commoncore")) + implementation(project(":examples:androidcore")) + implementation(project(":examples:books:domain")) + implementation(project(":examples:books:book")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.viewmodel) + + implementation(libs.kotlin.immutable) + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin.coroutines.android) + + implementation(platform(libs.compose.bom)) + + implementation(libs.bundles.compose.core) + implementation(libs.compose.activity) + implementation(libs.compose.viewmodel) + implementation(libs.compose.foundation) + implementation(libs.compose.foundation.layouts) + implementation(libs.compose.material.icons) + + implementation(libs.hilt.android) + implementation(libs.hilt.compose) + ksp(libs.hilt.compiler) + ksp(libs.hilt.compiler.androidx) + + debugImplementation(libs.compose.tooling) + + testImplementation(libs.bundles.test.core) + testImplementation(libs.test.kotlin.coroutines) +} diff --git a/examples/books/app/proguard-rules.pro b/examples/books/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/books/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/books/app/src/main/AndroidManifest.xml b/examples/books/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..84032b3 --- /dev/null +++ b/examples/books/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/App.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/App.kt new file mode 100644 index 0000000..b00c8e2 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/App.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class App : Application() { + override fun onCreate() { + super.onCreate() + setupLogger() + } + + private fun setupLogger() { + Timber.plant(Timber.DebugTree()) + } +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/MainActivity.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/MainActivity.kt new file mode 100644 index 0000000..ad1bf33 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/MainActivity.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import com.motorro.commonstatemachine.flow.viewmodel.setStateMachineContent +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.books.app.ui.MainScreen +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setStateMachineContent { state, onGesture -> + CommonStateMachineTheme { + MainScreen(state, onGesture) + } + } + } +} diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/MainViewModel.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/MainViewModel.kt new file mode 100644 index 0000000..ed17c66 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/MainViewModel.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app + +import com.motorro.commonstatemachine.flow.viewmodel.CommonFlowViewModel +import com.motorro.statemachine.books.app.api.MainApi +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor(mainApi: MainApi) : CommonFlowViewModel( + api = mainApi, + init = Unit +) \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainApi.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainApi.kt new file mode 100644 index 0000000..5a58725 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainApi.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.api + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.commonstatemachine.flow.data.CommonFlowDataApi +import com.motorro.commonstatemachine.flow.data.CommonFlowHost +import com.motorro.statemachine.books.app.data.MainDataState +import com.motorro.statemachine.books.app.state.MainFactory +import javax.inject.Inject + +/** + * Item common flow + */ +class MainApi @Inject internal constructor(private val factory: MainFactory.Factory) : CommonFlowDataApi { + + override fun init(flowHost: CommonFlowHost, input: Unit): CommonMachineState = + factory.create(flowHost).bookList(MainDataState()) + + override fun getDefaultUiState(): MainUiState = MainUiState.List.Loading + override fun getBackGesture(): MainGesture = MainGesture.Back +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainFlowHost.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainFlowHost.kt new file mode 100644 index 0000000..32b28fa --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainFlowHost.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.api + +import com.motorro.commonstatemachine.flow.data.CommonFlowHost + +/** + * Hosting interface + */ +typealias MainFlowHost = CommonFlowHost \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainGesture.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainGesture.kt new file mode 100644 index 0000000..b2d7fe0 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainGesture.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.api + +import com.motorro.statemachine.books.book.api.BookGesture + +/** + * Main flow gesture + */ +sealed class MainGesture { + /** + * Backwards navigation + */ + internal object Back : MainGesture() + + /** + * Item selected + */ + internal data class BookSelected(val id: Int) : MainGesture() + + /** + * Child flow + */ + internal data class Book(val child: BookGesture) : MainGesture() +} diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainUiState.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainUiState.kt new file mode 100644 index 0000000..6e245e2 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/api/MainUiState.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.api + +import androidx.compose.runtime.Immutable +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.domain.entity.ListBook +import kotlinx.collections.immutable.ImmutableList + +/** + * Main flow UI state + */ +sealed class MainUiState { + /** + * Main flow + */ + sealed class List : MainUiState() { + /** + * Main loader + */ + internal data object Loading : List() + + /** + * Main list + */ + @Immutable + internal data class Master(val items: ImmutableList) : List() + } + + /** + * Book detail + */ + internal data class Book(val child: BookUiState) : MainUiState() +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/data/MainDataState.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/data/MainDataState.kt new file mode 100644 index 0000000..cc6f6f7 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/data/MainDataState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.data + +import com.motorro.statemachine.books.domain.entity.ListBook +import kotlinx.collections.immutable.ImmutableList + +/** + * Main data state + */ +data class MainDataState(val items: ImmutableList? = null) \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/di/ApplicationModule.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/di/ApplicationModule.kt new file mode 100644 index 0000000..15f36d9 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/di/ApplicationModule.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.di + +import com.motorro.statemachine.books.app.repository.BookRepositoryImpl +import com.motorro.statemachine.books.domain.entity.Book +import com.motorro.statemachine.books.domain.repository.BookRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.collections.immutable.persistentListOf +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal class ApplicationModule { + @Provides + @Singleton + fun itemRepository(): BookRepository = BookRepositoryImpl( + listOf( + Book( + id = 1, + title = "The Hobbit", + authors = persistentListOf("J.R.R. Tolkien"), + content = "A reluctant hobbit, Bilbo Baggins, sets out to the Lonely Mountain with a spirited group of dwarves to reclaim their mountain home, and a treasure within, from the dragon Smaug." + ), + Book( + id = 2, + title = "The Lord of the Rings", + authors = persistentListOf("J.R.R. Tolkien"), + content = "Frodo Baggins, a hobbit, and his companions set out on a quest to destroy the powerful One Ring and save Middle-earth from the Dark Lord Sauron." + ), + Book( + id = 3, + title = "Pride and Prejudice", + authors = persistentListOf("Jane Austen"), + content = "Follows the turbulent relationship between Elizabeth Bennet, the daughter of a country gentleman, and Fitzwilliam Darcy, a rich aristocratic landowner." + ), + Book( + id = 4, + title = "To Kill a Mockingbird", + authors = persistentListOf("Harper Lee"), + content = "The story of a young girl, Scout Finch, her brother, Jem, and their father, Atticus, a lawyer who defends a black man falsely accused of raping a white woman in the 1930s Alabama." + ), + Book( + id = 5, + title = "1984", + authors = persistentListOf("George Orwell"), + content = "A dystopian novel set in Airstrip One (formerly Great Britain), a province of the superstate Oceania in a world of perpetual war, omnipresent government surveillance, and public manipulation." + ) + ) + ) +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/repository/BookRepositoryImpl.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/repository/BookRepositoryImpl.kt new file mode 100644 index 0000000..436860b --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/repository/BookRepositoryImpl.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.repository + +import com.motorro.statemachine.books.domain.entity.Book +import com.motorro.statemachine.books.domain.entity.ListBook +import com.motorro.statemachine.books.domain.repository.BookRepository +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlin.time.Duration.Companion.seconds + +class BookRepositoryImpl(books: List) : BookRepository { + + private val data = MutableStateFlow(books.associateBy { it.id }) + + override val books: Flow> = flow { + delay(DELAY) + emitAll(data.map { it.values.map { ListBook(it.id, it.title, it.authors) }.toImmutableList() }) + } + + override suspend fun getBook(id: Int): Book { + delay(DELAY) + return data.first().getValue(id) + } + + override suspend fun deleteBook(id: Int) { + delay(DELAY) + data.update { + it - id + } + } + + private companion object { + val DELAY = 1.seconds + } +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/BaseMainState.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/BaseMainState.kt new file mode 100644 index 0000000..a7e4831 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/BaseMainState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +import com.motorro.commonstatemachine.coroutines.CoroutineState +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.commoncore.log.Logger + +internal abstract class BaseMainState(context: MainContext) : CoroutineState(), MainContext by context { + override fun doProcess(gesture: MainGesture) { + Logger.w("Unhandled gesture: $gesture") + } +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/ItemListState.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/ItemListState.kt new file mode 100644 index 0000000..46903c6 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/ItemListState.kt @@ -0,0 +1,83 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.books.app.data.MainDataState +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.domain.entity.ListBook +import com.motorro.statemachine.books.domain.repository.BookRepository +import com.motorro.statemachine.commoncore.log.Logger +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject +import kotlin.properties.Delegates + +internal class ItemListState( + context: MainContext, + data: MainDataState, + private val repository: BookRepository +) : BaseMainState(context) { + + private var data: MainDataState by Delegates.observable(data) { _, _, newValue -> + render(newValue.items) + } + + override fun doStart() { + render(data.items) + subscribeSession() + } + + private fun subscribeSession() { + repository.books + .onEach { data = data.copy(items = it) } + .launchIn(stateScope) + } + + override fun doProcess(gesture: MainGesture) { + when (gesture) { + MainGesture.Back -> { + Logger.d("Back. Terminating...") + setMachineState(factory.terminated()) + } + is MainGesture.BookSelected -> { + val selected = data.items?.firstOrNull { it.id == gesture.id } ?: return + Logger.d("Item selected: ${selected.id}") + setMachineState(factory.book( + data = data, + input = BookInput(selected.id, selected.title) + )) + } + else -> super.doProcess(gesture) + } + } + + private fun render(items: ImmutableList?) { + val uiState = when { + null != items -> MainUiState.List.Master(items) + else -> MainUiState.List.Loading + } + setUiState(uiState) + } + + class Factory @Inject constructor(private val repository: BookRepository) { + fun invoke(context: MainContext, data: MainDataState) = ItemListState( + context, + data, + repository + ) + } +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/ItemProxy.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/ItemProxy.kt new file mode 100644 index 0000000..59e0311 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/ItemProxy.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +import com.motorro.commonstatemachine.ProxyMachineState +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.books.app.data.MainDataState +import com.motorro.statemachine.books.book.api.BookApi +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.api.ItemFlowHost +import com.motorro.statemachine.commoncore.log.Logger +import javax.inject.Inject + +internal class ItemProxy( + private val context: MainContext, + private val data: MainDataState, + private val input: BookInput, + private val api: BookApi +) : ProxyMachineState(api.getDefaultUiState()) { + + private val flowHast = ItemFlowHost { + Logger.d("Item flow host terminated") + setMachineState(context.factory.bookList(data)) + } + + override fun init() = api.init(flowHast, input) + + override fun mapGesture(parent: MainGesture): BookGesture? = when(parent) { + is MainGesture.Back -> api.getBackGesture() + is MainGesture.Book -> parent.child + else -> null + } + + override fun mapUiState(child: BookUiState): MainUiState = MainUiState.Book(child) + + class Factory @Inject constructor(private val api: BookApi) { + operator fun invoke(context: MainContext, data: MainDataState, input: BookInput) = ItemProxy( + context, + data, + input, + api + ) + } +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/MainContext.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/MainContext.kt new file mode 100644 index 0000000..76543a8 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/MainContext.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +/** + * Main flow context + */ +internal interface MainContext { + val factory: MainFactory +} \ No newline at end of file diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/MainFactory.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/MainFactory.kt new file mode 100644 index 0000000..34d8d2e --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/state/MainFactory.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.statemachine.books.app.api.MainFlowHost +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.books.app.data.MainDataState +import com.motorro.statemachine.books.book.api.BookInput +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import javax.inject.Provider + +/** + * Main state factory + */ +internal interface MainFactory { + fun bookList(data: MainDataState): CommonMachineState + fun book(data: MainDataState, input: BookInput): CommonMachineState + fun terminated(): CommonMachineState + + @AssistedFactory + interface Factory { + fun create(host: MainFlowHost): Impl + } + + class Impl @AssistedInject constructor( + @Assisted private val host: MainFlowHost, + private val createItemList: Provider, + private val createItem: Provider, + ) : MainFactory { + private val context = object : MainContext { + override val factory: MainFactory = this@Impl + } + + override fun bookList(data: MainDataState) = createItemList.get().invoke( + context, + data + ) + + override fun book(data: MainDataState, input: BookInput) = createItem.get().invoke( + context, + data, + input + ) + + override fun terminated() = object : CommonMachineState() { + override fun doStart() { + host.onComplete(Unit) + } + } + } +} diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/ui/BookListView.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/ui/BookListView.kt new file mode 100644 index 0000000..c332b4f --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/ui/BookListView.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import com.motorro.statemachine.books.domain.entity.ListBook +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun BookListView(items: ImmutableList, onItemSelected: (Int) -> Unit) { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(items) { item -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .clickable { onItemSelected(item.id) } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = item.authors.joinToString(), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + +@Preview +@Composable +private fun BookListViewPreview() { + val items = (1..10).map { ListBook(it, "Item $it", persistentListOf("Author $it")) }.toImmutableList() + CommonStateMachineTheme { + BookListView(items = items, onItemSelected = {}) + } +} diff --git a/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/ui/MainScreen.kt b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/ui/MainScreen.kt new file mode 100644 index 0000000..1fc3d31 --- /dev/null +++ b/examples/books/app/src/main/kotlin/com/motorro/statemachine/books/app/ui/MainScreen.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.motorro.statemachine.androidcore.compose.Loading +import com.motorro.statemachine.books.app.R +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainGesture.Book +import com.motorro.statemachine.books.app.api.MainGesture.BookSelected +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.books.book.ui.BookScreen +import kotlin.reflect.KClass + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun MainScreen(state: MainUiState, onGesture: (MainGesture) -> Unit, modifier: Modifier = Modifier) { + AnimatedContent( + targetState = state, + modifier = modifier, + contentKey = { it.contentKey }, + transitionSpec = mainTransitionSpec + ) { targetState -> + when (targetState) { + is MainUiState.List -> MainScreenScaffold(targetState, onGesture) + is MainUiState.Book -> BookScreen(targetState.child) { + onGesture(Book(it)) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun MainScreenScaffold(state: MainUiState.List, onGesture: (MainGesture) -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) } + ) + }, + content = { + AnimatedContent(targetState = state, modifier = Modifier.padding(it)) { targetState -> + when(targetState) { + MainUiState.List.Loading -> Loading() + is MainUiState.List.Master -> BookListView(targetState.items) { + onGesture(BookSelected(it)) + } + } + } + } + ) +} + +private val KClass<*>.contentKey: Any? get() = simpleName + +private val MainUiState.contentKey: Any? get() = when(this) { + is MainUiState.List -> MainUiState.List::class.contentKey + is MainUiState.Book -> MainUiState.Book::class.contentKey +} + +private val mainTransitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { + when (initialState.contentKey to targetState.contentKey) { + MainUiState.List::class.contentKey to MainUiState.Book::class.contentKey -> { + slideInHorizontally( + animationSpec = tween(220, delayMillis = 90), + initialOffsetX = { fullWidth -> fullWidth } + ) togetherWith slideOutHorizontally( + animationSpec = tween(220, delayMillis = 90), + targetOffsetX = { fullWidth -> -fullWidth } + ) + } + MainUiState.Book::class.contentKey to MainUiState.List::class.contentKey -> { + slideInHorizontally( + animationSpec = tween(220, delayMillis = 90), + initialOffsetX = { fullWidth -> - fullWidth } + ) togetherWith slideOutHorizontally( + animationSpec = tween(220, delayMillis = 90), + targetOffsetX = { fullWidth -> fullWidth } + ) + } + else -> ContentTransform(EnterTransition.None, ExitTransition.None) + } +} \ No newline at end of file diff --git a/examples/books/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/examples/books/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..40ce517 --- /dev/null +++ b/examples/books/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/main/res/drawable/ic_launcher_background.xml b/examples/books/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..997ef0b --- /dev/null +++ b/examples/books/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/books/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/books/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..0147ca8 --- /dev/null +++ b/examples/books/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/books/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..0147ca8 --- /dev/null +++ b/examples/books/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/books/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/examples/books/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/books/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/books/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/books/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/examples/books/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/books/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/books/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/books/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/examples/books/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/books/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/books/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/examples/books/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/examples/books/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/books/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/books/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/examples/books/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/examples/books/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/books/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/examples/books/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/books/app/src/main/res/values/colors.xml b/examples/books/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..164d1ef --- /dev/null +++ b/examples/books/app/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/books/app/src/main/res/values/strings.xml b/examples/books/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..bf54e8a --- /dev/null +++ b/examples/books/app/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + + Books + \ No newline at end of file diff --git a/examples/books/app/src/main/res/values/themes.xml b/examples/books/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..eff3025 --- /dev/null +++ b/examples/books/app/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/main/res/xml/backup_rules.xml b/examples/books/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..18b86f4 --- /dev/null +++ b/examples/books/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/main/res/xml/data_extraction_rules.xml b/examples/books/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..2d914d6 --- /dev/null +++ b/examples/books/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,32 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/mock.kt b/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/mock.kt new file mode 100644 index 0000000..dd62339 --- /dev/null +++ b/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/mock.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app + +import com.motorro.statemachine.books.domain.entity.ListBook +import kotlinx.collections.immutable.persistentListOf + +val BOOK_1 = ListBook( + id = 10, + title = "Book 1", + authors = persistentListOf("Author 1", "Author 2") +) + +val BOOK_2 = ListBook( + id = 20, + title = "Book 2", + authors = persistentListOf("Author 3", "Author 4") +) \ No newline at end of file diff --git a/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/state/BaseStateTest.kt b/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/state/BaseStateTest.kt new file mode 100644 index 0000000..87bbd1d --- /dev/null +++ b/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/state/BaseStateTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +import com.motorro.commonstatemachine.CommonStateMachine +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseStateTest { + protected lateinit var stateMachine: CommonStateMachine + protected lateinit var factory: MainFactory + protected lateinit var context: MainContext + protected lateinit var nextState: BaseMainState + protected lateinit var dispatcher: TestDispatcher + + @Before + fun init() { + dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + stateMachine = mockk(relaxed = true) + factory = mockk() + context = object : MainContext { + override val factory: MainFactory = this@BaseStateTest.factory + } + nextState = mockk(relaxed = true) + doInit() + } + + @After + fun deinit() { + Dispatchers.resetMain() + } + + protected fun test(block: suspend TestScope.() -> Unit) = runTest( + dispatcher, + testBody = block + ) + + protected open fun doInit() = Unit +} \ No newline at end of file diff --git a/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/state/ItemListStateTest.kt b/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/state/ItemListStateTest.kt new file mode 100644 index 0000000..b4ccfa8 --- /dev/null +++ b/examples/books/app/src/test/kotlin/com/motorro/statemachine/books/app/state/ItemListStateTest.kt @@ -0,0 +1,135 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.app.state + +import com.motorro.statemachine.books.app.BOOK_1 +import com.motorro.statemachine.books.app.BOOK_2 +import com.motorro.statemachine.books.app.api.MainGesture +import com.motorro.statemachine.books.app.api.MainUiState +import com.motorro.statemachine.books.app.data.MainDataState +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.domain.entity.ListBook +import com.motorro.statemachine.books.domain.repository.BookRepository +import io.mockk.Ordering +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.yield +import org.junit.Test +import kotlin.test.assertEquals + +internal class ItemListStateTest : BaseStateTest() { + private lateinit var items: MutableStateFlow> + + override fun doInit() { + items = MutableStateFlow(persistentListOf(BOOK_1)) + } + + private fun createState(data: MainDataState = MainDataState()): BaseMainState { + val repository = mockk { + every { this@mockk.books } returns this@ItemListStateTest.items + } + return ItemListState(context, data, repository) + } + + @Test + fun startsWithLoadingWhenDataIsEmpty() = test { + val state = createState() + + state.start(stateMachine) + + coVerify(ordering = Ordering.ORDERED) { + stateMachine.setUiState(MainUiState.List.Loading) + stateMachine.setUiState(MainUiState.List.Master(persistentListOf(BOOK_1))) + } + } + + @Test + fun startsWithCachedItemsWhenDataIsNotEmpty() = test { + val state = createState(MainDataState(items = persistentListOf(BOOK_2))) + + state.start(stateMachine) + + coVerify(ordering = Ordering.ORDERED) { + stateMachine.setUiState(MainUiState.List.Master(persistentListOf(BOOK_2))) + stateMachine.setUiState(MainUiState.List.Master(persistentListOf(BOOK_1))) + } + } + + @Test + fun updatesWithRepository() = test { + val state = createState() + + state.start(stateMachine) + items.emit(persistentListOf(BOOK_2)) + + coVerify(ordering = Ordering.ORDERED) { + stateMachine.setUiState(MainUiState.List.Loading) + stateMachine.setUiState(MainUiState.List.Master(persistentListOf(BOOK_1))) + stateMachine.setUiState(MainUiState.List.Master(persistentListOf(BOOK_2))) + } + } + + @Test + fun selectsItem() = test { + every { factory.book(any(), any()) } returns nextState + val state = createState() + + state.start(stateMachine) + yield() + state.process(MainGesture.BookSelected(BOOK_1.id)) + + coVerify { + factory.book( + data = withArg { + assertEquals(persistentListOf(BOOK_1), it.items) + }, + input = eq(BookInput(BOOK_1.id, BOOK_1.title)) + ) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun doesNothingOnInvalidItem() = test { + every { factory.book(any(), any()) } returns nextState + val state = createState() + + state.start(stateMachine) + yield() + state.process(MainGesture.BookSelected(100500)) + + coVerify(exactly = 0) { + factory.book(any(), any()) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun terminatesOnBack() { + every { factory.terminated() } returns nextState + val state = createState() + + state.start(stateMachine) + state.process(MainGesture.Back) + + coVerify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/books/book/.gitignore b/examples/books/book/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/books/book/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/books/book/build.gradle.kts b/examples/books/book/build.gradle.kts new file mode 100644 index 0000000..7e7c6a2 --- /dev/null +++ b/examples/books/book/build.gradle.kts @@ -0,0 +1,90 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.lib) + alias(libs.plugins.compose) + alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt) +} + +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + minSdk = androidMinSdkVersion + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + namespace = "com.motorro.statemachine.books.book" +} + +dependencies { + implementation(project(":commonstatemachine")) + implementation(project(":coroutines")) + implementation(project(":commonflow:data")) + + implementation(project(":examples:commoncore")) + implementation(project(":examples:androidcore")) + + implementation(project(":examples:books:domain")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.kotlin.coroutines.core) + + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose.core) + implementation(libs.compose.foundation) + implementation(libs.compose.foundation.layouts) + implementation(libs.compose.material.icons) + + implementation(libs.hilt.android) + implementation(libs.hilt.compose) + ksp(libs.hilt.compiler) + ksp(libs.hilt.compiler.androidx) + + debugImplementation(libs.compose.tooling) + + testImplementation(libs.bundles.test.core) + testImplementation(libs.test.kotlin.coroutines) +} diff --git a/examples/books/book/consumer-rules.pro b/examples/books/book/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/examples/books/book/demo/.gitignore b/examples/books/book/demo/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/books/book/demo/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/books/book/demo/build.gradle.kts b/examples/books/book/demo/build.gradle.kts new file mode 100644 index 0000000..c3c588f --- /dev/null +++ b/examples/books/book/demo/build.gradle.kts @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2022 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt) +} + +val versionCode: String by project.extra +val versionName: String by project.extra +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra +val androidTargetSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + applicationId = "com.motorro.statemachine.books.book.demo" + + minSdk = androidMinSdkVersion + targetSdk = androidTargetSdkVersion + versionCode = versionCode + versionName = versionName + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + namespace = "com.motorro.statemachine.books.book.demo" +} + +dependencies { + implementation(project(":commonstatemachine")) + implementation(project(":commonflow:data")) + implementation(project(":commonflow:compose")) + implementation(project(":commonflow:viewmodel")) + + implementation(project(":examples:commoncore")) + implementation(project(":examples:androidcore")) + implementation(project(":examples:books:domain")) + implementation(project(":examples:books:book")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.viewmodel) + + implementation(libs.kotlin.immutable) + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin.coroutines.android) + + implementation(platform(libs.compose.bom)) + + implementation(libs.bundles.compose.core) + implementation(libs.compose.activity) + implementation(libs.compose.viewmodel) + implementation(libs.compose.foundation) + implementation(libs.compose.foundation.layouts) + implementation(libs.compose.material.icons) + + implementation(libs.hilt.android) + implementation(libs.hilt.compose) + ksp(libs.hilt.compiler) + ksp(libs.hilt.compiler.androidx) + + debugImplementation(libs.compose.tooling) +} diff --git a/examples/books/book/demo/proguard-rules.pro b/examples/books/book/demo/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/books/book/demo/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/books/book/demo/src/main/AndroidManifest.xml b/examples/books/book/demo/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0c5ede3 --- /dev/null +++ b/examples/books/book/demo/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/App.kt b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/App.kt new file mode 100644 index 0000000..5567f41 --- /dev/null +++ b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/App.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.demo + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class App : Application() { + override fun onCreate() { + super.onCreate() + setupLogger() + } + + private fun setupLogger() { + Timber.plant(Timber.DebugTree()) + } +} \ No newline at end of file diff --git a/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/MainActivity.kt b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/MainActivity.kt new file mode 100644 index 0000000..47aa33c --- /dev/null +++ b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/MainActivity.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.enableEdgeToEdge +import com.motorro.commonstatemachine.flow.viewmodel.setStateMachineContent +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.ui.BookScreen +import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setStateMachineContent( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { + it.create(BookInput(BOOK.id, BOOK.title)) + } + }, + content = { state, onGesture -> + CommonStateMachineTheme { + BookScreen(state, onGesture) + } + } + ) + } +} diff --git a/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/MainViewModel.kt b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/MainViewModel.kt new file mode 100644 index 0000000..143073a --- /dev/null +++ b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/MainViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.demo + +import com.motorro.commonstatemachine.flow.viewmodel.CommonFlowViewModel +import com.motorro.statemachine.books.book.api.BookApi +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel(assistedFactory = MainViewModel.Factory::class) +class MainViewModel @AssistedInject internal constructor( + @Assisted input: BookInput, + bookApi: BookApi +) : CommonFlowViewModel( + api = bookApi, + init = input +) { + @AssistedFactory + interface Factory { + fun create(input: BookInput): MainViewModel + } +} \ No newline at end of file diff --git a/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/data.kt b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/data.kt new file mode 100644 index 0000000..bd22232 --- /dev/null +++ b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/data.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.demo + +import com.motorro.statemachine.books.domain.entity.Book +import kotlinx.collections.immutable.persistentListOf + +internal val BOOK = Book( + id = 1, + title = "The Hobbit", + authors = persistentListOf("J.R.R. Tolkien"), + content = "A reluctant hobbit, Bilbo Baggins, sets out to the Lonely Mountain with a spirited group of dwarves to reclaim their mountain home, and a treasure within, from the dragon Smaug." +) \ No newline at end of file diff --git a/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/di/ApplicationModule.kt b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/di/ApplicationModule.kt new file mode 100644 index 0000000..56d4fee --- /dev/null +++ b/examples/books/book/demo/src/main/kotlin/com/motorro/statemachine/books/book/demo/di/ApplicationModule.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.demo.di + +import com.motorro.statemachine.books.book.demo.BOOK +import com.motorro.statemachine.books.domain.entity.Book +import com.motorro.statemachine.books.domain.entity.ListBook +import com.motorro.statemachine.books.domain.repository.BookRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import javax.inject.Singleton +import kotlin.time.Duration.Companion.seconds + +@Module +@InstallIn(SingletonComponent::class) +internal class ApplicationModule { + @Provides + @Singleton + fun itemRepository(): BookRepository = object : BookRepository { + override val books: Flow> + get() = throw NotImplementedError("Not implemented in this demo") + + override suspend fun getBook(id: Int): Book { + delay(1.seconds) + return BOOK + } + + override suspend fun deleteBook(id: Int) { + delay(1.seconds) + } + } +} \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml b/examples/books/book/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..40ce517 --- /dev/null +++ b/examples/books/book/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/drawable/ic_launcher_background.xml b/examples/books/book/demo/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..997ef0b --- /dev/null +++ b/examples/books/book/demo/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/books/book/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/books/book/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..0147ca8 --- /dev/null +++ b/examples/books/book/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/books/book/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..0147ca8 --- /dev/null +++ b/examples/books/book/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/books/book/demo/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/books/book/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/books/book/demo/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/books/book/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/books/book/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/books/book/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/examples/books/book/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/books/book/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/examples/books/book/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/examples/books/book/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/books/book/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/examples/books/book/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/books/book/demo/src/main/res/values/colors.xml b/examples/books/book/demo/src/main/res/values/colors.xml new file mode 100644 index 0000000..164d1ef --- /dev/null +++ b/examples/books/book/demo/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/values/strings.xml b/examples/books/book/demo/src/main/res/values/strings.xml new file mode 100644 index 0000000..87fa69b --- /dev/null +++ b/examples/books/book/demo/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + + Book demo + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/values/themes.xml b/examples/books/book/demo/src/main/res/values/themes.xml new file mode 100644 index 0000000..f9d35b1 --- /dev/null +++ b/examples/books/book/demo/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/xml/backup_rules.xml b/examples/books/book/demo/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..18b86f4 --- /dev/null +++ b/examples/books/book/demo/src/main/res/xml/backup_rules.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/examples/books/book/demo/src/main/res/xml/data_extraction_rules.xml b/examples/books/book/demo/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..2d914d6 --- /dev/null +++ b/examples/books/book/demo/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,32 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/books/book/proguard-rules.pro b/examples/books/book/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/books/book/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookApi.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookApi.kt new file mode 100644 index 0000000..fe12321 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookApi.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.api + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.commonstatemachine.flow.data.CommonFlowDataApi +import com.motorro.commonstatemachine.flow.data.CommonFlowHost +import com.motorro.statemachine.books.book.state.BookFactory +import javax.inject.Inject + +/** + * Item common flow + */ +class BookApi @Inject internal constructor(private val factory: BookFactory.Factory) : CommonFlowDataApi { + override fun init(flowHost: CommonFlowHost, input: BookInput): CommonMachineState = + factory.create(flowHost).loading(input) + + override fun getDefaultUiState(): BookUiState = BookUiState.Loading("") + override fun getBackGesture(): BookGesture = BookGesture.Back +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookFlowHost.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookFlowHost.kt new file mode 100644 index 0000000..877d02d --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookFlowHost.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.api + +import com.motorro.commonstatemachine.flow.data.CommonFlowHost + +/** + * Hosting interface + */ +typealias ItemFlowHost = CommonFlowHost \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookGesture.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookGesture.kt new file mode 100644 index 0000000..39b6daa --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookGesture.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.api + +/** + * Item item flow gesture + */ +sealed class BookGesture { + internal data object Back : BookGesture() + internal data object Delete : BookGesture() + internal data object Confirm : BookGesture() + internal data object Cancel : BookGesture() +} diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookInput.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookInput.kt new file mode 100644 index 0000000..04c4953 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookInput.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.api + +/** + * Flow input + */ +data class BookInput(val id: Int, val title: String = "") \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookUiState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookUiState.kt new file mode 100644 index 0000000..89bb7e1 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/api/BookUiState.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.api + +import com.motorro.statemachine.books.domain.entity.Book + +/** + * Item sub-flow UI state + */ +sealed class BookUiState { + /** + * Loading + */ + data class Loading(val title: String) : BookUiState() + + /** + * Content + */ + internal data class Content(val book: Book, val showDeleteConfirmation: Boolean) : BookUiState() + + /** + * Error + */ + internal data class Error(val title: String, val error: Throwable) : BookUiState() +} diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/data/BookDataState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/data/BookDataState.kt new file mode 100644 index 0000000..075b7bc --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/data/BookDataState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.data + +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.domain.entity.Book + +/** + * Item sub-flow internal data + */ +data class BookDataState( + val input: BookInput, + val book: Book? = null +) \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BaseBookState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BaseBookState.kt new file mode 100644 index 0000000..90277d3 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BaseBookState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.commonstatemachine.coroutines.CoroutineState +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.commoncore.log.Logger + +internal abstract class BaseBookState(context: BookContext) : CoroutineState(), BookContext by context { + override fun doProcess(gesture: BookGesture) { + Logger.w("Unsupported gesture: $gesture") + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BookContext.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BookContext.kt new file mode 100644 index 0000000..6e8ce27 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BookContext.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +/** + * Item sub-flow common components + */ +internal interface BookContext { + val factory: BookFactory +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BookFactory.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BookFactory.kt new file mode 100644 index 0000000..9abdb71 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/BookFactory.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.api.ItemFlowHost +import com.motorro.statemachine.books.book.data.BookDataState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import javax.inject.Provider + +internal interface BookFactory { + fun loading(input: BookInput): CommonMachineState + fun content(data: BookDataState): CommonMachineState + fun deleteConfirmation(data: BookDataState): CommonMachineState + fun error(dataState: BookDataState, error: Throwable): CommonMachineState + fun deleting(data: BookDataState): CommonMachineState + fun terminated(): CommonMachineState + + @AssistedFactory + interface Factory { + fun create(host: ItemFlowHost): Impl + } + + class Impl @AssistedInject constructor( + @Assisted private val host: ItemFlowHost, + private val createLoadingState: Provider, + private val createDeletingState: Provider + ) : BookFactory { + + private val context = object : BookContext { + override val factory: BookFactory = this@Impl + } + + override fun loading(input: BookInput): CommonMachineState = createLoadingState.get().invoke( + context, + BookDataState(input) + ) + + override fun content(data: BookDataState) = ContentState( + context, + data + ) + + override fun deleteConfirmation(data: BookDataState) = DeleteConfirmationState( + context, + data + ) + + override fun error(dataState: BookDataState, error: Throwable) = ErrorState( + context, + dataState, + error + ) + + override fun deleting(data: BookDataState) = createDeletingState.get().invoke( + context, + data + ) + + override fun terminated() = object : CommonMachineState() { + override fun doStart() { + host.onComplete(Unit) + } + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/ContentState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/ContentState.kt new file mode 100644 index 0000000..d8812ae --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/ContentState.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.commoncore.log.Logger + +internal class ContentState( + context: BookContext, + private val data: BookDataState +) : BaseBookState(context) { + + val item get() = checkNotNull(data.book) { + "Illegal state: Item is null!" + } + + override fun doStart() { + setUiState(BookUiState.Content(item, false)) + } + + override fun doProcess(gesture: BookGesture) { + when(gesture) { + BookGesture.Back -> { + Logger.d("Back gesture. Terminating...") + setMachineState(factory.terminated()) + } + BookGesture.Delete -> { + Logger.d("Delete gesture. Showing delete confirmation...") + setMachineState(factory.deleteConfirmation(data)) + } + else -> super.doProcess(gesture) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/DeleteConfirmationState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/DeleteConfirmationState.kt new file mode 100644 index 0000000..c03bf53 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/DeleteConfirmationState.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.commoncore.log.Logger + +internal class DeleteConfirmationState( + context: BookContext, + private val data: BookDataState +) : BaseBookState(context) { + + override fun doStart() { + val item = requireNotNull(data.book) { + "Item is null - unexpected state" + } + setUiState(BookUiState.Content(item, true)) + } + + override fun doProcess(gesture: BookGesture) { + when(gesture) { + BookGesture.Back, BookGesture.Cancel -> { + Logger.d("Delete canceled. Back to content...") + setMachineState(factory.content(data)) + } + BookGesture.Confirm -> { + Logger.d("Delete confirmed. Deleting...") + setMachineState(factory.deleting(data)) + } + else -> super.doProcess(gesture) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/DeletingState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/DeletingState.kt new file mode 100644 index 0000000..c162702 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/DeletingState.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.books.domain.repository.BookRepository +import com.motorro.statemachine.commoncore.log.Logger +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class DeletingState( + context: BookContext, + private val data: BookDataState, + private val repository: BookRepository +) : BaseBookState(context) { + + val item get() = checkNotNull(data.book) { + "Illegal state: Item is null!" + } + + override fun doStart() { + setUiState(BookUiState.Loading(item.title)) + delete() + } + + private fun delete() = stateScope.launch { + Logger.d("Deleting item: ${item.id}") + try { + repository.deleteBook(item.id) + Logger.d("Item ${item.id} deleted. Terminating...") + setMachineState(factory.terminated()) + } catch (e: Throwable) { + Logger.e(e, "Failed to delete item: ${item.id}") + setMachineState(factory.error(data, e)) + } + } + + class Factory @Inject constructor(private val repository: BookRepository) { + operator fun invoke(context: BookContext, data: BookDataState) = DeletingState( + context, + data, + repository + ) + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/ErrorState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/ErrorState.kt new file mode 100644 index 0000000..942f150 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/ErrorState.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.commoncore.log.Logger + +internal class ErrorState( + context: BookContext, + private val data: BookDataState, + private val error: Throwable +) : BaseBookState(context) { + + override fun doStart() { + setUiState( + BookUiState.Error( + data.book?.title ?: data.input.title, + error + ) + ) + } + + override fun doProcess(gesture: BookGesture) { + when(gesture) { + BookGesture.Back, BookGesture.Confirm -> { + Logger.d("Dismissed. Terminating...") + setMachineState(factory.terminated()) + } + else -> super.doProcess(gesture) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/LoadingState.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/LoadingState.kt new file mode 100644 index 0000000..c8c8c48 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/state/LoadingState.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.books.domain.repository.BookRepository +import com.motorro.statemachine.commoncore.log.Logger +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class LoadingState( + context: BookContext, + private val data: BookDataState, + private val repository: BookRepository +) : BaseBookState(context) { + + override fun doStart() { + setUiState(BookUiState.Loading(data.input.title)) + load() + } + + private fun load() = stateScope.launch { + val id = data.input.id + Logger.d("Loading item: $id") + try { + val item = repository.getBook(id) + Logger.d("Item loaded: $item") + setMachineState(factory.content(data.copy(book = item))) + } catch (e: Throwable) { + ensureActive() + Logger.e(e, "Failed to load item: $id") + setMachineState(factory.error(data, e)) + } + } + + override fun doProcess(gesture: BookGesture) { + when (gesture) { + BookGesture.Back -> { + Logger.w("Back gesture. Aborting load...") + setMachineState(factory.terminated()) + } + else -> super.doProcess(gesture) + } + } + + class Factory @Inject constructor(private val repository: BookRepository) { + operator fun invoke(context: BookContext, dataState: BookDataState) = LoadingState( + context, + dataState, + repository + ) + } +} \ No newline at end of file diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/ui/BookContentScreen.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/ui/BookContentScreen.kt new file mode 100644 index 0000000..c93ae76 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/ui/BookContentScreen.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.motorro.statemachine.books.book.R +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.domain.entity.Book +import kotlinx.collections.immutable.persistentListOf + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun BookContentScreen(state: BookUiState.Content, onGesture: (BookGesture) -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = state.book.title) }, + navigationIcon = { + IconButton(onClick = { onGesture(BookGesture.Back) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.content_back) + ) + } + }, + actions = { + IconButton(onClick = { onGesture(BookGesture.Delete) }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(id = R.string.content_delete_item) + ) + } + } + ) + } + ) { padding -> + if (state.showDeleteConfirmation) { + DeleteConfirmationDialog(onGesture) + } + BookContent( + padding = padding, + book = state.book + ) + } +} + +@Composable +private fun BookContent(padding: PaddingValues, book: Book) { + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .padding(padding) + .verticalScroll(scrollState) + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = book.title, + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = book.authors.joinToString(), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = book.content, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Composable +private fun DeleteConfirmationDialog(onGesture: (BookGesture) -> Unit) { + AlertDialog( + onDismissRequest = { onGesture(BookGesture.Cancel) }, + title = { Text(text = stringResource(id = R.string.item_delete_confirmation_title)) }, + text = { Text(text = stringResource(id = R.string.item_delete_confirmation_message)) }, + confirmButton = { + TextButton(onClick = { onGesture(BookGesture.Confirm) }) { + Text(text = stringResource(id = R.string.item_delete_confirmation_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { onGesture(BookGesture.Cancel) }) { + Text(text = stringResource(id = R.string.item_delete_confirmation_cancel)) + } + } + ) +} + +@Preview +@Composable +private fun BookContentScreenPreview() { + BookContentScreen( + state = BookUiState.Content( + book = Book(1, "Title", persistentListOf("Author"), "Content"), + showDeleteConfirmation = false + ), + onGesture = {} + ) +} + +@Preview +@Composable +private fun BookContentScreenWithConfirmationPreview() { + BookContentScreen( + state = BookUiState.Content( + book = Book(1, "Title", persistentListOf("Author"), "Content"), + showDeleteConfirmation = true + ), + onGesture = {} + ) +} diff --git a/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/ui/BookScreen.kt b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/ui/BookScreen.kt new file mode 100644 index 0000000..da169e2 --- /dev/null +++ b/examples/books/book/src/main/kotlin/com/motorro/statemachine/books/book/ui/BookScreen.kt @@ -0,0 +1,91 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.ui + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.motorro.statemachine.androidcore.compose.Error +import com.motorro.statemachine.androidcore.compose.Loading +import com.motorro.statemachine.books.book.R +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState + +@Composable +fun BookScreen(state: BookUiState, onGesture: (BookGesture) -> Unit) { + when(state) { + is BookUiState.Loading -> LoadingScreen(state, onGesture) + is BookUiState.Content -> BookContentScreen(state, onGesture) + is BookUiState.Error -> ErrorScreen(state, onGesture) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LoadingScreen(state: BookUiState.Loading, onGesture: (BookGesture) -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = state.title) }, + navigationIcon = { + IconButton(onClick = { onGesture(BookGesture.Back) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.content_back) + ) + } + } + ) + } + ) { padding -> + Loading( + modifier = Modifier.padding(padding) + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ErrorScreen(state: BookUiState.Error, onGesture: (BookGesture) -> Unit) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(text = state.title) }, + navigationIcon = { + IconButton(onClick = { onGesture(BookGesture.Back) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.content_back) + ) + } + } + ) + } + ) { padding -> + Error( + error = state.error, + onDismiss = { onGesture(BookGesture.Confirm) }, + modifier = Modifier.padding(padding) + ) + } +} diff --git a/examples/books/book/src/main/res/values/strings.xml b/examples/books/book/src/main/res/values/strings.xml new file mode 100644 index 0000000..e7e5763 --- /dev/null +++ b/examples/books/book/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + + Back + Delete item + Delete item? + Are you sure you want to delete this item? This action cannot be undone. + Delete + Cancel + diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/mock.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/mock.kt new file mode 100644 index 0000000..974ceae --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/mock.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book + +import com.motorro.statemachine.books.domain.entity.Book +import kotlinx.collections.immutable.persistentListOf + +internal const val BOOK_ID = 10 +internal const val BOOK_TITLE = "Book title" + +internal val Book = Book( + id = BOOK_ID, + title = BOOK_TITLE, + authors = persistentListOf("Author 1", "Author 2"), + content = "Book content" +) \ No newline at end of file diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/BaseStateTest.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/BaseStateTest.kt new file mode 100644 index 0000000..1533a52 --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/BaseStateTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.commonstatemachine.CommonStateMachine +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookUiState +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseStateTest { + protected lateinit var stateMachine: CommonStateMachine + protected lateinit var factory: BookFactory + protected lateinit var context: BookContext + protected lateinit var nextState: BaseBookState + protected lateinit var dispatcher: TestDispatcher + + @Before + fun init() { + dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + stateMachine = mockk(relaxed = true) + factory = mockk() + context = object : BookContext { + override val factory: BookFactory = this@BaseStateTest.factory + } + nextState = mockk(relaxed = true) + doInit() + } + + @After + fun deinit() { + Dispatchers.resetMain() + } + + protected fun test(block: suspend TestScope.() -> Unit) = runTest( + dispatcher, + testBody = block + ) + + protected open fun doInit() = Unit +} \ No newline at end of file diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/ContentStateTest.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/ContentStateTest.kt new file mode 100644 index 0000000..cc42f52 --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/ContentStateTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.BOOK_ID +import com.motorro.statemachine.books.book.BOOK_TITLE +import com.motorro.statemachine.books.book.Book +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import io.mockk.every +import io.mockk.verify +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +internal class ContentStateTest : BaseStateTest() { + + private lateinit var data: BookDataState + private lateinit var state: ContentState + + override fun doInit() { + data = BookDataState(BookInput(BOOK_ID, BOOK_TITLE), Book) + state = ContentState(context, data) + } + + @Test + fun displaysContentOnStart() = test { + state.start(stateMachine) + + verify { + stateMachine.setUiState(withArg { + val content = assertIs(it) + assertEquals(Book, content.book) + assertEquals(false, content.showDeleteConfirmation) + }) + } + } + + @Test + fun terminatesOnBack() = test { + every { factory.terminated() } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Back) + + verify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } + + @Test + fun requestsConfirmationOnDelete() = test { + every { factory.deleteConfirmation(any()) } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Delete) + + verify { + factory.deleteConfirmation(data) + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/DeleteConfirmationStateTest.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/DeleteConfirmationStateTest.kt new file mode 100644 index 0000000..10538fd --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/DeleteConfirmationStateTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.BOOK_ID +import com.motorro.statemachine.books.book.BOOK_TITLE +import com.motorro.statemachine.books.book.Book +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import io.mockk.every +import io.mockk.verify +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +internal class DeleteConfirmationStateTest : BaseStateTest() { + + private lateinit var data: BookDataState + private lateinit var state: DeleteConfirmationState + + override fun doInit() { + data = BookDataState(BookInput(BOOK_ID, BOOK_TITLE), Book) + state = DeleteConfirmationState(context, data) + } + + @Test + fun displaysConfirmationOnStart() = test { + state.start(stateMachine) + + verify { + stateMachine.setUiState(withArg { + val content = assertIs(it) + assertEquals(Book, content.book) + assertEquals(true, content.showDeleteConfirmation) + }) + } + } + + @Test + fun returnsToContentOnBack() = test { + every { factory.content(any()) } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Back) + + verify { + factory.content(data) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun returnsToContentOnCancel() = test { + every { factory.content(any()) } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Cancel) + + verify { + factory.content(data) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun proceedsToDeletingOnConfirm() = test { + every { factory.deleting(any()) } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Confirm) + + verify { + factory.deleting(data) + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/DeletingStateTest.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/DeletingStateTest.kt new file mode 100644 index 0000000..cf550d9 --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/DeletingStateTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.BOOK_ID +import com.motorro.statemachine.books.book.BOOK_TITLE +import com.motorro.statemachine.books.book.Book +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.books.domain.repository.BookRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +internal class DeletingStateTest : BaseStateTest() { + + private lateinit var data: BookDataState + private lateinit var repository: BookRepository + private lateinit var state: DeletingState + + override fun doInit() { + data = BookDataState(BookInput(BOOK_ID, BOOK_TITLE), Book) + repository = mockk() + state = DeletingState(context, data, repository) + } + + @Test + fun deletesItemAndTerminates() = test { + coEvery { repository.deleteBook(any()) } returns Unit + every { factory.terminated() } returns nextState + + state.start(stateMachine) + + coVerify { + stateMachine.setUiState(BookUiState.Loading(BOOK_TITLE)) + repository.deleteBook(Book.id) + factory.terminated() + stateMachine.setMachineState(nextState) + } + } + + @Test + fun advancesToErrorIfFails() = test { + val error = RuntimeException("Test error") + coEvery { repository.deleteBook(any()) } throws error + every { factory.error(any(), any()) } returns nextState + + state.start(stateMachine) + + coVerify { + stateMachine.setUiState(BookUiState.Loading(BOOK_TITLE)) + repository.deleteBook(Book.id) + factory.error(any(), error) + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/ErrorStateTest.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/ErrorStateTest.kt new file mode 100644 index 0000000..f016776 --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/ErrorStateTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.BOOK_ID +import com.motorro.statemachine.books.book.BOOK_TITLE +import com.motorro.statemachine.books.book.Book +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import io.mockk.every +import io.mockk.verify +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +internal class ErrorStateTest : BaseStateTest() { + + private lateinit var data: BookDataState + private lateinit var error: Throwable + private lateinit var state: ErrorState + + override fun doInit() { + data = BookDataState(BookInput(BOOK_ID, BOOK_TITLE), Book) + error = RuntimeException("Test error") + state = ErrorState(context, data, error) + } + + @Test + fun displaysErrorOnStart() = test { + state.start(stateMachine) + + verify { + stateMachine.setUiState(withArg { + val errorState = assertIs(it) + assertEquals(error, errorState.error) + }) + } + } + + @Test + fun terminatesOnBack() = test { + every { factory.terminated() } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Back) + + verify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } + + @Test + fun terminatesOnConfirm() = test { + every { factory.terminated() } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Confirm) + + verify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/LoadingStateTest.kt b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/LoadingStateTest.kt new file mode 100644 index 0000000..7db5a48 --- /dev/null +++ b/examples/books/book/src/test/kotlin/com/motorro/statemachine/books/book/state/LoadingStateTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.book.state + +import com.motorro.statemachine.books.book.BOOK_ID +import com.motorro.statemachine.books.book.BOOK_TITLE +import com.motorro.statemachine.books.book.Book +import com.motorro.statemachine.books.book.api.BookGesture +import com.motorro.statemachine.books.book.api.BookInput +import com.motorro.statemachine.books.book.api.BookUiState +import com.motorro.statemachine.books.book.data.BookDataState +import com.motorro.statemachine.books.domain.repository.BookRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.coroutines.suspendCoroutine +import kotlin.test.assertEquals + +internal class LoadingStateTest : BaseStateTest() { + private lateinit var data: BookDataState + lateinit var repository: BookRepository + lateinit var state: BaseBookState + + override fun doInit() { + repository = mockk() + data = BookDataState(BookInput(BOOK_ID, BOOK_TITLE), Book) + state = LoadingState(context, data, repository) + } + + @Test + fun loadsItemAndAdvancesToContent() = test { + coEvery { repository.getBook(any()) } returns Book + every { factory.content(any()) } returns nextState + + state.start(stateMachine) + + coVerify { + stateMachine.setUiState(BookUiState.Loading(BOOK_TITLE)) + repository.getBook(BOOK_ID) + factory.content(withArg { + assertEquals(Book, it.book) + }) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun loadsItemAndAdvancesToErrorIfFails() = test { + val error = RuntimeException("Test error") + coEvery { repository.getBook(any()) } throws error + every { factory.error(any(), any()) } returns nextState + + state.start(stateMachine) + + coVerify { + repository.getBook(BOOK_ID) + factory.error(any(), error) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun terminatesOnBack() = test { + coEvery { repository.getBook(any()) } coAnswers { + suspendCoroutine { + // No-op + } + } + every { factory.terminated() } returns nextState + + state.start(stateMachine) + state.process(BookGesture.Back) + + coVerify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/books/domain/.gitignore b/examples/books/domain/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/books/domain/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/books/domain/build.gradle.kts b/examples/books/domain/build.gradle.kts new file mode 100644 index 0000000..f367afd --- /dev/null +++ b/examples/books/domain/build.gradle.kts @@ -0,0 +1,60 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.lib) +} + +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + minSdk = androidMinSdkVersion + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + namespace = "com.motorro.statemachine.books.domain" +} + +dependencies { + coreLibraryDesugaring(libs.desugaring) + + api(libs.kotlin.immutable) + api(libs.kotlin.coroutines.core) +} diff --git a/examples/books/domain/consumer-rules.pro b/examples/books/domain/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/examples/books/domain/proguard-rules.pro b/examples/books/domain/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/books/domain/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/entity/Book.kt b/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/entity/Book.kt new file mode 100644 index 0000000..5a4da8b --- /dev/null +++ b/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/entity/Book.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.domain.entity + +import kotlinx.collections.immutable.ImmutableList + +/** + * Content data item + */ +data class Book(val id: Int, val title: String, val authors: ImmutableList, val content: String) \ No newline at end of file diff --git a/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/entity/ListBook.kt b/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/entity/ListBook.kt new file mode 100644 index 0000000..8b83a30 --- /dev/null +++ b/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/entity/ListBook.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.domain.entity + +import kotlinx.collections.immutable.ImmutableList + +/** + * List item + */ +data class ListBook(val id: Int, val title: String, val authors: ImmutableList) \ No newline at end of file diff --git a/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/repository/BookRepository.kt b/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/repository/BookRepository.kt new file mode 100644 index 0000000..05c6d6a --- /dev/null +++ b/examples/books/domain/src/main/kotlin/com/motorro/statemachine/books/domain/repository/BookRepository.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.books.domain.repository + +import com.motorro.statemachine.books.domain.entity.Book +import com.motorro.statemachine.books.domain.entity.ListBook +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.Flow + +/** + * Item repository + */ +interface BookRepository { + val books: Flow> + suspend fun getBook(id: Int): Book + suspend fun deleteBook(id: Int) +} diff --git a/examples/di/api/.gitignore b/examples/di/api/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/di/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/di/api/build.gradle.kts b/examples/di/api/build.gradle.kts new file mode 100644 index 0000000..ab5d430 --- /dev/null +++ b/examples/di/api/build.gradle.kts @@ -0,0 +1,71 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.lib) + alias(libs.plugins.compose) +} + +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + minSdk = androidMinSdkVersion + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + namespace = "com.motorro.statemachine.di.api" +} + +dependencies { + api(project(":commonstatemachine")) + api(project(":commonflow:data")) + api(project(":commonflow:compose")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.kotlin.coroutines.core) + + implementation(platform(libs.compose.bom)) + + implementation(libs.bundles.compose.core) +} diff --git a/examples/di/api/consumer-rules.pro b/examples/di/api/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/examples/di/api/proguard-rules.pro b/examples/di/api/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/di/api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/AuthApi.kt b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/AuthApi.kt new file mode 100644 index 0000000..0517567 --- /dev/null +++ b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/AuthApi.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.api + +import androidx.compose.runtime.staticCompositionLocalOf +import com.motorro.commonstatemachine.flow.compose.CommonFlowUiApi +import com.motorro.commonstatemachine.flow.data.CommonFlowDataApi +import com.motorro.statemachine.di.api.data.Session + +/** + * Auth gesture marker + */ +interface AuthGesture + +/** + * Auth UI state marker + */ +interface AuthUiState + +/** + * Auth flow data API + */ +typealias AuthDataApi = CommonFlowDataApi + +/** + * Auth flow ui API + */ +typealias AuthUiApi = CommonFlowUiApi + +/** + * Local Authentication + */ +val LocalAuth = staticCompositionLocalOf { + error("No auth ui api provided") +} + + + + diff --git a/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/AuthFlowHost.kt b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/AuthFlowHost.kt new file mode 100644 index 0000000..cdb1b0c --- /dev/null +++ b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/AuthFlowHost.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.api + +import com.motorro.commonstatemachine.flow.data.CommonFlowHost +import com.motorro.statemachine.di.api.data.Session + +/** + * Handles authentication + */ +typealias AuthFlowHost = CommonFlowHost \ No newline at end of file diff --git a/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/SessionManager.kt b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/SessionManager.kt new file mode 100644 index 0000000..4e74da4 --- /dev/null +++ b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/SessionManager.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.api + +import com.motorro.statemachine.di.api.data.Session +import kotlinx.coroutines.flow.StateFlow + +/** + * Session manager + */ +interface SessionManager { + /** + * Session state + */ + val session: StateFlow + + /** + * Updates session state + * @param session New session state + */ + suspend fun update(session: Session) +} \ No newline at end of file diff --git a/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/data/Session.kt b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/data/Session.kt new file mode 100644 index 0000000..ae26484 --- /dev/null +++ b/examples/di/api/src/main/kotlin/com/motorro/statemachine/di/api/data/Session.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.api.data + +/** + * Session state + */ +sealed class Session { + /** + * No session + */ + data object None : Session() + + /** + * Session is active + */ + data class Active(val accessToken: String, val refreshToken: String) : Session() +} \ No newline at end of file diff --git a/examples/di/app/.gitignore b/examples/di/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/di/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/di/app/build.gradle.kts b/examples/di/app/build.gradle.kts new file mode 100644 index 0000000..ec5bec9 --- /dev/null +++ b/examples/di/app/build.gradle.kts @@ -0,0 +1,140 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("unused") + +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2022 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") + alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt) +} + +val versionCode: String by project.extra +val versionName: String by project.extra +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra +val androidTargetSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + applicationId = "com.motorro.statemachine.di" + + minSdk = androidMinSdkVersion + targetSdk = androidTargetSdkVersion + versionCode = versionCode + versionName = versionName + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + namespace = "com.motorro.statemachine.di.app" + + flavorDimensions += "auth" + productFlavors { + create("login") { + dimension = "auth" + applicationIdSuffix = ".login" + } + create("social") { + dimension = "auth" + applicationIdSuffix = ".social" + } + } +} + +dependencies { + implementation(project(":commonstatemachine")) + implementation(project(":coroutines")) + implementation(project(":examples:commoncore")) + implementation(project(":examples:androidcore")) + implementation(project(":examples:di:api")) + + "loginImplementation"(project(":examples:di:login")) + "socialImplementation"(project(":examples:di:social")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.timber) + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime) + implementation(libs.androidx.lifecycle.livedata) + implementation(libs.androidx.lifecycle.viewmodel) + + implementation(libs.kotlin.coroutines.core) + implementation(libs.kotlin.coroutines.android) + + implementation(platform(libs.compose.bom)) + + implementation(libs.bundles.compose.core) + implementation(libs.compose.activity) + implementation(libs.compose.viewmodel) + implementation(libs.compose.foundation) + implementation(libs.compose.foundation.layouts) + + implementation(libs.hilt.android) + implementation(libs.hilt.compose) + ksp(libs.hilt.compiler) + ksp(libs.hilt.compiler.androidx) + + debugImplementation(libs.compose.material.icons) + + debugImplementation(libs.compose.tooling) + + testImplementation(libs.bundles.test.core) + testImplementation(libs.test.kotlin.coroutines) +} diff --git a/examples/di/app/proguard-rules.pro b/examples/di/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/di/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/di/app/src/main/AndroidManifest.xml b/examples/di/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e09dc21 --- /dev/null +++ b/examples/di/app/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/App.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/App.kt new file mode 100644 index 0000000..ccc579b --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/App.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp +import timber.log.Timber + +@HiltAndroidApp +class App : Application() { + override fun onCreate() { + super.onCreate() + setupLogger() + } + + private fun setupLogger() { + Timber.plant(Timber.DebugTree()) + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/MainActivity.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/MainActivity.kt new file mode 100644 index 0000000..ab05030 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/MainActivity.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2022 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import com.motorro.statemachine.di.api.AuthUiApi +import com.motorro.statemachine.di.api.LocalAuth +import com.motorro.statemachine.di.app.ui.MainScreen +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + + /** + * Provided by variant module + */ + @field:Inject + lateinit var authUiApi: AuthUiApi + + private val viewModel: MainViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + CommonStateMachineTheme { + WithLocals { + MainScreen( + state = state, + onGesture = viewModel::process, + onTerminated = ::finish + ) + } + } + } + } + + /** + * Provides injected locals + */ + @Composable + internal fun WithLocals(block: @Composable () -> Unit) { + CompositionLocalProvider(LocalAuth provides authUiApi) { + block() + } + } +} diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/MainViewModel.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/MainViewModel.kt new file mode 100644 index 0000000..174bad7 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/MainViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app + +import androidx.lifecycle.ViewModel +import com.motorro.commonstatemachine.coroutines.FlowStateMachine +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState +import com.motorro.statemachine.di.app.state.MainFactory +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +internal class MainViewModel @Inject constructor(factory: MainFactory) : ViewModel() { + + private val stateMachine = FlowStateMachine(MainUiState.Loading) { + factory.content() + } + + val uiState: StateFlow get() = stateMachine.uiState + fun process(gesture: MainGesture) { + stateMachine.process(gesture) + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/data/MainGesture.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/data/MainGesture.kt new file mode 100644 index 0000000..9b297cc --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/data/MainGesture.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.data + +import com.motorro.statemachine.di.api.AuthGesture + +/** + * Main flow gesture + */ +internal sealed class MainGesture { + object Back : MainGesture() + object Logout : MainGesture() + data class Auth(val child: AuthGesture) : MainGesture() +} diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/data/MainUiState.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/data/MainUiState.kt new file mode 100644 index 0000000..763cf86 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/data/MainUiState.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.data + +import com.motorro.statemachine.di.api.AuthUiState +import com.motorro.statemachine.di.api.data.Session + +internal sealed class MainUiState { + data object Loading : MainUiState() + data class Auth(val child: AuthUiState) : MainUiState() + data class Content(val session: Session.Active) : MainUiState() + data object Terminated : MainUiState() +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/di/ApplicationModule.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/di/ApplicationModule.kt new file mode 100644 index 0000000..91baaf3 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/di/ApplicationModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.di + +import com.motorro.statemachine.di.api.SessionManager +import com.motorro.statemachine.di.app.session.SessionManagerImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ApplicationModule { + @Binds + @Singleton + abstract fun sessionManager(impl: SessionManagerImpl): SessionManager +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/di/MainModule.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/di/MainModule.kt new file mode 100644 index 0000000..09c2e3b --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/di/MainModule.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.di + +import com.motorro.statemachine.di.app.state.MainFactory +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class MainModule { + @Binds + abstract fun mainFactory(impl: MainFactory.Impl): MainFactory +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/session/SessionManagerImpl.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/session/SessionManagerImpl.kt new file mode 100644 index 0000000..114a267 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/session/SessionManagerImpl.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.session + +import com.motorro.statemachine.di.api.SessionManager +import com.motorro.statemachine.di.api.data.Session +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +internal class SessionManagerImpl @Inject constructor() : SessionManager { + private val _session = MutableStateFlow(Session.None) + override val session: StateFlow get() = _session.asStateFlow() + + override suspend fun update(session: Session) { + _session.emit(session) + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/AuthProxy.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/AuthProxy.kt new file mode 100644 index 0000000..ebb65ad --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/AuthProxy.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.commonstatemachine.ProxyMachineState +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.AuthDataApi +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiState +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState +import javax.inject.Inject + +internal class AuthProxy( + private val context: MainContext, + private val api: AuthDataApi +) : ProxyMachineState(api.getDefaultUiState()) { + + private val flowHost = AuthFlowHost { result -> + when(result) { + null -> { + Logger.d("Auth cancelled. Terminating...") + setMachineState(context.factory.terminated()) + } + + else -> { + Logger.d("Auth completed. Logging in...") + setMachineState(context.factory.loggingIn(result)) + } + } + } + + override fun init() = api.init(flowHost, Unit) + + override fun mapGesture(parent: MainGesture): AuthGesture? = when (parent) { + is MainGesture.Auth -> parent.child + MainGesture.Back -> api.getBackGesture() + else -> null + } + + override fun mapUiState(child: AuthUiState): MainUiState = MainUiState.Auth(child) + + class Factory @Inject constructor(private val api: AuthDataApi) { + fun invoke(context: MainContext) = AuthProxy( + context, + api + ) + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/BaseMainState.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/BaseMainState.kt new file mode 100644 index 0000000..b8e9329 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/BaseMainState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.commonstatemachine.coroutines.CoroutineState +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState + +internal abstract class BaseMainState(context: MainContext) : CoroutineState(), MainContext by context { + override fun doProcess(gesture: MainGesture) { + Logger.w("Unhandled gesture: $gesture") + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/Content.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/Content.kt new file mode 100644 index 0000000..8a090bc --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/Content.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.SessionManager +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +internal class Content(context: MainContext, private val sessionManager: SessionManager) : BaseMainState(context) { + override fun doStart() { + subscribeSession() + } + + private fun subscribeSession() = sessionManager + .session + .onEach { + when(it) { + is Session.None -> { + Logger.d("No session. Switching to Auth...") + setMachineState(factory.auth()) + } + is Session.Active -> render(it) + } + } + .launchIn(stateScope) + + override fun doProcess(gesture: MainGesture) { + when (gesture) { + MainGesture.Back -> { + Logger.d("Back. Terminating...") + setMachineState(factory.terminated()) + } + MainGesture.Logout -> { + Logger.d("Logging out...") + setMachineState(factory.loggingOut()) + } + else -> super.doProcess(gesture) + } + } + + private fun render(session: Session.Active) { + setUiState(MainUiState.Content(session)) + } + + class Factory @Inject constructor(private val sessionManager: SessionManager) { + fun invoke(context: MainContext) = Content( + context, + sessionManager + ) + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/MainContext.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/MainContext.kt new file mode 100644 index 0000000..34d5742 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/MainContext.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +/** + * Main flow context + */ +internal interface MainContext { + val factory: MainFactory +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/MainFactory.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/MainFactory.kt new file mode 100644 index 0000000..a752123 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/MainFactory.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState +import javax.inject.Inject +import javax.inject.Provider + +/** + * Main state factory + */ +internal interface MainFactory { + fun auth(): CommonMachineState + fun loggingIn(session: Session.Active): CommonMachineState + fun loggingOut(): CommonMachineState + fun content(): CommonMachineState + fun terminated(): CommonMachineState + + class Impl @Inject constructor( + private val createContent: Provider, + private val createAuth: Provider, + private val createUpdateSession: Provider, + ) : MainFactory { + + private val context = object : MainContext { + override val factory: MainFactory get() = this@Impl + } + + override fun auth() = createAuth.get().invoke( + context + ) + + override fun loggingIn(session: Session.Active) = createUpdateSession.get().invoke( + context, + session + ) + + override fun loggingOut() = createUpdateSession.get().invoke( + context, + Session.None + ) + + override fun content() = createContent.get().invoke( + context + ) + + override fun terminated() = object : CommonMachineState() { + override fun doStart() { + setUiState(MainUiState.Terminated) + } + } + } +} diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/UpdatingSession.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/UpdatingSession.kt new file mode 100644 index 0000000..bce2095 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/state/UpdatingSession.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.SessionManager +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.app.data.MainUiState +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal class UpdatingSession( + context: MainContext, + private val session: Session, + private val sessionManager: SessionManager +) : BaseMainState(context) { + + override fun doStart() { + setUiState(MainUiState.Loading) + login() + } + + private fun login() = stateScope.launch { + Logger.d("Updating session...") + sessionManager.update(session) + setMachineState(factory.content()) + } + + class Factory @Inject constructor(private val sessionManager: SessionManager) { + fun invoke(context: MainContext, session: Session) = UpdatingSession( + context, + session, + sessionManager + ) + } +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/ui/MainScreen.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/ui/MainScreen.kt new file mode 100644 index 0000000..6cf88a7 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/ui/MainScreen.kt @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.ui + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import com.motorro.statemachine.androidcore.compose.Loading +import com.motorro.statemachine.di.api.LocalAuth +import com.motorro.statemachine.di.app.R +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun MainScreen(state: MainUiState, onGesture: (MainGesture) -> Unit, onTerminated: () -> Unit) { + val onBack: () -> Unit = remember { + { onGesture(MainGesture.Back) } + } + + BackHandler(onBack = onBack) + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.app_name)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.Black + ) + } + } + ) + }, + content = { paddingValues -> + val padding = remember { + Modifier.padding(paddingValues) + } + + when(state) { + // Take injected screen implementation + is MainUiState.Auth -> LocalAuth.current.Screen( + state = state.child, + onGesture = { onGesture(MainGesture.Auth(it)) }, + modifier = padding + ) + is MainUiState.Content -> MainScreenView( + state = state, + onGesture = onGesture, + modifier = padding + ) + MainUiState.Loading -> Loading( + modifier = padding + ) + MainUiState.Terminated -> LaunchedEffect(Unit) { + onTerminated() + } + } + } + ) +} \ No newline at end of file diff --git a/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/ui/MainScreenView.kt b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/ui/MainScreenView.kt new file mode 100644 index 0000000..5dc5ae5 --- /dev/null +++ b/examples/di/app/src/main/kotlin/com/motorro/statemachine/di/app/ui/MainScreenView.kt @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.app.R +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState + +@Composable +internal fun MainScreenView(state: MainUiState.Content, onGesture: (MainGesture) -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.title_content), + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.access_token), + style = MaterialTheme.typography.labelLarge + ) + Text( + text = state.session.accessToken, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.refresh_token), + style = MaterialTheme.typography.labelLarge + ) + Text( + text = state.session.refreshToken, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = { onGesture(MainGesture.Logout) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.logout)) + } + } +} + +@Preview +@Composable +private fun MainScreenViewPreview() { + MainScreenView( + state = MainUiState.Content( + Session.Active( + "access.token.123", + "refresh.token.456" + ) + ), + onGesture = {} + ) +} diff --git a/examples/di/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/examples/di/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..40ce517 --- /dev/null +++ b/examples/di/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/main/res/drawable/ic_launcher_background.xml b/examples/di/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..997ef0b --- /dev/null +++ b/examples/di/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/di/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/examples/di/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..0147ca8 --- /dev/null +++ b/examples/di/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/examples/di/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..0147ca8 --- /dev/null +++ b/examples/di/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/examples/di/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/examples/di/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/examples/di/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/examples/di/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/examples/di/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/examples/di/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/examples/di/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/examples/di/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/examples/di/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/examples/di/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/examples/di/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/examples/di/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/examples/di/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/examples/di/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/examples/di/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/examples/di/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/examples/di/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/examples/di/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/examples/di/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/examples/di/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/examples/di/app/src/main/res/values/colors.xml b/examples/di/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..164d1ef --- /dev/null +++ b/examples/di/app/src/main/res/values/colors.xml @@ -0,0 +1,23 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/examples/di/app/src/main/res/values/strings.xml b/examples/di/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6876969 --- /dev/null +++ b/examples/di/app/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + DI + Access token + Refresh token + Logout + Content + \ No newline at end of file diff --git a/examples/di/app/src/main/res/values/themes.xml b/examples/di/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..01f1f57 --- /dev/null +++ b/examples/di/app/src/main/res/values/themes.xml @@ -0,0 +1,20 @@ + + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/main/res/xml/backup_rules.xml b/examples/di/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..18b86f4 --- /dev/null +++ b/examples/di/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/main/res/xml/data_extraction_rules.xml b/examples/di/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..2d914d6 --- /dev/null +++ b/examples/di/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,32 @@ + + + + + + + + + \ No newline at end of file diff --git a/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/mock.kt b/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/mock.kt new file mode 100644 index 0000000..8c145d0 --- /dev/null +++ b/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/mock.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app + +import com.motorro.statemachine.di.api.data.Session + +internal val ACTIVE_SESSION = Session.Active( + accessToken = "access", + refreshToken = "refresh" +) \ No newline at end of file diff --git a/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/state/BaseStateTest.kt b/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/state/BaseStateTest.kt new file mode 100644 index 0000000..d8804ad --- /dev/null +++ b/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/state/BaseStateTest.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.commonstatemachine.CommonStateMachine +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseStateTest { + protected lateinit var stateMachine: CommonStateMachine + protected lateinit var factory: MainFactory + protected lateinit var context: MainContext + protected lateinit var nextState: BaseMainState + protected lateinit var flowHost: AuthFlowHost + protected lateinit var dispatcher: TestDispatcher + + @Before + fun init() { + dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + stateMachine = mockk(relaxed = true) + factory = mockk() + flowHost = mockk { + every { this@mockk.onComplete(anyNullable()) } just Runs + } + context = object : MainContext { + override val factory: MainFactory = this@BaseStateTest.factory + } + nextState = mockk(relaxed = true) + doInit() + } + + @After + fun deinit() { + Dispatchers.resetMain() + } + + protected fun test(block: suspend TestScope.() -> Unit) = runTest( + dispatcher, + testBody = block + ) + + protected open fun doInit() = Unit +} \ No newline at end of file diff --git a/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/state/ContentTest.kt b/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/state/ContentTest.kt new file mode 100644 index 0000000..7b4a617 --- /dev/null +++ b/examples/di/app/src/test/kotlin/com/motorro/statemachine/di/app/state/ContentTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.app.state + +import com.motorro.statemachine.di.api.SessionManager +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.app.ACTIVE_SESSION +import com.motorro.statemachine.di.app.data.MainGesture +import com.motorro.statemachine.di.app.data.MainUiState +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test + +internal class ContentTest : BaseStateTest() { + private lateinit var session: MutableStateFlow + private lateinit var sessionManager: SessionManager + + private lateinit var state: BaseMainState + + private fun createState(active: Boolean) { + session = MutableStateFlow(if (active) ACTIVE_SESSION else Session.None) + sessionManager = mockk { + every { this@mockk.session } returns this@ContentTest.session + coEvery { this@mockk.update(any()) } just runs + } + state = Content(context, sessionManager) + } + + @Test + fun displaysActiveSession() = test { + createState(active = true) + + state.start(stateMachine) + + coVerify { + stateMachine.setUiState(MainUiState.Content(ACTIVE_SESSION)) + } + } + + @Test + fun switchesToAuthWhenNotAuthorized() = test { + createState(active = false) + every { factory.auth() } returns nextState + + state.start(stateMachine) + session.emit(Session.None) + + coVerify { + factory.auth() + stateMachine.setMachineState(nextState) + } + } + + @Test + fun switchesToAuthWhenLoggedOut() = test { + createState(active = true) + every { factory.auth() } returns nextState + + state.start(stateMachine) + session.emit(Session.None) + + coVerify { + factory.auth() + stateMachine.setMachineState(nextState) + } + } + + @Test + fun terminatesOnBack() = test { + createState(active = true) + every { factory.terminated() } returns nextState + + state.start(stateMachine) + state.process(MainGesture.Back) + + coVerify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } + + @Test + fun transfersToLogout() = test { + createState(active = true) + every { factory.loggingOut() } returns nextState + + state.start(stateMachine) + state.process(MainGesture.Logout) + + coVerify { + factory.loggingOut() + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/di/login/.gitignore b/examples/di/login/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/di/login/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/di/login/build.gradle.kts b/examples/di/login/build.gradle.kts new file mode 100644 index 0000000..8ef3dac --- /dev/null +++ b/examples/di/login/build.gradle.kts @@ -0,0 +1,85 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.lib) + alias(libs.plugins.compose) + alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt) +} + +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + minSdk = androidMinSdkVersion + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + namespace = "com.motorro.statemachine.di.login" +} + +dependencies { + implementation(project(":examples:commoncore")) + implementation(project(":examples:androidcore")) + implementation(project(":examples:di:api")) + implementation(project(":coroutines")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.kotlin.coroutines.core) + + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose.core) + implementation(libs.compose.foundation) + implementation(libs.compose.foundation.layouts) + + implementation(libs.hilt.android) + implementation(libs.hilt.compose) + ksp(libs.hilt.compiler) + ksp(libs.hilt.compiler.androidx) + + debugImplementation(libs.compose.tooling) + + testImplementation(libs.bundles.test.core) + testImplementation(libs.test.kotlin.coroutines) +} diff --git a/examples/di/login/consumer-rules.pro b/examples/di/login/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/examples/di/login/proguard-rules.pro b/examples/di/login/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/di/login/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/LoginConstants.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/LoginConstants.kt new file mode 100644 index 0000000..3fbdfe0 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/LoginConstants.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login + +/** + * Mock data + */ +internal object LoginConstants { + const val USERNAME = "user" + const val PASSWORD = "password" + + const val ACCESS_TOKEN = "login_session_token" + const val REFRESH_TOKEN = "login_refresh_token" +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/LoginDataApi.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/LoginDataApi.kt new file mode 100644 index 0000000..dc7809f --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/LoginDataApi.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.motorro.statemachine.di.api.AuthDataApi +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiApi +import com.motorro.statemachine.di.api.AuthUiState +import com.motorro.statemachine.di.login.data.LoginDataState +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState +import com.motorro.statemachine.di.login.state.LoginFactory +import com.motorro.statemachine.di.login.ui.LoginScreen +import jakarta.inject.Inject + +/** + * Auth data API implementation for login flow + */ +internal class LoginDataApi @Inject constructor(private val createFactory: LoginFactory.Factory) : AuthDataApi { + /** + * Initializes flow + */ + override fun init(flowHost: AuthFlowHost, input: Unit) = createFactory(flowHost).form(LoginDataState()) + + /** + * Returns default UI state + */ + override fun getDefaultUiState() = LoginUiState.Loading + + /** + * Returns back gesture for this flow + */ + override fun getBackGesture(): AuthGesture = LoginGesture.Back +} + +/** + * Auth UI API implementation for login flow + */ +internal class LoginUiApi @Inject constructor() : AuthUiApi { + @Composable + override fun Screen( + state: AuthUiState, + onGesture: (AuthGesture) -> Unit, + modifier: Modifier + ) = LoginScreen( + state = state as LoginUiState, + onGesture = onGesture, + modifier = modifier + ) +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginDataState.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginDataState.kt new file mode 100644 index 0000000..1943f8e --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginDataState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.data + +/** + * Data state + */ +data class LoginDataState( + val username: String = "", + val password: String = "" +) \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginGesture.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginGesture.kt new file mode 100644 index 0000000..24d3a2a --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginGesture.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.data + +import com.motorro.statemachine.di.api.AuthGesture + +internal sealed class LoginGesture : AuthGesture { + data object Back : LoginGesture() + data object Action : LoginGesture() + data class UsernameChanged(val username: String) : LoginGesture() + data class PasswordChanged(val password: String) : LoginGesture() +} diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginUiState.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginUiState.kt new file mode 100644 index 0000000..7857d44 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/data/LoginUiState.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.data + +import com.motorro.statemachine.di.api.AuthUiState + +internal sealed class LoginUiState: AuthUiState { + data object Loading : LoginUiState() + data class Form(val username: String, val password: String, val loginEnabled: Boolean) : LoginUiState() + data class Error(val error: Throwable) : LoginUiState() +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/di/LoginFlowModule.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/di/LoginFlowModule.kt new file mode 100644 index 0000000..18395f2 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/di/LoginFlowModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.di + +import com.motorro.statemachine.di.api.AuthDataApi +import com.motorro.statemachine.di.api.AuthUiApi +import com.motorro.statemachine.di.login.LoginDataApi +import com.motorro.statemachine.di.login.LoginUiApi +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ViewModelComponent + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class LoginFlowDataModule { + @Binds + abstract fun bindLoginDataApi(impl: LoginDataApi): AuthDataApi +} + +@Module +@InstallIn(ActivityComponent::class) +internal abstract class LoginFlowUiModule { + @Binds + abstract fun bindLoginUiApi(impl: LoginUiApi): AuthUiApi +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/BaseLoginState.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/BaseLoginState.kt new file mode 100644 index 0000000..9736270 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/BaseLoginState.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.commonstatemachine.coroutines.CoroutineState +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiState +import com.motorro.statemachine.di.login.data.LoginGesture + +internal abstract class BaseLoginState(context: LoginContext) : CoroutineState(), LoginContext by context { + + final override fun doProcess(gesture: AuthGesture) { + when (gesture) { + is LoginGesture -> doProcess(gesture) + else -> Logger.w("Not a `LoginGesture`: $gesture") + } + } + + protected open fun doProcess(gesture: LoginGesture) { + Logger.w("Gesture not handled: $gesture") + } +} diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/Error.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/Error.kt new file mode 100644 index 0000000..f5ae0a2 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/Error.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.login.data.LoginDataState +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState + +/** + * Displays error + */ +internal class Error( + context: LoginContext, + private val data: LoginDataState, + private val error: Throwable +) : BaseLoginState(context) { + override fun doStart() { + setUiState(LoginUiState.Error(error)) + } + + override fun doProcess(gesture: LoginGesture) { + when(gesture) { + LoginGesture.Back, LoginGesture.Action -> { + Logger.d("Dismissed. Back to form...") + setMachineState(factory.form(data)) + } + else -> super.doProcess(gesture) + } + } +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/FormState.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/FormState.kt new file mode 100644 index 0000000..9ca534e --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/FormState.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.login.data.LoginDataState +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState +import kotlin.properties.Delegates + +internal class FormState(context: LoginContext, data: LoginDataState) : BaseLoginState(context) { + + private var data: LoginDataState by Delegates.observable(data) { _, _, newValue -> + render(newValue) + } + + override fun doStart() { + render(data) + } + + override fun doProcess(gesture: LoginGesture) { + when (gesture) { + LoginGesture.Action -> if (data.isLoginEnabled()) { + Logger.d("Logging in...") + setMachineState(factory.loggingIn(data)) + } + LoginGesture.Back -> { + Logger.d("Back pressed. Terminating...") + setMachineState(factory.terminated()) + } + is LoginGesture.PasswordChanged -> { + data = data.copy(password = gesture.password) + } + is LoginGesture.UsernameChanged -> { + data = data.copy(username = gesture.username) + } + } + } + + private fun LoginDataState.isLoginEnabled(): Boolean = username.isNotBlank() && password.isNotBlank() + + private fun render(data: LoginDataState) { + setUiState(LoginUiState.Form( + username = data.username, + password = data.password, + loginEnabled = data.isLoginEnabled() + )) + } +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoggingIn.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoggingIn.kt new file mode 100644 index 0000000..5025f2c --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoggingIn.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.login.LoginConstants +import com.motorro.statemachine.di.login.data.LoginDataState +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.seconds + +/** + * Emulates login + */ +internal class LoggingIn(context: LoginContext, private val data: LoginDataState) : BaseLoginState(context) { + override fun doStart() { + setUiState(LoginUiState.Loading) + login() + } + + private fun login() = stateScope.launch { + Logger.d("Logging in...") + delay(2.seconds) + if (data.username == LoginConstants.USERNAME && data.password == LoginConstants.PASSWORD) { + Logger.d("Login successful") + setMachineState(factory.complete(Session.Active( + accessToken = LoginConstants.ACCESS_TOKEN, + refreshToken = LoginConstants.REFRESH_TOKEN + ))) + } else { + Logger.d("Login failed") + setMachineState(factory.error( + data, + IllegalArgumentException("Invalid username or password") + )) + } + } + + override fun doProcess(gesture: LoginGesture) { + when(gesture) { + LoginGesture.Back -> { + Logger.d("Back pressed. Back to form...") + setMachineState(factory.form(data)) + } + else -> super.doProcess(gesture) + } + } +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoginContext.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoginContext.kt new file mode 100644 index 0000000..253887e --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoginContext.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.statemachine.di.api.AuthFlowHost + +/** + * Common inter-state context + */ +internal interface LoginContext { + val factory: LoginFactory + val flowHost: AuthFlowHost +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoginFactory.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoginFactory.kt new file mode 100644 index 0000000..46bc515 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/state/LoginFactory.kt @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiState +import com.motorro.statemachine.di.api.data.Session +import com.motorro.statemachine.di.login.data.LoginDataState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Sub-flow state factory + */ +internal interface LoginFactory { + fun form(data: LoginDataState): CommonMachineState + fun loggingIn(data: LoginDataState): CommonMachineState + fun complete(session: Session.Active): CommonMachineState + fun error(data: LoginDataState, error: Throwable): CommonMachineState + fun terminated(): CommonMachineState + + @AssistedFactory + interface Factory { + operator fun invoke(flowHost: AuthFlowHost): Impl + } + + class Impl @AssistedInject constructor(@Assisted flowHost: AuthFlowHost) : LoginFactory { + + private val context = object : LoginContext { + override val factory: LoginFactory = this@Impl + override val flowHost: AuthFlowHost = flowHost + } + + override fun form(data: LoginDataState) = FormState( + context, + data + ) + + override fun loggingIn(data: LoginDataState) = LoggingIn( + context, + data + ) + + override fun complete(session: Session.Active) = object : CommonMachineState() { + override fun doStart() { + context.flowHost.onComplete(session) + } + } + + override fun error(data: LoginDataState, error: Throwable) = Error( + context, + data, + error + ) + + override fun terminated() = object : CommonMachineState() { + override fun doStart() { + context.flowHost.onComplete(null) + } + } + } +} \ No newline at end of file diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/ui/LoginFormView.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/ui/LoginFormView.kt new file mode 100644 index 0000000..37c9634 --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/ui/LoginFormView.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState + +@Composable +internal fun LoginFormView(state: LoginUiState.Form, onGesture: (LoginGesture) -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OutlinedTextField( + value = state.username, + onValueChange = { onGesture(LoginGesture.UsernameChanged(it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text(text = "Username") } + ) + OutlinedTextField( + value = state.password, + onValueChange = { onGesture(LoginGesture.PasswordChanged(it)) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + label = { Text(text = "Password") }, + visualTransformation = PasswordVisualTransformation() + ) + Button( + onClick = { onGesture(LoginGesture.Action) }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + enabled = state.loginEnabled + ) { + Text(text = "Login") + } + } +} + +@Preview +@Composable +private fun LoginFormViewPreview() { + CommonStateMachineTheme { + LoginFormView( + state = LoginUiState.Form("user", "password", true), + onGesture = {} + ) + } +} diff --git a/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/ui/LoginScreen.kt b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/ui/LoginScreen.kt new file mode 100644 index 0000000..95734ac --- /dev/null +++ b/examples/di/login/src/main/kotlin/com/motorro/statemachine/di/login/ui/LoginScreen.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.motorro.statemachine.androidcore.compose.Error +import com.motorro.statemachine.androidcore.compose.Loading +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState + +@Composable +internal fun LoginScreen(state: LoginUiState, onGesture: (LoginGesture) -> Unit, modifier: Modifier = Modifier) { + when(state) { + is LoginUiState.Form -> LoginFormView(state, onGesture, modifier) + LoginUiState.Loading -> Loading(modifier) + is LoginUiState.Error -> Error( + error = state.error, + onDismiss = { onGesture(LoginGesture.Action) }, + modifier = modifier + ) + } +} \ No newline at end of file diff --git a/examples/di/login/src/test/kotlin/com/motorro/statemachine/di/login/state/BaseStateTest.kt b/examples/di/login/src/test/kotlin/com/motorro/statemachine/di/login/state/BaseStateTest.kt new file mode 100644 index 0000000..a5cc5e7 --- /dev/null +++ b/examples/di/login/src/test/kotlin/com/motorro/statemachine/di/login/state/BaseStateTest.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.commonstatemachine.CommonStateMachine +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiState +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before + +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class BaseStateTest { + protected lateinit var stateMachine: CommonStateMachine + protected lateinit var factory: LoginFactory + protected lateinit var context: LoginContext + protected lateinit var nextState: BaseLoginState + protected lateinit var flowHost: AuthFlowHost + protected lateinit var dispatcher: TestDispatcher + + @Before + fun init() { + dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + stateMachine = mockk(relaxed = true) + factory = mockk() + flowHost = mockk { + every { this@mockk.onComplete(anyNullable()) } just Runs + } + context = object : LoginContext { + override val factory: LoginFactory = this@BaseStateTest.factory + override val flowHost: AuthFlowHost = this@BaseStateTest.flowHost + } + nextState = mockk(relaxed = true) + doInit() + } + + @After + fun deinit() { + Dispatchers.resetMain() + } + + protected fun test(block: suspend TestScope.() -> Unit) = runTest( + dispatcher, + testBody = block + ) + + protected open fun doInit() = Unit +} \ No newline at end of file diff --git a/examples/di/login/src/test/kotlin/com/motorro/statemachine/di/login/state/FormStateTest.kt b/examples/di/login/src/test/kotlin/com/motorro/statemachine/di/login/state/FormStateTest.kt new file mode 100644 index 0000000..8f4cbc7 --- /dev/null +++ b/examples/di/login/src/test/kotlin/com/motorro/statemachine/di/login/state/FormStateTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.motorro.statemachine.di.login.state + +import com.motorro.statemachine.di.login.data.LoginDataState +import com.motorro.statemachine.di.login.data.LoginGesture +import com.motorro.statemachine.di.login.data.LoginUiState +import io.mockk.Ordering +import io.mockk.coVerify +import io.mockk.every +import org.junit.Test +import kotlin.test.assertEquals + +internal class FormStateTest : BaseStateTest() { + + private lateinit var state: BaseLoginState + + override fun doInit() { + state = FormState(context, LoginDataState()) + } + + @Test + fun rendersDataOnStart() = test { + state.start(stateMachine) + + coVerify { + stateMachine.setUiState(LoginUiState.Form(username = "", password = "", loginEnabled = false)) + } + } + + @Test + fun willNotEnableLoginWhenUserEmpty() = test { + state.start(stateMachine) + state.process(LoginGesture.PasswordChanged("pass")) + state.process(LoginGesture.Action) + + coVerify(exactly = 0) { + factory.loggingIn(any()) + stateMachine.setMachineState(any()) + } + } + + @Test + fun willNotEnableLoginWhenPasswordEmpty() = test { + state.start(stateMachine) + state.process(LoginGesture.UsernameChanged("user")) + state.process(LoginGesture.Action) + + coVerify(exactly = 0) { + factory.loggingIn(any()) + stateMachine.setMachineState(any()) + } + } + + @Test + fun updatesUsername() = test { + state.start(stateMachine) + state.process(LoginGesture.UsernameChanged("user")) + + coVerify(ordering = Ordering.ORDERED) { + stateMachine.setUiState(LoginUiState.Form(username = "", password = "", loginEnabled = false)) + stateMachine.setUiState(LoginUiState.Form(username = "user", password = "", loginEnabled = false)) + } + } + + @Test + fun updatesPassword() = test { + state.start(stateMachine) + state.process(LoginGesture.PasswordChanged("pass")) + + coVerify(ordering = Ordering.ORDERED) { + stateMachine.setUiState(LoginUiState.Form(username = "", password = "", loginEnabled = false)) + stateMachine.setUiState(LoginUiState.Form(username = "", password = "pass", loginEnabled = false)) + } + } + + @Test + fun enablesLoginWhenUserAndPassSet() = test { + state.start(stateMachine) + state.process(LoginGesture.UsernameChanged("user")) + state.process(LoginGesture.PasswordChanged("pass")) + + coVerify(ordering = Ordering.ORDERED) { + stateMachine.setUiState(LoginUiState.Form(username = "", password = "", loginEnabled = false)) + stateMachine.setUiState(LoginUiState.Form(username = "user", password = "", loginEnabled = false)) + stateMachine.setUiState(LoginUiState.Form(username = "user", password = "pass", loginEnabled = true)) + } + } + + @Test + fun startsLogin() = test { + every { factory.loggingIn(any()) } returns nextState + + state.start(stateMachine) + state.process(LoginGesture.UsernameChanged("user")) + state.process(LoginGesture.PasswordChanged("pass")) + state.process(LoginGesture.Action) + + coVerify { + factory.loggingIn(withArg { + assertEquals("user", it.username) + assertEquals("pass", it.password) + }) + stateMachine.setMachineState(nextState) + } + } + + @Test + fun terminatesOnBack() { + every { factory.terminated() } returns nextState + + state.start(stateMachine) + state.process(LoginGesture.Back) + + coVerify { + factory.terminated() + stateMachine.setMachineState(nextState) + } + } +} \ No newline at end of file diff --git a/examples/di/social/.gitignore b/examples/di/social/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/examples/di/social/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/examples/di/social/build.gradle.kts b/examples/di/social/build.gradle.kts new file mode 100644 index 0000000..e948f41 --- /dev/null +++ b/examples/di/social/build.gradle.kts @@ -0,0 +1,86 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.android.lib) + alias(libs.plugins.compose) + alias(libs.plugins.google.ksp) + alias(libs.plugins.hilt) +} + +val androidMinSdkVersion: Int by project.extra +val androidCompileSdkVersion: Int by project.extra + +android { + compileSdk = androidCompileSdkVersion + + defaultConfig { + minSdk = androidMinSdkVersion + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + buildFeatures { + compose = true + } + namespace = "com.motorro.statemachine.di.social" +} + +dependencies { + implementation(project(":examples:commoncore")) + implementation(project(":examples:androidcore")) + implementation(project(":examples:di:api")) + implementation(project(":coroutines")) + + coreLibraryDesugaring(libs.desugaring) + + implementation(libs.kotlin.coroutines.core) + + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose.core) + implementation(libs.compose.foundation) + implementation(libs.compose.foundation.layouts) + implementation(libs.compose.material.icons) + + implementation(libs.hilt.android) + implementation(libs.hilt.compose) + ksp(libs.hilt.compiler) + ksp(libs.hilt.compiler.androidx) + + debugImplementation(libs.compose.tooling) + + testImplementation(libs.bundles.test.core) + testImplementation(libs.test.kotlin.coroutines) +} diff --git a/examples/di/social/consumer-rules.pro b/examples/di/social/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/examples/di/social/proguard-rules.pro b/examples/di/social/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/examples/di/social/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/SocialConstants.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/SocialConstants.kt new file mode 100644 index 0000000..8c4ad86 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/SocialConstants.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social + +/** + * Mock data + */ +internal object SocialConstants { + const val ACCESS_TOKEN = "social_session_token" + const val REFRESH_TOKEN = "social_refresh_token" +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/SocialDataApi.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/SocialDataApi.kt new file mode 100644 index 0000000..5f4b003 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/SocialDataApi.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.motorro.statemachine.di.api.AuthDataApi +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiApi +import com.motorro.statemachine.di.api.AuthUiState +import jakarta.inject.Inject +import main.kotlin.com.motorro.statemachine.di.social.data.SocialGesture +import main.kotlin.com.motorro.statemachine.di.social.data.SocialUiState +import main.kotlin.com.motorro.statemachine.di.social.state.SocialFactory +import main.kotlin.com.motorro.statemachine.di.social.ui.SocialScreen + +/** + * Auth data API implementation for Social flow + */ +internal class SocialDataApi @Inject constructor(private val createFactory: SocialFactory.Factory) : AuthDataApi { + /** + * Initializes flow + */ + override fun init(flowHost: AuthFlowHost, input: Unit) = createFactory(flowHost).form() + + /** + * Returns default UI state + */ + override fun getDefaultUiState() = SocialUiState.Loading + + /** + * Returns back gesture for this flow + */ + override fun getBackGesture(): AuthGesture = SocialGesture.Back +} + +/** + * Auth UI API implementation for Social flow + */ +internal class SocialUiApi @Inject constructor() : AuthUiApi { + @Composable + override fun Screen( + state: AuthUiState, + onGesture: (AuthGesture) -> Unit, + modifier: Modifier + ) = SocialScreen( + state = state as SocialUiState, + onGesture = onGesture, + modifier = modifier + ) +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/data/SocialGesture.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/data/SocialGesture.kt new file mode 100644 index 0000000..89bfcb0 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/data/SocialGesture.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.data + +import com.motorro.statemachine.di.api.AuthGesture + +internal sealed class SocialGesture : AuthGesture { + data object Back : SocialGesture() + data object Action : SocialGesture() +} diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/data/SocialUiState.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/data/SocialUiState.kt new file mode 100644 index 0000000..cdc63d7 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/data/SocialUiState.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.data + +import com.motorro.statemachine.di.api.AuthUiState + +internal sealed class SocialUiState: AuthUiState { + data object Loading : SocialUiState() + data object Form : SocialUiState() +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/di/SocialFlowModule.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/di/SocialFlowModule.kt new file mode 100644 index 0000000..8c8c582 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/di/SocialFlowModule.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.di + +import com.motorro.statemachine.di.api.AuthDataApi +import com.motorro.statemachine.di.api.AuthUiApi +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.components.ViewModelComponent +import main.kotlin.com.motorro.statemachine.di.social.SocialDataApi +import main.kotlin.com.motorro.statemachine.di.social.SocialUiApi + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class SocialFlowDataModule { + @Binds + abstract fun bindSocialDataApi(impl: SocialDataApi): AuthDataApi +} + +@Module +@InstallIn(ActivityComponent::class) +internal abstract class SocialFlowUiModule { + @Binds + abstract fun bindSocialUiApi(impl: SocialUiApi): AuthUiApi +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/BaseSocialState.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/BaseSocialState.kt new file mode 100644 index 0000000..4db3fb2 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/BaseSocialState.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.state + +import com.motorro.commonstatemachine.coroutines.CoroutineState +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiState +import main.kotlin.com.motorro.statemachine.di.social.data.SocialGesture + +internal abstract class BaseSocialState(context: SocialContext) : CoroutineState(), SocialContext by context { + + final override fun doProcess(gesture: AuthGesture) { + when (gesture) { + is SocialGesture -> doProcess(gesture) + else -> Logger.w("Not a `SocialGesture`: $gesture") + } + } + + protected open fun doProcess(gesture: SocialGesture) { + Logger.w("Gesture not handled: $gesture") + } +} diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/FormState.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/FormState.kt new file mode 100644 index 0000000..3427c6f --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/FormState.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.state + +import com.motorro.statemachine.commoncore.log.Logger +import main.kotlin.com.motorro.statemachine.di.social.data.SocialGesture +import main.kotlin.com.motorro.statemachine.di.social.data.SocialUiState + +internal class FormState(context: SocialContext) : BaseSocialState(context) { + + override fun doStart() { + setUiState(SocialUiState.Form) + } + + override fun doProcess(gesture: SocialGesture) { + when (gesture) { + SocialGesture.Action -> { + Logger.d("Logging in...") + setMachineState(factory.loggingIn()) + } + SocialGesture.Back -> { + Logger.d("Back pressed. Terminating...") + setMachineState(factory.terminated()) + } + } + } +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/LoggingIn.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/LoggingIn.kt new file mode 100644 index 0000000..c30c1f7 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/LoggingIn.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.state + +import com.motorro.statemachine.commoncore.log.Logger +import com.motorro.statemachine.di.api.data.Session +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import main.kotlin.com.motorro.statemachine.di.social.SocialConstants +import main.kotlin.com.motorro.statemachine.di.social.data.SocialGesture +import main.kotlin.com.motorro.statemachine.di.social.data.SocialUiState +import kotlin.time.Duration.Companion.seconds + +/** + * Emulates Social login + */ +internal class LoggingIn(context: SocialContext) : BaseSocialState(context) { + override fun doStart() { + setUiState(SocialUiState.Loading) + Social() + } + + private fun Social() = stateScope.launch { + Logger.d("Logging in...") + delay(2.seconds) + setMachineState(factory.complete(Session.Active( + accessToken = SocialConstants.ACCESS_TOKEN, + refreshToken = SocialConstants.REFRESH_TOKEN + ))) + } + + override fun doProcess(gesture: SocialGesture) { + when(gesture) { + SocialGesture.Back -> { + Logger.d("Back pressed. Back to form...") + setMachineState(factory.form()) + } + else -> super.doProcess(gesture) + } + } +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/SocialContext.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/SocialContext.kt new file mode 100644 index 0000000..56b89c6 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/SocialContext.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.state + +import com.motorro.statemachine.di.api.AuthFlowHost + +/** + * Common inter-state context + */ +internal interface SocialContext { + val factory: SocialFactory + val flowHost: AuthFlowHost +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/SocialFactory.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/SocialFactory.kt new file mode 100644 index 0000000..2492310 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/state/SocialFactory.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.state + +import com.motorro.commonstatemachine.CommonMachineState +import com.motorro.statemachine.di.api.AuthFlowHost +import com.motorro.statemachine.di.api.AuthGesture +import com.motorro.statemachine.di.api.AuthUiState +import com.motorro.statemachine.di.api.data.Session +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject + +/** + * Sub-flow state factory + */ +internal interface SocialFactory { + fun form(): CommonMachineState + fun loggingIn(): CommonMachineState + fun complete(session: Session.Active): CommonMachineState + fun terminated(): CommonMachineState + + @AssistedFactory + interface Factory { + operator fun invoke(flowHost: AuthFlowHost): Impl + } + + class Impl @AssistedInject constructor(@Assisted flowHost: AuthFlowHost) : SocialFactory { + + private val context = object : SocialContext { + override val factory: SocialFactory = this@Impl + override val flowHost: AuthFlowHost = flowHost + } + + override fun form() = FormState( + context + ) + + override fun loggingIn() = LoggingIn( + context, + ) + + override fun complete(session: Session.Active) = object : CommonMachineState() { + override fun doStart() { + context.flowHost.onComplete(session) + } + } + + override fun terminated() = object : CommonMachineState() { + override fun doStart() { + context.flowHost.onComplete(null) + } + } + } +} \ No newline at end of file diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/ui/SocialFormView.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/ui/SocialFormView.kt new file mode 100644 index 0000000..8fe1585 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/ui/SocialFormView.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +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.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.motorro.statemachine.androidcore.ui.theme.CommonStateMachineTheme +import main.kotlin.com.motorro.statemachine.di.social.data.SocialGesture +import main.kotlin.com.motorro.statemachine.di.social.data.SocialUiState + +@Composable +internal fun SocialFormView(state: SocialUiState.Form, onGesture: (SocialGesture) -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card(modifier = modifier.fillMaxWidth().clickable { onGesture(SocialGesture.Action) } ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Rounded.AccountCircle, + contentDescription = "Login with Social", + modifier = Modifier.size(96.dp) + ) + Text( + text = "Login with Social", + style = MaterialTheme.typography.headlineMedium + ) + } + } + } +} + +@Preview +@Composable +private fun SocialFormViewPreview() { + CommonStateMachineTheme { + SocialFormView( + state = SocialUiState.Form, + onGesture = {} + ) + } +} diff --git a/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/ui/SocialScreen.kt b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/ui/SocialScreen.kt new file mode 100644 index 0000000..1841ca3 --- /dev/null +++ b/examples/di/social/src/main/kotlin/main/kotlin/com/motorro/statemachine/di/social/ui/SocialScreen.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Nikolai Kotchetkov. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package main.kotlin.com.motorro.statemachine.di.social.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.motorro.statemachine.androidcore.compose.Loading +import main.kotlin.com.motorro.statemachine.di.social.data.SocialGesture +import main.kotlin.com.motorro.statemachine.di.social.data.SocialUiState + +@Composable +internal fun SocialScreen(state: SocialUiState, onGesture: (SocialGesture) -> Unit, modifier: Modifier = Modifier) { + when(state) { + is SocialUiState.Form -> SocialFormView(state, onGesture, modifier) + SocialUiState.Loading -> Loading(modifier) + } +} \ No newline at end of file diff --git a/examples/lifecycle/build.gradle.kts b/examples/lifecycle/build.gradle.kts index ddf09d7..9a15623 100644 --- a/examples/lifecycle/build.gradle.kts +++ b/examples/lifecycle/build.gradle.kts @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget /* -* Copyright 2023 Nikolai Kotchetkov. +* Copyright 2026 Nikolai Kotchetkov. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f0f6b55..6ee01a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,18 @@ [versions] androidGradlePlugin = "9.0.0" +androidxAppcompat = "1.7.1" androidxCore = "1.17.0" +androidxActivity = "1.12.3" +androidxFragment = "1.8.9" androidxHilt = "1.3.0" -compose = "2026.01.00" -composeActivity = "1.12.2" +compose = "2026.01.01" +composeMultiplatform = "1.10.0" +viewmodelMultiplatform = "2.9.6" +lificyclelMultiplatform = "2.9.6" +composeActivity = "1.12.3" composeViewmodel = "2.10.0" coroutines = "1.10.2" +immutable = "0.4.0" desugaring = "2.1.5" dokka = "2.1.0" git = "5.3.3" @@ -21,11 +28,12 @@ testAndroidxCore = "1.7.0" testAndroidxEspresso = "3.7.0" testAndroidxExtJunit = "1.3.0" testJunit = "4.13.2" -testMockk = "1.14.7" +testMockk = "1.14.9" testMockkCommon = "1.13.5" [plugins] compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } android_app = { id = "com.android.application", version.ref = "androidGradlePlugin" } android_lib = { id = "com.android.library", version.ref = "androidGradlePlugin" } kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } @@ -39,14 +47,17 @@ git = { id = "org.ajoberstar.grgit", version.ref = "git" } nexus_publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexusPublish" } [libraries] -kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } +kotlin-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "immutable" } kotlin-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlin-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinDatetime" } desugaring = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugaring" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppcompat" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidxActivity" } +androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidxFragment" } androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } @@ -64,6 +75,11 @@ compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-foundation-layouts = { module = "androidx.compose.foundation:foundation-layout" } +composeMultiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "composeMultiplatform" } +composeMultiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "composeMultiplatform" } +composeMultiplatform-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "viewmodelMultiplatform" } +composeMultiplatform-lifecycle = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lificyclelMultiplatform" } + hilt_android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt_compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt_compiler_androidx = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 62389bb..8270742 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sun Aug 24 18:49:33 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 5f24179..00857ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,9 @@ include( ":tmap", ":commonstatemachine", ":coroutines", + ":commonflow:data", + ":commonflow:compose", + ":commonflow:viewmodel", ":examples:commoncore", ":examples:androidcore", ":examples:welcome:welcome", @@ -49,5 +52,13 @@ include( ":examples:multi:mixed", ":examples:multi:navbar", ":examples:multi:parallel", - ":examples:lifecycle" + ":examples:lifecycle", + ":examples:di:api", + ":examples:di:login", + ":examples:di:social", + ":examples:di:app", + ":examples:books:domain", + ":examples:books:book", + ":examples:books:book:demo", + ":examples:books:app" )