diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..feeb1a9 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,140 @@ +name: Library Release Deploy + +on: + push: + branches: [ "main" ] + workflow_dispatch: + +env: + GITHUB_USERNAME: "meetacy" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + + deploy-multiplatform: + runs-on: ubuntu-latest + outputs: + release_version: ${{ steps.output_version.outputs.release_version }} + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + release_version=$(./gradlew printVersion -q) + echo "release_version=$release_version" >> $GITHUB_ENV + - name: Publish ${{ env.release_version }} + run: ./gradlew publishKotlinMultiplatformPublicationToGitHubRepository + - name: Add Sdk Version to Output + id: output_version + run: echo "release_version=${{ env.release_version }}" >> $GITHUB_OUTPUT + + deploy-jvm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + release_version=$(./gradlew printVersion -q) + echo "release_version=$release_version" >> $GITHUB_ENV + - name: Publish ${{ env.release_version }} + run: ./gradlew publishJvmPublicationToGitHubRepository + + deploy-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + release_version=$(./gradlew printVersion -q) + echo "release_version=$release_version" >> $GITHUB_ENV + - name: Publish ${{ env.release_version }} + run: ./gradlew publishAndroidPublicationToGitHubRepository + + deploy-js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + release_version=$(./gradlew printVersion -q) + echo "release_version=$release_version" >> $GITHUB_ENV + - name: Publish ${{ env.release_version }} + run: ./gradlew publishJsPublicationToGitHubRepository + + deploy-konan: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + - name: Konan Cache Setup + uses: actions/cache@v3 + with: + path: ~/.konan + key: konan-cache + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + release_version=$(./gradlew printVersion -q) + echo "release_version=$release_version" >> $GITHUB_ENV + - name: Publish ${{ env.release_version }} + run: | + ./gradlew publishIosX64PublicationToGitHubRepository \ + publishIosSimulatorArm64PublicationToGitHubRepository \ + publishIosArm64PublicationToGitHubRepository + + create-release: + runs-on: ubuntu-latest + needs: + - deploy-multiplatform + - deploy-jvm + - deploy-js + - deploy-konan + - deploy-android + steps: + - name: Create Release + uses: actions/create-release@v1 + with: + tag_name: ${{ needs.deploy-multiplatform.outputs.release_version }} + release_name: Release ${{ needs.deploy-multiplatform.outputs.release_version }} diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml new file mode 100644 index 0000000..3e4f8de --- /dev/null +++ b/.github/workflows/publish-snapshot.yml @@ -0,0 +1,132 @@ +name: Library Snapshot Deploy + +on: + push: + branches-ignore: [ "main" ] + workflow_dispatch: + +env: + GITHUB_USERNAME: "meetacy" + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + ORG_GRADLE_PROJECT_snapshot: true + ORG_GRADLE_PROJECT_commit: ${{ github.sha }} + ORG_GRADLE_PROJECT_attempt: ${{ github.run_attempt }} + +jobs: + + deploy-multiplatform: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + with: + cache-read-only: ${{ github.ref != 'refs/heads/dev' }} + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + snapshot_version=$(./gradlew printVersion -q) + echo "snapshot_version=$snapshot_version" >> $GITHUB_ENV + - name: Publish ${{ env.snapshot_version }} + run: ./gradlew publishKotlinMultiplatformPublicationToMeetacySdkRepository + + deploy-jvm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + with: + cache-read-only: ${{ github.ref != 'refs/heads/dev' }} + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + snapshot_version=$(./gradlew printVersion -q) + echo "snapshot_version=$snapshot_version" >> $GITHUB_ENV + - name: Publish ${{ env.snapshot_version }} + run: ./gradlew publishJvmPublicationToMeetacySdkRepository + + deploy-android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + release_version=$(./gradlew printVersion -q) + echo "release_version=$release_version" >> $GITHUB_ENV + - name: Publish ${{ env.release_version }} + run: ./gradlew publishAndroidPublicationToGitHubRepository + + deploy-js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + with: + cache-read-only: ${{ github.ref != 'refs/heads/dev' }} + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + snapshot_version=$(./gradlew printVersion -q) + echo "snapshot_version=$snapshot_version" >> $GITHUB_ENV + - name: Publish ${{ env.snapshot_version }} + run: ./gradlew publishJsPublicationToMeetacySdkRepository + + deploy-konan: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 11 + - name: Gradle Cache Setup + uses: gradle/gradle-build-action@v2 + with: + cache-read-only: ${{ github.ref != 'refs/heads/dev' }} + - name: Konan Cache Setup + uses: actions/cache@v3 + with: + path: ~/.konan + key: konan-cache + - name: Gradle Sync + run: ./gradlew + - name: Add Version to Env + run: | + snapshot_version=$(./gradlew printVersion -q) + echo "snapshot_version=$snapshot_version" >> $GITHUB_ENV + - name: Publish ${{ env.snapshot_version }} + run: | + ./gradlew publishIosX64PublicationToMeetacySdkRepository \ + publishIosSimulatorArm64PublicationToMeetacySdkRepository \ + publishIosArm64PublicationToMeetacySdkRepository diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d14494 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.idea +build +.gradle +.kotlin +kotlin-js-store +local.properties +**/.DS_Store +*/**/xcuserdata +**/*xcworkspace diff --git a/README.md b/README.md new file mode 100644 index 0000000..567c59c --- /dev/null +++ b/README.md @@ -0,0 +1,219 @@ +# Kotlin State Machine + +## Overall Idea + +This library tries to unify approach of different implications of +state machines. For now, considered cases to support are: + +- Multiplatform Navigation (With convenient native wrappers) +- Native Navigation for android/iOS (Without redundant entities needed for multiplatform) +- FSM for telegram bots + +Even though FSM for telegram bots and Navigation seems to be completely +different things they actually do share lots of common things such as: + +- Coroutines Integration (both need to start/cancel scope when start/finish state and structured concurrency when state starts child state) +- Serialization Integration (both need to save entries when state is resumed = +either the state was just created or it's child was finished and the state become top state) +- Stack Integration (both have the same stack idea which is also shared between them) + +## Plugins + +All functionality of StateController is extended using Plugins. Plugin is just a simple interface with +the only method 'install(context: Context): Context' which creates a new context with plugin +installed in it. + +There are some core principles and idioms of writing plugins. + + +// TODO: + +- plugin and it's internal things under 'plugin' folder +- public apis under feature folder +- you should NEVER append elements to context except for context interceptors (otherwise onResume will work incorrect) + +## WIP + +Some WIP docs are under [docs folder](docs). + +## Example + +### Telegram Bots + +
+ Example + +```kotlin +package ksm.ktgbotapi.example + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.extensions.utils.types.buttons.replyKeyboard +import dev.inmo.tgbotapi.types.buttons.SimpleKeyboardButton +import dev.inmo.tgbotapi.types.message.abstracts.PrivateContentMessage +import dev.inmo.tgbotapi.types.update.MessageUpdate +import kotlinx.coroutines.flow.Flow +import ksm.context.finish +import ksm.finish +import ksm.kotlinx.serialization.plugin.KotlinxSerializationPlugin +import ksm.ktgbotapi.* +import ksm.ktgbotapi.match.command +import ksm.ktgbotapi.match.exact +import ksm.state.builder.StateRouteScope +import ksm.state.builder.states +import ksm.state.launch +import ksm.state.name.named + +const val MAIN_STATE = "MainState" +const val MAIN_MENU_STATE = "MainMenuState" +const val STATE_A = "MainMenuState" +const val STATE_B = "MainMenuState" + +val MessageUpdate.peerKey: TelegramPeerKey get() { + val message = data as PrivateContentMessage<*> + val id = message.chat.id + return TelegramPeerKey(id.chatId.long.toString()) +} + +suspend fun start( + bot: TelegramBot, + updates: Flow +) { + val fsm = TelegramBotStateMachine { + install(KotlinxSerializationPlugin()) + + states { + main() + mainMenu() + stateA() + stateB() + } + } + + fsm.start( + startStateName = MAIN_STATE, + telegramBot = bot, + updates = updates, + key = MessageUpdate::peerKey + ) +} + +fun StateRouteScope.main() = named(MAIN_STATE) { + execute { + sendMessage( + text = "Hello, ${user.firstName}! Choose an option using buttons below:", + replyMarkup = replyKeyboard(oneTimeKeyboard = true) { + +SimpleKeyboardButton(text = "Launch StateA") + +SimpleKeyboardButton(text = "Launch StateB") + } + ) + controller.launch(MAIN_MENU_STATE) + } +} + +fun StateRouteScope.mainMenu() = named(MAIN_MENU_STATE) { + execute { + matchMessage { + exact("Launch StateA") { controller.launch(STATE_A) } + exact("Launch StateB") { controller.launch(STATE_B) } + command("cancel") { controller.finish() } + } + } +} + +fun StateRouteScope.stateA() = named(STATE_A) { + execute { sendMessage("StateA!") } +} + +fun StateRouteScope.stateB() = named(STATE_B) { + execute { sendMessage("StateB!") } +} +``` + +
+ +### Compose + +
+ Example + +```kotlin +package ksm.compose.example + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import app.meetacy.di.DI +import ksm.mdi.di +import ksm.mdi.plugin.DIPlugin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import ksm.compose.Content +import ksm.compose.example.MainViewModel.Action +import ksm.compose.host.StateHost +import ksm.compose.rememberStateController +import ksm.kotlinx.serialization.plugin.KotlinxSerializationPlugin +import ksm.state.builder.StateRouteScope +import ksm.state.builder.states +import ksm.state.data.receive +import ksm.state.launch +import ksm.state.name.named + +class MainViewModel { + val actions: Flow = emptyFlow() + + sealed interface Action { + data object RouteDetails : Action + } +} + +data class DetailsParameters(val info: String) + +const val MAIN_STATE = "MainState" +const val DETAILS_STATE = "DetailsState" + +@Composable +fun AppContent(di: DI) { + val controller = rememberStateController { + install(KotlinxSerializationPlugin()) + install(DIPlugin(di)) + + states { + main() + details() + } + } + + StateHost( + controller = controller, + startStateName = MAIN_STATE + ) +} + +fun StateRouteScope.main() = named(MAIN_STATE) { + Content { + val viewModel: MainViewModel = controller.di.viewModel() + + LaunchedEffect(viewModel) { + viewModel.actions.collect { action -> + when (action) { + Action.RouteDetails -> controller.launch( + name = DETAILS_STATE, + data = DetailsParameters(info = "Test") + ) + } + } + } + } +} + +fun StateRouteScope.details() = named(DETAILS_STATE) { + Content { + val parameters: DetailsParameters = controller.receive() + + LaunchedEffect(Unit) { + println(parameters) + } + } +} +``` + +
diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 0000000..60a2a2b --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + `kotlin-dsl` +} + +dependencies { + api(libs.kotlin.gradle.plugin) + api(libs.kotlinx.serialization.gradle.plugin) + api(libs.android.gradle.plugin) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 0000000..ae470e1 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,12 @@ +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } + + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/build-logic/src/main/kotlin/Version.kt b/build-logic/src/main/kotlin/Version.kt new file mode 100644 index 0000000..719ec1a --- /dev/null +++ b/build-logic/src/main/kotlin/Version.kt @@ -0,0 +1,26 @@ +import org.gradle.api.Project + +fun Project.versionFromProperties(acceptor: (String) -> Unit) { + afterEvaluate { + acceptor(versionFromProperties()) + } +} + +fun Project.versionFromProperties(): String { + val snapshot = project.findProperty("snapshot")?.toString()?.toBooleanStrict() + if (snapshot == null || !snapshot) return project.version.toString() + + val commit = project.property("commit").toString() + val attempt = project.property("attempt").toString().toInt() + + val version = buildString { + append(project.version) + append("-build") + append(commit.take(n = 7)) + if (attempt > 1) { + append(attempt) + } + } + + return version +} diff --git a/build-logic/src/main/kotlin/android-library-convention.gradle.kts b/build-logic/src/main/kotlin/android-library-convention.gradle.kts new file mode 100644 index 0000000..2081e18 --- /dev/null +++ b/build-logic/src/main/kotlin/android-library-convention.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.library") + kotlin("android") + id("publication-convention") +} + +android { + compileSdk = 33 + + defaultConfig { + minSdk = 21 + targetSdk = 33 + } + + buildFeatures { + buildConfig = false + } + + publishing { + singleVariant("release") { + withSourcesJar() + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } +} + +publishing { + publications { + register("android") { + groupId = "app.meetacy.ksm" + artifactId = project.name + afterEvaluate { + version = versionFromProperties() + from(components["release"]) + } + } + } +} + +kotlin { + explicitApi() + jvmToolchain(8) +} diff --git a/build-logic/src/main/kotlin/kmp-library-convention.gradle.kts b/build-logic/src/main/kotlin/kmp-library-convention.gradle.kts new file mode 100644 index 0000000..a0bdddc --- /dev/null +++ b/build-logic/src/main/kotlin/kmp-library-convention.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("multiplatform") + id("publication-convention") +} + +kotlin { + explicitApi() + + jvmToolchain(8) + + jvm() + + js(IR) { + browser() + nodejs() + } + iosArm64() + iosX64() + iosSimulatorArm64() +} diff --git a/build-logic/src/main/kotlin/print-version-convention.gradle.kts b/build-logic/src/main/kotlin/print-version-convention.gradle.kts new file mode 100644 index 0000000..b31e74a --- /dev/null +++ b/build-logic/src/main/kotlin/print-version-convention.gradle.kts @@ -0,0 +1,13 @@ +@file:Suppress("UNUSED_VARIABLE") + +import org.gradle.kotlin.dsl.creating + +tasks { + val printVersion by creating { + group = "CI" + + doFirst { + println(versionFromProperties()) + } + } +} diff --git a/build-logic/src/main/kotlin/publication-convention.gradle.kts b/build-logic/src/main/kotlin/publication-convention.gradle.kts new file mode 100644 index 0000000..b6d92bc --- /dev/null +++ b/build-logic/src/main/kotlin/publication-convention.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("org.gradle.maven-publish") +} + +group = "app.meetacy.ksm" + +publishing { + repositories { + maven { + name = "GitHub" + url = uri("https://maven.pkg.github.com/meetacy/ksm") + credentials { + username = System.getenv("GITHUB_USERNAME") + password = System.getenv("GITHUB_TOKEN") + } + } + } + + publications.withType { + versionFromProperties { version -> + this.version = version + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6b123e4 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("print-version-convention") +} + +version = libs.versions.ksm.get() diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..e396a49 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("kmp-library-convention") +} + +version = libs.versions.ksm.get() diff --git a/core/src/commonMain/kotlin/ksm/StateController.kt b/core/src/commonMain/kotlin/ksm/StateController.kt new file mode 100644 index 0000000..7f9205a --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/StateController.kt @@ -0,0 +1,57 @@ +package ksm + +import ksm.builder.StateControllerBuilder +import ksm.builtin.HasBuiltinPluginsPlugin +import ksm.context.StateContext +import ksm.context.configuration.plugin.ConfigurationPlugin +import ksm.context.finish +import ksm.finish.once.plugin.FinishOncePlugin +import ksm.lifecycle.plugin.LifecyclePlugin +import ksm.stack.plugin.StateStackPlugin +import ksm.state.name.plugin.StateNamePlugin +import ksm.state.parameters.plugin.StateParametersPlugin + +public inline fun createRawStateController( + context: StateContext = StateContext.Empty, + enableStateName: Boolean = true, + enableStateParameters: Boolean = true, + enableConfiguration: Boolean = true, + enableLifecycle: Boolean = true, + enableStateStack: Boolean = true, + enableFinishOnce: Boolean = true, + builder: StateControllerBuilder.() -> Unit = {} +): StateController { + var applied = context + + applied = StateControllerBuilder(applied).apply { + if (HasBuiltinPluginsPlugin in context) return@apply + + // Installing built-in features + if (enableStateName) install(StateNamePlugin) + if (enableStateParameters) install(StateParametersPlugin) + if (enableConfiguration) install(ConfigurationPlugin) + if (enableLifecycle) install(LifecyclePlugin) + if (enableStateStack) install(StateStackPlugin) + if (enableFinishOnce) install(FinishOncePlugin) + + install(HasBuiltinPluginsPlugin) + }.apply(builder).build() + + return object : StateController { + override val context = applied + } +} + +public fun StateController(context: StateContext): StateController { + return object : StateController { + override val context = context + } +} + +public interface StateController { + public val context: StateContext +} + +public fun StateController.finish() { + context.finish() +} diff --git a/core/src/commonMain/kotlin/ksm/annotation/MutateContext.kt b/core/src/commonMain/kotlin/ksm/annotation/MutateContext.kt new file mode 100644 index 0000000..d89c855 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/annotation/MutateContext.kt @@ -0,0 +1,7 @@ +package ksm.annotation + +@RequiresOptIn( + message = "Indicates that the following function creates a copy of context. " + + "Do not opt-in! Only propagate this annotation" +) +public annotation class MutateContext diff --git a/core/src/commonMain/kotlin/ksm/annotation/StateBuilderDSL.kt b/core/src/commonMain/kotlin/ksm/annotation/StateBuilderDSL.kt new file mode 100644 index 0000000..449dfea --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/annotation/StateBuilderDSL.kt @@ -0,0 +1,4 @@ +package ksm.annotation + +@DslMarker +public annotation class StateBuilderDSL diff --git a/core/src/commonMain/kotlin/ksm/builder/StateControllerBuilder.kt b/core/src/commonMain/kotlin/ksm/builder/StateControllerBuilder.kt new file mode 100644 index 0000000..e55a251 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/builder/StateControllerBuilder.kt @@ -0,0 +1,18 @@ +package ksm.builder + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.install +import ksm.plugin.Plugin + +public class StateControllerBuilder(public var context: StateContext) { + + @OptIn(MutateContext::class) + public fun install(plugin: Plugin) { + context = context.install(plugin) + } + + public fun build(): StateContext { + return context + } +} diff --git a/core/src/commonMain/kotlin/ksm/builtin/HasBuiltinPluginsPlugin.kt b/core/src/commonMain/kotlin/ksm/builtin/HasBuiltinPluginsPlugin.kt new file mode 100644 index 0000000..61030cb --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/builtin/HasBuiltinPluginsPlugin.kt @@ -0,0 +1,12 @@ +package ksm.builtin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.plugin.Plugin + +public object HasBuiltinPluginsPlugin : Plugin.Singleton { + + // HasBuiltinPluginsPlugin is installed and added to context automatically + @MutateContext + override fun install(context: StateContext): StateContext = context +} diff --git a/core/src/commonMain/kotlin/ksm/context/CombinedContext.kt b/core/src/commonMain/kotlin/ksm/context/CombinedContext.kt new file mode 100644 index 0000000..1149f54 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/CombinedContext.kt @@ -0,0 +1,22 @@ +package ksm.context + +import ksm.annotation.MutateContext + +/** + * [right] elements overwrite [left] elements + */ +internal class CombinedContext( + val left: StateContext, + val right: StateContext +) : StateContext { + override val keys = left.keys + right.keys + + override fun get(key: StateContext.Key): T? { + return right[key] ?: left[key] + } + + @MutateContext + override fun minus(key: StateContext.Key<*>): StateContext { + return CombinedContext(left.minus(key), right.minus(key)) + } +} diff --git a/core/src/commonMain/kotlin/ksm/context/CreateChildStateContext.kt b/core/src/commonMain/kotlin/ksm/context/CreateChildStateContext.kt new file mode 100644 index 0000000..8e55629 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/CreateChildStateContext.kt @@ -0,0 +1,23 @@ +package ksm.context + +import ksm.annotation.MutateContext +import ksm.context.configuration.plugin.ConfigurationPlugin +import ksm.lifecycle.plugin.LifecyclePlugin + +@OptIn(MutateContext::class) +public inline fun StateContext.createChildContext( + setup: (StateContext) -> Unit +): StateContext { + this[LifecyclePlugin]?.onPause(context = this) + + val applied = this[ConfigurationPlugin] + ?.onConfigure(context = this) + ?: this + + applied.apply(setup) + + applied[LifecyclePlugin]?.onCreate(applied) + applied[LifecyclePlugin]?.onResume(applied) + + return applied +} diff --git a/core/src/commonMain/kotlin/ksm/context/FinishStateContext.kt b/core/src/commonMain/kotlin/ksm/context/FinishStateContext.kt new file mode 100644 index 0000000..8b5bfdf --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/FinishStateContext.kt @@ -0,0 +1,14 @@ +package ksm.context + +import ksm.finish.once.plugin.FinishOncePlugin +import ksm.stack.previousContextOrNull +import ksm.lifecycle.plugin.LifecyclePlugin + +public fun StateContext.finish(): StateContext { + this[FinishOncePlugin]?.finish(context = this) + this[LifecyclePlugin]?.onPause(context = this) + this[LifecyclePlugin]?.onFinish(context = this) + val previousContext = previousContextOrNull ?: return this + previousContext[LifecyclePlugin]?.onResume(context = previousContext) + return this +} diff --git a/core/src/commonMain/kotlin/ksm/context/InstallPlugin.kt b/core/src/commonMain/kotlin/ksm/context/InstallPlugin.kt new file mode 100644 index 0000000..8326e3b --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/InstallPlugin.kt @@ -0,0 +1,9 @@ +package ksm.context + +import ksm.annotation.MutateContext +import ksm.plugin.Plugin + +@MutateContext +public fun StateContext.install(plugin: Plugin): StateContext { + return plugin.install(context = this + plugin) +} diff --git a/core/src/commonMain/kotlin/ksm/context/SetupStateContext.kt b/core/src/commonMain/kotlin/ksm/context/SetupStateContext.kt new file mode 100644 index 0000000..13a7da8 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/SetupStateContext.kt @@ -0,0 +1,16 @@ +package ksm.context + +import ksm.annotation.MutateContext + +@MutateContext +public inline fun StateContext.plus( + vararg elements: StateContext.Element, + block: StateContext.() -> Unit = {} +): StateContext { + val result = elements.fold( + initial = this, + operation = StateContext::plus + ) + block(result) + return result +} diff --git a/core/src/commonMain/kotlin/ksm/context/SingleElementContext.kt b/core/src/commonMain/kotlin/ksm/context/SingleElementContext.kt new file mode 100644 index 0000000..7bfc9d1 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/SingleElementContext.kt @@ -0,0 +1,25 @@ +package ksm.context + +import ksm.annotation.MutateContext + +public fun StateContext.Element.asContext(): StateContext { + return SingleElementContext(element = this) +} + +private class SingleElementContext(val element: StateContext.Element): StateContext { + override val keys = setOf(element.key) + + @Suppress("UNCHECKED_CAST") + override fun get(key: StateContext.Key): T? { + return if (element.key == key) (this as T) else null + } + + @MutateContext + override fun minus(key: StateContext.Key<*>): StateContext { + return if (key == element.key) { + StateContext.Empty + } else { + this + } + } +} diff --git a/core/src/commonMain/kotlin/ksm/context/StateContext.kt b/core/src/commonMain/kotlin/ksm/context/StateContext.kt new file mode 100644 index 0000000..98e5310 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/StateContext.kt @@ -0,0 +1,57 @@ +package ksm.context + +import ksm.annotation.MutateContext + +public interface StateContext { + /** + * Contract: Variable should be constant + */ + public val keys: Set> + + public operator fun get(key: Key): T? + public fun require(key: Key): T = get(key) ?: error("Cannot find element with key $key") + + public operator fun contains(key: Key<*>): Boolean = get(key) != null + + @MutateContext + public operator fun plus(other: Element): StateContext { + return plus(other.asContext()) + } + + @MutateContext + public operator fun plus(other: StateContext): StateContext { + if (this === Empty) return other + return CombinedContext(left = this, right = other) + } + + @MutateContext + public operator fun minus(key: Key<*>): StateContext + + public interface Key + + public interface Element { + public val key: Key<*> + + public interface Singleton> : Element, Key { + override val key: Key get() = this + } + } + + public object Empty : StateContext { + override val keys: Set> = emptySet() + override fun get(key: Key): T? = null + @MutateContext + override fun minus(key: Key<*>): StateContext = this + } +} + + +public operator fun StateContext?.get(key: StateContext.Key): T? { + return this?.get(key) +} + +public operator fun StateContext?.contains(key: StateContext.Key<*>): Boolean { + return this?.contains(key) ?: false +} + +public fun StateContext?.orEmpty(): StateContext = this ?: StateContext.Empty diff --git a/core/src/commonMain/kotlin/ksm/context/configuration/interceptor/ConfigurationInterceptor.kt b/core/src/commonMain/kotlin/ksm/context/configuration/interceptor/ConfigurationInterceptor.kt new file mode 100644 index 0000000..0c19ca2 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/configuration/interceptor/ConfigurationInterceptor.kt @@ -0,0 +1,10 @@ +package ksm.context.configuration.interceptor + +import ksm.annotation.MutateContext +import ksm.context.StateContext + +public fun interface ConfigurationInterceptor { + + @MutateContext + public fun onConfigure(context: StateContext): StateContext +} diff --git a/core/src/commonMain/kotlin/ksm/context/configuration/interceptor/StateContext.kt b/core/src/commonMain/kotlin/ksm/context/configuration/interceptor/StateContext.kt new file mode 100644 index 0000000..0fb080b --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/configuration/interceptor/StateContext.kt @@ -0,0 +1,12 @@ +package ksm.context.configuration.interceptor + +import ksm.context.StateContext +import ksm.context.configuration.plugin.ConfigurationPlugin +import ksm.plugin.plugin + +public fun StateContext.addConfigurationInterceptor(interceptor: ConfigurationInterceptor) { + plugin(ConfigurationPlugin).addConfigurationInterceptor( + context = this, + interceptor = interceptor + ) +} diff --git a/core/src/commonMain/kotlin/ksm/context/configuration/plugin/ConfigurationPlugin.kt b/core/src/commonMain/kotlin/ksm/context/configuration/plugin/ConfigurationPlugin.kt new file mode 100644 index 0000000..9edbabb --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/configuration/plugin/ConfigurationPlugin.kt @@ -0,0 +1,26 @@ +package ksm.context.configuration.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.plugin.Plugin + +public object ConfigurationPlugin : Plugin.Singleton { + + @MutateContext + override fun install(context: StateContext): StateContext { + return context + ConfigurationStateController() + } + + public fun addConfigurationInterceptor( + context: StateContext, + interceptor: ConfigurationInterceptor + ) { + context.require(ConfigurationStateController).addInterceptor(interceptor) + } + + @MutateContext + public fun onConfigure(context: StateContext): StateContext { + return context.require(ConfigurationStateController).onConfigure(context) + } +} diff --git a/core/src/commonMain/kotlin/ksm/context/configuration/plugin/ConfigurationStateController.kt b/core/src/commonMain/kotlin/ksm/context/configuration/plugin/ConfigurationStateController.kt new file mode 100644 index 0000000..65ee286 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/context/configuration/plugin/ConfigurationStateController.kt @@ -0,0 +1,22 @@ +package ksm.context.configuration.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor + +internal class ConfigurationStateController : StateContext.Element { + override val key = ConfigurationStateController + + private val interceptors = mutableListOf() + + fun addInterceptor(interceptor: ConfigurationInterceptor) { + interceptors += interceptor + } + + @MutateContext + fun onConfigure(context: StateContext): StateContext { + return interceptors.fold(context) { acc, interceptor -> interceptor.onConfigure(acc) } + } + + companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/finish/once/plugin/FinishOnceEntry.kt b/core/src/commonMain/kotlin/ksm/finish/once/plugin/FinishOnceEntry.kt new file mode 100644 index 0000000..f960ac4 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/finish/once/plugin/FinishOnceEntry.kt @@ -0,0 +1,20 @@ +package ksm.finish.once.plugin + +import ksm.context.StateContext + +internal class FinishOnceEntry : StateContext.Element { + override val key = FinishOncePlugin + + private var isFinished: Boolean = false + + fun checkCanCreate() { + if (isFinished) error("Current state was already finished, cannot create a new one") + } + + fun finish() { + if (isFinished) error("Current state was already finished, cannot finish it twice") + isFinished = true + } + + companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/finish/once/plugin/FinishOncePlugin.kt b/core/src/commonMain/kotlin/ksm/finish/once/plugin/FinishOncePlugin.kt new file mode 100644 index 0000000..e78f9ec --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/finish/once/plugin/FinishOncePlugin.kt @@ -0,0 +1,31 @@ +package ksm.finish.once.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.plugin.Plugin + +public object FinishOncePlugin : Plugin.Singleton { + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + return context + FinishOnceEntry() + } + } + + public fun checkCanCreate(context: StateContext) { + context.require(FinishOnceEntry).checkCanCreate() + } + + public fun finish(context: StateContext) { + context.require(FinishOnceEntry).finish() + } +} diff --git a/core/src/commonMain/kotlin/ksm/lifecycle/LifecycleInterceptor.kt b/core/src/commonMain/kotlin/ksm/lifecycle/LifecycleInterceptor.kt new file mode 100644 index 0000000..0258caf --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/lifecycle/LifecycleInterceptor.kt @@ -0,0 +1,10 @@ +package ksm.lifecycle + +import ksm.context.StateContext + +public interface LifecycleInterceptor { + public fun onCreate(context: StateContext) {} + public fun onResume(context: StateContext) {} + public fun onPause(context: StateContext) {} + public fun onFinish(context: StateContext) {} +} diff --git a/core/src/commonMain/kotlin/ksm/lifecycle/StateContext.kt b/core/src/commonMain/kotlin/ksm/lifecycle/StateContext.kt new file mode 100644 index 0000000..dff3a3c --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/lifecycle/StateContext.kt @@ -0,0 +1,12 @@ +package ksm.lifecycle + +import ksm.context.StateContext +import ksm.lifecycle.plugin.LifecyclePlugin +import ksm.plugin.plugin + +public fun StateContext.addLifecycleInterceptor(interceptor: LifecycleInterceptor) { + plugin(LifecyclePlugin).addLifecycleInterceptor( + context = this, + observer = interceptor + ) +} diff --git a/core/src/commonMain/kotlin/ksm/lifecycle/plugin/LifecycleEntry.kt b/core/src/commonMain/kotlin/ksm/lifecycle/plugin/LifecycleEntry.kt new file mode 100644 index 0000000..72b65c0 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/lifecycle/plugin/LifecycleEntry.kt @@ -0,0 +1,37 @@ +package ksm.lifecycle.plugin + +import ksm.context.StateContext +import ksm.lifecycle.LifecycleInterceptor + +internal class LifecycleEntry : StateContext.Element { + override val key = LifecycleEntry + + private val observers = mutableListOf() + + fun addInterceptor(observer: LifecycleInterceptor) { + observers += observer + } + + fun onCreate(context: StateContext) { + for (observer in observers) { + observer.onCreate(context) + } + } + fun onResume(context: StateContext) { + for (observer in observers) { + observer.onResume(context) + } + } + fun onPause(context: StateContext) { + for (observer in observers) { + observer.onPause(context) + } + } + fun onFinish(context: StateContext) { + for (observer in observers) { + observer.onFinish(context) + } + } + + companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/lifecycle/plugin/LifecyclePlugin.kt b/core/src/commonMain/kotlin/ksm/lifecycle/plugin/LifecyclePlugin.kt new file mode 100644 index 0000000..be88427 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/lifecycle/plugin/LifecyclePlugin.kt @@ -0,0 +1,44 @@ +package ksm.lifecycle.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.lifecycle.LifecycleInterceptor +import ksm.plugin.Plugin + +public object LifecyclePlugin : Plugin.Singleton { + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + return context + LifecycleEntry() + } + } + + public fun addLifecycleInterceptor( + context: StateContext, + observer: LifecycleInterceptor + ) { + context.require(LifecycleEntry).addInterceptor(observer) + } + + public fun onCreate(context: StateContext) { + context.require(LifecycleEntry).onCreate(context) + } + public fun onResume(context: StateContext) { + context.require(LifecycleEntry).onResume(context) + } + public fun onPause(context: StateContext) { + context.require(LifecycleEntry).onPause(context) + } + public fun onFinish(context: StateContext) { + context.require(LifecycleEntry).onFinish(context) + } +} diff --git a/core/src/commonMain/kotlin/ksm/plugin/Plugin.kt b/core/src/commonMain/kotlin/ksm/plugin/Plugin.kt new file mode 100644 index 0000000..ce6e668 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/plugin/Plugin.kt @@ -0,0 +1,15 @@ +package ksm.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext + +public interface Plugin : StateContext.Element { + override val key: StateContext.Key<*> + + @MutateContext + public fun install(context: StateContext): StateContext + + public interface Singleton> : Plugin, StateContext.Element.Singleton { + override val key: StateContext.Key get() = this + } +} diff --git a/core/src/commonMain/kotlin/ksm/plugin/StateController.kt b/core/src/commonMain/kotlin/ksm/plugin/StateController.kt new file mode 100644 index 0000000..0d4c9e4 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/plugin/StateController.kt @@ -0,0 +1,31 @@ +package ksm.plugin + +import ksm.context.StateContext + +@Deprecated( + message = "Use array access syntax instead", + replaceWith = ReplaceWith( + expression = "this[key]" + ) +) +public inline fun StateContext.pluginOrNull(key: StateContext.Key): T? { + return this[key] +} + +public inline fun StateContext.ifPlugin( + key: StateContext.Key, + block: T.() -> Unit +) { + this[key]?.run(block) +} + +public inline fun StateContext.plugin(key: StateContext.Key): T { + return this[key] ?: error("Plugin `${T::class.simpleName}` is not installed") +} + +public inline fun StateContext.withPlugin( + key: StateContext.Key, + block: T.() -> Unit +) { + plugin(key).run(block) +} diff --git a/core/src/commonMain/kotlin/ksm/serialization/BaseSerializationFormat.kt b/core/src/commonMain/kotlin/ksm/serialization/BaseSerializationFormat.kt new file mode 100644 index 0000000..d81fad5 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/serialization/BaseSerializationFormat.kt @@ -0,0 +1,9 @@ +package ksm.serialization + +import ksm.typed.TypedValue +import kotlin.reflect.KType + +public interface BaseSerializationFormat { + public fun encode(value: TypedValue<*>) + public fun decode(type: KType): Any? +} diff --git a/core/src/commonMain/kotlin/ksm/serialization/BaseSerializationStore.kt b/core/src/commonMain/kotlin/ksm/serialization/BaseSerializationStore.kt new file mode 100644 index 0000000..29a2bb2 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/serialization/BaseSerializationStore.kt @@ -0,0 +1,16 @@ +package ksm.serialization + +import ksm.context.StateContext + +public interface BaseSerializationStore { + public suspend fun await() {} + + public interface String : BaseSerializationStore, StateContext.Element { + override val key: Companion get() = String + + public fun get(): kotlin.String? + public fun apply(string: kotlin.String) + + public companion object : StateContext.Key + } +} diff --git a/core/src/commonMain/kotlin/ksm/serialization/StateContext.kt b/core/src/commonMain/kotlin/ksm/serialization/StateContext.kt new file mode 100644 index 0000000..ea4202e --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/serialization/StateContext.kt @@ -0,0 +1,9 @@ +package ksm.serialization + +import ksm.context.StateContext +import ksm.plugin.plugin +import ksm.serialization.plugin.BaseSerializationPlugin + +public fun StateContext.restore() { + plugin(BaseSerializationPlugin).restore(context = this) +} diff --git a/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationParametersInterceptor.kt b/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationParametersInterceptor.kt new file mode 100644 index 0000000..8ade552 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationParametersInterceptor.kt @@ -0,0 +1,24 @@ +package ksm.serialization.plugin + +import ksm.state.parameters.interceptor.StateParametersInterceptor +import ksm.typed.TypedValue + +internal class BaseSerializationParametersInterceptor : StateParametersInterceptor { + private val map = mutableMapOf>() + + override fun onPut( + key: String, + value: TypedValue<*> + ) { + map[key] = TypedValue.Generic.of(value) + } + + @Suppress("UNCHECKED_CAST") + override fun onReceive(key: String): TypedValue.Generic? { + return map[key] as TypedValue.Generic? + } + + fun toMap(): Map> { + return map + } +} diff --git a/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationPlugin.kt b/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationPlugin.kt new file mode 100644 index 0000000..c2a2e63 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationPlugin.kt @@ -0,0 +1,48 @@ +package ksm.serialization.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.lifecycle.LifecycleInterceptor +import ksm.lifecycle.addLifecycleInterceptor +import ksm.plugin.Plugin +import ksm.serialization.BaseSerializationFormat +import ksm.state.parameters.interceptor.addParametersInterceptor + +public class BaseSerializationPlugin(format: BaseSerializationFormat) : Plugin { + override val key: Companion = BaseSerializationPlugin + private val controller = BaseSerializationStateController(format) + + @MutateContext + override fun install( + context: StateContext + ): StateContext { + context.addConfigurationInterceptor(Configuration()) + return context + } + + private inner class Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + val parametersInterceptor = BaseSerializationParametersInterceptor() + context.addParametersInterceptor(parametersInterceptor) + context.addLifecycleInterceptor(Lifecycle(parametersInterceptor)) + return context + } + } + + private inner class Lifecycle( + val interceptor: BaseSerializationParametersInterceptor + ) : LifecycleInterceptor { + override fun onResume(context: StateContext) { + controller.commit(context, interceptor) + } + } + + public fun restore(context: StateContext) { + controller.restore(context) + } + + public companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationStateController.kt b/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationStateController.kt new file mode 100644 index 0000000..371f1ac --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/serialization/plugin/BaseSerializationStateController.kt @@ -0,0 +1,75 @@ +package ksm.serialization.plugin + +import ksm.context.StateContext +import ksm.context.createChildContext +import ksm.serialization.BaseSerializationFormat +import ksm.stack.previousContextOrNull +import ksm.state.name.setStateName +import ksm.state.name.stateName +import ksm.state.parameters.setStateParameter +import ksm.typed.TypedValue +import kotlin.reflect.typeOf + +private typealias SerializedStackType = List>>> + +internal class BaseSerializationStateController( + private val format: BaseSerializationFormat +) : StateContext.Element { + override val key = BaseSerializationStateController + + private val serializedStackType = typeOf() + + @Suppress("UNCHECKED_CAST") + fun restore(root: StateContext) { + val serializedStack = format.decode(serializedStackType) as SerializedStackType + var current = root + for (serializedEntry in serializedStack) { + current = decodeEntry(current, serializedEntry.data) + } + } + + private fun decodeEntry( + context: StateContext, + parameters: Map> + ): StateContext = context.createChildContext { child -> + val name = parameters[STATE_NAME_KEY]?.data as String? + if (name != null) { + child.setStateName(name) + } + for ((key, value) in parameters) { + if (key == STATE_NAME_KEY) continue + child.setStateParameter(key, value) + } + } + + fun commit(context: StateContext, entry: BaseSerializationParametersInterceptor) { + var current: StateContext? = context + val serializedStack = mutableListOf>() + + while (current != null) { + val encodedEntry = encodeEntry(current, entry) + serializedStack.add( + // Make sure root context is always on top + index = 0, + element = encodedEntry + ) + current = context.previousContextOrNull + } + + val value = TypedValue.of(serializedStack.toList()) + format.encode(value) + } + + private fun encodeEntry( + context: StateContext, + entry: BaseSerializationParametersInterceptor + ): TypedValue<*> { + val name = STATE_NAME_KEY to TypedValue.Generic.of(context.stateName) + val map = entry.toMap() + name + return TypedValue.of(map) + } + + companion object : StateContext.Key { + const val STATE_NAME_KEY = "ksm.serialization.plugin.STATE_NAME" + } +} diff --git a/core/src/commonMain/kotlin/ksm/stack/StateContext.kt b/core/src/commonMain/kotlin/ksm/stack/StateContext.kt new file mode 100644 index 0000000..8f70257 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/stack/StateContext.kt @@ -0,0 +1,35 @@ +package ksm.stack + +import ksm.context.StateContext +import ksm.plugin.ifPlugin +import ksm.serialization.plugin.BaseSerializationPlugin +import ksm.stack.plugin.StateStackPlugin + +public val StateContext.previousContext: StateContext + get() = previousContextOrNull ?: error("Cannot get previous context") + +public val StateContext.previousContextOrNull: StateContext? + get() = this[StateStackPlugin]?.previousContextOrNull(context = this) + +public val StateContext.nextContext: StateContext + get() = nextContextOrNull ?: error("Cannot get next context") + +public val StateContext.nextContextOrNull: StateContext? + get() = this[StateStackPlugin]?.nextContextOrNull(context = this) + +public val StateContext.hasNextContext: Boolean + get() = nextContextOrNull != null + +public val StateContext.lastContext: StateContext + get() = lastContextOrNull ?: error("There is no child context") + +public val StateContext.lastContextOrNull: StateContext? get() { + var context: StateContext? = null + while (hasNextContext) { + context = nextContext + } + return context +} + +public val StateContext.lastContextOrThis: StateContext + get() = lastContextOrNull ?: this diff --git a/core/src/commonMain/kotlin/ksm/stack/StateController.kt b/core/src/commonMain/kotlin/ksm/stack/StateController.kt new file mode 100644 index 0000000..ca95c53 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/stack/StateController.kt @@ -0,0 +1,7 @@ +package ksm.stack + +import ksm.StateController + +public val StateController.previous: StateController get() { + return StateController(context.previousContext) +} diff --git a/core/src/commonMain/kotlin/ksm/stack/plugin/StateStackEntry.kt b/core/src/commonMain/kotlin/ksm/stack/plugin/StateStackEntry.kt new file mode 100644 index 0000000..f517e52 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/stack/plugin/StateStackEntry.kt @@ -0,0 +1,30 @@ +package ksm.stack.plugin + +import ksm.context.StateContext +import ksm.context.finish +import ksm.context.get + +internal class StateStackEntry( + private val previousEntry: StateStackEntry? +) : StateContext.Element { + override val key = StateStackEntry + + val previousContext: StateContext? get() = previousEntry?.context + + private lateinit var context: StateContext + + var nextContext: StateContext? = null + private set + + fun attachContext(context: StateContext) { + this.context = context + previousEntry?.nextContext = context + } + + fun onFinish() { + previousEntry?.nextContext = null + nextContext?.finish() + } + + companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/stack/plugin/StateStackPlugin.kt b/core/src/commonMain/kotlin/ksm/stack/plugin/StateStackPlugin.kt new file mode 100644 index 0000000..5f61d76 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/stack/plugin/StateStackPlugin.kt @@ -0,0 +1,46 @@ +package ksm.stack.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.plugin.Plugin +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.lifecycle.LifecycleInterceptor +import ksm.lifecycle.addLifecycleInterceptor + +public object StateStackPlugin : Plugin.Singleton { + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + val entry = StateStackEntry(context[StateStackEntry]) + context.addLifecycleInterceptor(Lifecycle(entry)) + return context + entry + } + } + + private class Lifecycle(val entry: StateStackEntry) : LifecycleInterceptor { + override fun onCreate(context: StateContext) { + entry.attachContext(context) + } + override fun onFinish(context: StateContext) { + entry.onFinish() + } + } + + public fun previousContextOrNull(context: StateContext): StateContext? { + return context.require(StateStackEntry).previousContext + } + + public fun nextContextOrNull( + context: StateContext + ): StateContext? { + return context.require(StateStackEntry).nextContext + } +} diff --git a/core/src/commonMain/kotlin/ksm/state/LaunchState.kt b/core/src/commonMain/kotlin/ksm/state/LaunchState.kt new file mode 100644 index 0000000..0ec4560 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/LaunchState.kt @@ -0,0 +1,40 @@ +package ksm.state + +import ksm.StateController +import ksm.context.StateContext +import ksm.context.createChildContext +import ksm.state.data.setStateData +import ksm.state.name.setStateName +import ksm.typed.TypedValue + +public inline fun StateController.launch( + name: String, + data: T +) { + launch(name, TypedValue.of(data)) +} + +public fun StateController.launch( + name: String, + data: TypedValue<*> = TypedValue.of(Unit) +) { + context.launchChildContext(name, data) +} + +public inline fun StateContext.launchChildContext( + name: String, + data: T +) { + launchChildContext(name, TypedValue.of(data)) +} + +public fun StateContext.launchChildContext( + name: String, + data: TypedValue<*> = TypedValue.of(Unit) +) { + createChildContext { child -> + child.setStateName(name) + child.setStateData(data) + } +} + diff --git a/core/src/commonMain/kotlin/ksm/state/StateScope.kt b/core/src/commonMain/kotlin/ksm/state/StateScope.kt new file mode 100644 index 0000000..0425b9b --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/StateScope.kt @@ -0,0 +1,7 @@ +package ksm.state + +import ksm.StateController +import ksm.annotation.StateBuilderDSL + +@StateBuilderDSL +public class StateScope(public val controller: StateController) diff --git a/core/src/commonMain/kotlin/ksm/state/builder/StateBuilderScope.kt b/core/src/commonMain/kotlin/ksm/state/builder/StateBuilderScope.kt new file mode 100644 index 0000000..9283049 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/builder/StateBuilderScope.kt @@ -0,0 +1,7 @@ +package ksm.state.builder + +import ksm.annotation.StateBuilderDSL +import ksm.context.StateContext + +@StateBuilderDSL +public class StateBuilderScope(public val context: StateContext) diff --git a/core/src/commonMain/kotlin/ksm/state/builder/StateControllerBuilder.kt b/core/src/commonMain/kotlin/ksm/state/builder/StateControllerBuilder.kt new file mode 100644 index 0000000..ef12462 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/builder/StateControllerBuilder.kt @@ -0,0 +1,19 @@ +package ksm.state.builder + +import ksm.builder.StateControllerBuilder +import ksm.context.StateContext +import ksm.lifecycle.LifecycleInterceptor +import ksm.lifecycle.addLifecycleInterceptor + +public fun StateControllerBuilder.states(block: StateRouteScope.() -> Unit) { + val interceptor = object : LifecycleInterceptor { + override fun onCreate(context: StateContext) { + val scope = StateRouteScope(context) + block(scope) + if (!scope.intercepted) { + error("Cannot launch state because there is no handlers for this") + } + } + } + context.addLifecycleInterceptor(interceptor) +} diff --git a/core/src/commonMain/kotlin/ksm/state/builder/StateRouteScope.kt b/core/src/commonMain/kotlin/ksm/state/builder/StateRouteScope.kt new file mode 100644 index 0000000..416eeb7 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/builder/StateRouteScope.kt @@ -0,0 +1,26 @@ +package ksm.state.builder + +import ksm.annotation.StateBuilderDSL +import ksm.context.StateContext + +@StateBuilderDSL +public class StateRouteScope( + public val context: StateContext +) { + internal var intercepted = false + + public fun intercept() { + require(!intercepted) { "State was already intercepted" } + intercepted = true + } + + public inline fun intercept( + predicate: () -> Boolean, + block: StateBuilderScope.() -> Unit + ) { + if (predicate()) { + intercept() + block(StateBuilderScope(context)) + } + } +} diff --git a/core/src/commonMain/kotlin/ksm/state/data/StateContext.kt b/core/src/commonMain/kotlin/ksm/state/data/StateContext.kt new file mode 100644 index 0000000..432cbc1 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/data/StateContext.kt @@ -0,0 +1,25 @@ +package ksm.state.data + +import ksm.context.StateContext +import ksm.state.parameters.receiveStateParameter +import ksm.state.parameters.setStateParameter +import ksm.typed.TypedValue +import kotlin.reflect.KType + +public const val STATE_DATA_KEY: String = "ksm.state.data.STATE_DATA" + +public inline fun StateContext.setStateData(value: T) { + setStateData(TypedValue.of(value)) +} + +public fun StateContext.setStateData(value: TypedValue<*>) { + setStateParameter(STATE_DATA_KEY, value) +} + +public inline fun StateContext.receive(): T { + return receiveStateParameter(STATE_DATA_KEY) ?: error("Data is not provided") +} + +public fun StateContext.receive(type: KType): Any? { + return receiveStateParameter(STATE_DATA_KEY, type) +} diff --git a/core/src/commonMain/kotlin/ksm/state/data/StateController.kt b/core/src/commonMain/kotlin/ksm/state/data/StateController.kt new file mode 100644 index 0000000..f117a75 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/data/StateController.kt @@ -0,0 +1,7 @@ +package ksm.state.data + +import ksm.StateController + +public inline fun StateController.receive(): T { + return context.receive() +} diff --git a/core/src/commonMain/kotlin/ksm/state/name/StateContext.kt b/core/src/commonMain/kotlin/ksm/state/name/StateContext.kt new file mode 100644 index 0000000..91c9075 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/name/StateContext.kt @@ -0,0 +1,20 @@ +package ksm.state.name + +import ksm.context.StateContext +import ksm.plugin.plugin +import ksm.state.name.plugin.StateNamePlugin + +public fun StateContext.setStateName(name: String) { + plugin(StateNamePlugin).setName( + context = this, + name = name + ) +} + +public val StateContext.stateName: String get() { + return stateNameOrNull ?: error("Current state has no name") +} + +public val StateContext.stateNameOrNull: String? get() { + return plugin(StateNamePlugin).stateName(context = this) +} diff --git a/core/src/commonMain/kotlin/ksm/state/name/StateRouteScope.kt b/core/src/commonMain/kotlin/ksm/state/name/StateRouteScope.kt new file mode 100644 index 0000000..5c170d7 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/name/StateRouteScope.kt @@ -0,0 +1,14 @@ +package ksm.state.name + +import ksm.state.builder.StateBuilderScope +import ksm.state.builder.StateRouteScope + +public inline fun StateRouteScope.named( + string: String, + block: StateBuilderScope.() -> Unit +) { + intercept( + predicate = { context.stateNameOrNull == string }, + block = block + ) +} diff --git a/core/src/commonMain/kotlin/ksm/state/name/plugin/StateNameEntry.kt b/core/src/commonMain/kotlin/ksm/state/name/plugin/StateNameEntry.kt new file mode 100644 index 0000000..8c389cc --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/name/plugin/StateNameEntry.kt @@ -0,0 +1,11 @@ +package ksm.state.name.plugin + +import ksm.context.StateContext + +internal class StateNameEntry : StateContext.Element { + override val key = StateNameEntry + + var name: String? = null + + companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/state/name/plugin/StateNamePlugin.kt b/core/src/commonMain/kotlin/ksm/state/name/plugin/StateNamePlugin.kt new file mode 100644 index 0000000..728413f --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/name/plugin/StateNamePlugin.kt @@ -0,0 +1,34 @@ +package ksm.state.name.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.plugin.Plugin + +public object StateNamePlugin : Plugin.Singleton { + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + return context + StateNameEntry() + } + } + + public fun setName( + context: StateContext, + name: String + ) { + context.require(StateNameEntry).name = name + } + + public fun stateName(context: StateContext): String? { + return context.require(StateNameEntry).name + } +} diff --git a/core/src/commonMain/kotlin/ksm/state/parameters/StateContext.kt b/core/src/commonMain/kotlin/ksm/state/parameters/StateContext.kt new file mode 100644 index 0000000..60dcb7e --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/parameters/StateContext.kt @@ -0,0 +1,33 @@ +package ksm.state.parameters + +import ksm.context.StateContext +import ksm.plugin.plugin +import ksm.state.parameters.plugin.StateParametersPlugin +import ksm.typed.TypedValue +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +public fun StateContext.setStateParameter( + key: String, + value: TypedValue +) { + plugin(StateParametersPlugin).put( + context = this, + key = key, + value = value + ) +} + +public inline fun StateContext.receiveStateParameter(key: String): T? { + return receiveStateParameter(key, typeOf()) +} + +public fun StateContext.receiveStateParameter( + key: String, + type: KType +): T? { + return plugin(StateParametersPlugin).receive( + context = this, + key = key + )?.get(type) +} diff --git a/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/StateContext.kt b/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/StateContext.kt new file mode 100644 index 0000000..9e30c22 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/StateContext.kt @@ -0,0 +1,34 @@ +package ksm.state.parameters.interceptor + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.plugin.plugin +import ksm.state.parameters.plugin.StateParametersPlugin +import ksm.typed.TypedValue + +@MutateContext +public fun StateContext.addParametersInterceptor( + onPut: (key: String, value: TypedValue<*>) -> Unit = { _, _ -> }, + onReceive: (String) -> TypedValue.Generic<*>? = { null } +) { + val interceptor = object : StateParametersInterceptor { + override fun onPut(key: String, value: TypedValue<*>) { + onPut(key, value) + } + override fun onReceive(key: String): TypedValue.Generic? { + @Suppress("UNCHECKED_CAST") + return onReceive(key) as TypedValue.Generic? + } + } + addParametersInterceptor(interceptor) +} + +@MutateContext +public fun StateContext.addParametersInterceptor( + interceptor: StateParametersInterceptor +) { + plugin(StateParametersPlugin).addParametersInterceptor( + context = this, + interceptor = interceptor + ) +} diff --git a/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/StateParametersInterceptor.kt b/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/StateParametersInterceptor.kt new file mode 100644 index 0000000..d5df1e9 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/StateParametersInterceptor.kt @@ -0,0 +1,8 @@ +package ksm.state.parameters.interceptor + +import ksm.typed.TypedValue + +public interface StateParametersInterceptor { + public fun onPut(key: String, value: TypedValue<*>) {} + public fun onReceive(key: String): TypedValue.Generic? = null +} diff --git a/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/memory/MemoryStateParametersInterceptor.kt b/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/memory/MemoryStateParametersInterceptor.kt new file mode 100644 index 0000000..0936ec5 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/parameters/interceptor/memory/MemoryStateParametersInterceptor.kt @@ -0,0 +1,17 @@ +package ksm.state.parameters.interceptor.memory + +import ksm.state.parameters.interceptor.StateParametersInterceptor +import ksm.typed.TypedValue + +internal class MemoryStateParametersInterceptor : StateParametersInterceptor { + private val map = mutableMapOf>() + + override fun onPut(key: String, value: TypedValue<*>) { + map[key] = TypedValue.Generic.of(value) + } + + @Suppress("UNCHECKED_CAST") + override fun onReceive(key: String): TypedValue.Generic? { + return map[key] as TypedValue.Generic? + } +} diff --git a/core/src/commonMain/kotlin/ksm/state/parameters/plugin/StateParametersEntry.kt b/core/src/commonMain/kotlin/ksm/state/parameters/plugin/StateParametersEntry.kt new file mode 100644 index 0000000..17be924 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/parameters/plugin/StateParametersEntry.kt @@ -0,0 +1,34 @@ +package ksm.state.parameters.plugin + +import ksm.context.StateContext +import ksm.state.parameters.interceptor.StateParametersInterceptor +import ksm.typed.TypedValue + +internal class StateParametersEntry : StateContext.Element { + override val key = StateParametersEntry + + private val interceptors = mutableListOf() + + fun addInterceptor(interceptor: StateParametersInterceptor) { + interceptors += interceptor + } + + fun onPut( + key: String, + value: TypedValue<*> + ) { + for (interceptor in interceptors) { + interceptor.onPut(key, value) + } + } + + fun onReceive(key: String): TypedValue.Generic? { + for (interceptor in interceptors.asReversed()) { + val value = interceptor.onReceive(key) + if (value != null) return value + } + return null + } + + companion object : StateContext.Key +} diff --git a/core/src/commonMain/kotlin/ksm/state/parameters/plugin/StateParametersPlugin.kt b/core/src/commonMain/kotlin/ksm/state/parameters/plugin/StateParametersPlugin.kt new file mode 100644 index 0000000..d62d139 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/state/parameters/plugin/StateParametersPlugin.kt @@ -0,0 +1,52 @@ +package ksm.state.parameters.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.plugin.Plugin +import ksm.state.parameters.interceptor.StateParametersInterceptor +import ksm.state.parameters.interceptor.memory.MemoryStateParametersInterceptor +import ksm.typed.TypedValue + +public object StateParametersPlugin : Plugin.Singleton { + + @MutateContext + override fun install( + context: StateContext + ): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + val entry = StateParametersEntry() + entry.addInterceptor(MemoryStateParametersInterceptor()) + return context + entry + } + } + + public fun put( + context: StateContext, + key: String, + value: TypedValue + ) { + context.require(StateParametersEntry).onPut(key, value) + } + + public fun receive( + context: StateContext, + key: String + ): TypedValue.Generic? { + return context.require(StateParametersEntry).onReceive(key) + } + + public fun addParametersInterceptor( + context: StateContext, + interceptor: StateParametersInterceptor + ) { + context.require(StateParametersEntry).addInterceptor(interceptor) + } +} diff --git a/core/src/commonMain/kotlin/ksm/typed/TypedValue.kt b/core/src/commonMain/kotlin/ksm/typed/TypedValue.kt new file mode 100644 index 0000000..151b7b2 --- /dev/null +++ b/core/src/commonMain/kotlin/ksm/typed/TypedValue.kt @@ -0,0 +1,32 @@ +package ksm.typed + +import kotlin.reflect.KType +import kotlin.reflect.typeOf + +public class TypedValue( + public val data: T, + public val type: KType +) { + public fun interface Generic { + public fun get(type: KType): T + + public companion object { + public inline fun of(value: T): Generic { + return of(TypedValue.of(value)) + } + + public fun of(value: TypedValue): Generic { + return Generic { type -> + require(type == value.type) + value.data + } + } + } + } + + public companion object { + public inline fun of(value: T): TypedValue { + return TypedValue(value, typeOf()) + } + } +} diff --git a/docs/EXPOSING_API.md b/docs/EXPOSING_API.md new file mode 100644 index 0000000..5a47e87 --- /dev/null +++ b/docs/EXPOSING_API.md @@ -0,0 +1,42 @@ +# Exposing Plugin API + +Class or object implementing `Plugin` interface must have all public methods +required to interact with the feature. A good example of such plugin is +`CoroutinesPlugin`: + +```kotlin +object CoroutinesPlugin : Plugin { + override fun install(context: StateContext): StateContext { ... } + + fun coroutineContext(context: StateContext): CoroutineContext { + return context.require(CoroutineContextKey).coroutineContext + } +} +``` + +All the keys of the plugin should be internal or even private, you should not share +keys directly but rather provide convenient extensions on top of StateContext. + +Continuing with `CoroutinesPlugin`, the extension to get coroutineContext looks like +this: + +```kotlin +val StateContext.coroutineContext: CoroutineContext get() { + return plugin(CoroutinesPlugin).coroutineContext(context = this) +} +``` + +Here you can see why you should not share Key: + +If there is no plugin +installed the Key will be absent and any operation will fail saying +'Cannot find element with key $key'. And the name of the key can +be literally anything and user of the plugin probably knows +no more than the plugin name. + +When you get plugin using `plugin` function, it first checks if the +plugin itself is installed. And if it isn't, it throws an exception +saying 'Plugin $name is not installed' which is ultimately more clear. + +Additionally, that way we know that when 'Cannot find element with key $key' +exception in thrown, it's due to the bug in the plugin and not user's fault. diff --git a/docs/PHASES.md b/docs/PHASES.md new file mode 100644 index 0000000..30ba752 --- /dev/null +++ b/docs/PHASES.md @@ -0,0 +1,198 @@ +# Phases + +KSM is based on different callbacks and interceptors and therefore, +there are some phases you should be aware about. + +Note: There is no `Phase` in the library as an interface or something +similar, it's just a good mental model. + +## Visual Scheme + +```mermaid +stateDiagram-v2 + [*] --> StateController + StateController --> Plugins + Plugins: Apply Plugins + state Plugins { + [*] --> install + install: // Installing interceptors
Plugin.install(context#58; StateContext)#58; StateContext + install --> [*] + } + Plugins --> RootStateController + RootStateController: Root StateController is configured + RootStateController --> ChildStateContext + ChildStateContext: Create Child State + state ChildStateContext { + [*] --> onConfigure + onConfigure: onConfigure + onConfigure --> onConfigureDescription + onConfigureDescription: // Registering entries
StateContextInterceptor.onConfigure(context)#58; Context + onConfigureDescription --> fillEntries + fillEntries: Entries set up#59; usually done by extension functions + fillEntries --> onCreate + onCreate --> onCreateDescription + onCreateDescription: // Plugins can handle filled data
StateContextInterceptor.onCreate(childContext) + onCreateDescription --> onResume + onResume --> onResumeDescription + onResumeDescription: // Invoked right after onCreate or after child finish
StateContextInterceptor.onResume(childContext) + onResumeDescription --> stateLives + stateLives: -- Child state lives -- + stateLives --> onFinish + onFinish --> onFinishDescription + onFinish --> onResume + onFinishDescription: // Invoked after StateContext.finish() invoked
StateContextInterceptor.onFinish(childContext) + onFinishDescription --> [*] + } + ChildStateContext --> [*] +``` + +## Plugin Installation + +First phase is a plugin installation. This is where a life of the +root `StateController` begins. This is where all plugins install +their interceptors and store configuration: + +```kotlin +class CoroutinesPlugin( + // Configuration is just plugin properties + val context: CoroutineContext +) : Plugin { + + override fun install(context: StateContext) { + return context + StateContextInterceptor(context) + } +} +``` + +Plugins can be installed using `createRawStateController` function. +It is called 'Raw' because there is no plugins except builtins and +the end user probably should not use this function. The function +is used by creators of libraries that integrates ksm into some ecosystems. +For example: + +```kotlin +fun createTelegramKSM(): StateController { + return createRawStateController { + install(TelegramKSM) + } +} +``` + +### Restrictions + +- **Do not read properties from `context`, only write them** + + Current context is incomplete and will be modified at + later point of time by another plugins, so you should + not save it or read any properties from it + +## State Phases + +The following phases relate to creation of child states, and they +are being invoked at `StateContext.createChildContext`. And +all the following methods are methods of `StateContextInterceptor` + +### onConfigure + +When creating a new `StateContext`, the first method that is being +invoked is `onConfigure`. Let's look at the signature: + +```kotlin +fun onConfigure(context: StateContext): StateContext +``` + +It takes context and returns context and this is the place where +you can register entries scoped to child context. Continuing with +the example with coroutines: + +```kotlin +class CoroutinesContextInterceptor( + val initialContext: CoroutineContext +) : StateContextInterceptor { + override fun onConfigure(context: StateContext): StateContext { + val previousEntry = context[CoroutinesEntry] + // Support for structured concurrency + return context + CoroutinesEntry(previousEntry) + } +} +``` + +#### Restrictions + +- **Do not set any interceptors** + + There is a chance that interceptor that you try to set was already + invoked earlier by another plugin and if you set interceptor at that phase, + it will lead to inconsistent behaviour in different cases + + +- **Do not read from context except for entries under plugin control** + + Even though it is guaranteed that interceptors of plugins are called in + the order that plugins were registered, it's you should not access other + plugins in `onConfigure` method. _This is to be discussed, but there is + no seemed reason for this to be allowed._ + +### onCreate + +Continuing with `StateContext.createChildContext`: + +```kotlin +fun StateContext.createChildContext(apply: (StateContext) -> Unit) { + // All plugins register their entries + val childContext = this.onConfigure(applied) + // This is where all the setup of entries happens + apply(childContext) + childContext.onCreate() +} +``` + +As we can clearly see from the current code snippet, onCreate is called +when all context entries were set up. What does set up mean? Let's consider +the following simplified example: + +We have `NavigationPlugin`, which registers `NavigationEntry` in +`NavigationInterceptor` in `onConfigure` method, similarly to the +previous example: + +```kotlin +class NavigationInterceptor { + override fun onConfigure(context: StateContext): StateContext { + return context + NavigationEntry() + } +} +``` + +And the `NavigationEntry` itself looks like this: + +```kotlin +class NavigationEntry { + var route: String? = null +} +``` + +It is expected that `route` will be set in `apply` lambda of +`createChildContext` function. And `onCreate` is a perfect place to receive it: + +```kotlin +class NavigationInterceptor( + private val controller: NavController +) { + override fun onConfigure(context: StateContext): StateContext { + return context + NavigationEntry() + } + + override fun onCreate(context: StateContext) { + val entry = context.require(NavigationEntry) + val route = entry.route ?: return + controller.navigate(route) + } +} +``` + +### Other Lifecycle Events + +Also, there are other lifecycle events: + +- `StateContextInterceptor.onResume` – when context after this was finished +- `StateContextInterceptor.onFinish` – when context was finished diff --git a/docs/STRUCTURED_NAVIGATION.md b/docs/STRUCTURED_NAVIGATION.md new file mode 100644 index 0000000..ef02718 --- /dev/null +++ b/docs/STRUCTURED_NAVIGATION.md @@ -0,0 +1,8 @@ +# Structured Navigation + +Structured navigation is like a structured concurrency when +entries can have children and their children can have children. +Finishing one entry will cause its child to finish. And +starting new child from entry also finishes its previous child. + + diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2d8d1e4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..f13a039 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,35 @@ +[versions] + +kotlin = "1.9.23" +kotlinx-coroutines = "1.8.0" +kotlinx-serialization = "1.6.3" +android-gradle = "7.3.1" +compose-runtime = "1.6.5" +compose-compiler = "1.5.11" +androidx-navigation = "2.7.7" +androidx-lifecycle = "2.7.0" +ktgbotapi = "11.0.0" +mdi = "0.0.37" + +ksm = "0.0.1" + +[libraries] + +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } + +compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose-runtime" } +compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "compose-runtime" } +compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable", version.ref = "compose-runtime" } +androidx-navigation-ui = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } +androidx-lifecycle = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } + +ktgbotapi = { module = "dev.inmo:tgbotapi", version.ref = "ktgbotapi" } + +mdi = { module = "app.meetacy.di:core", version.ref = "mdi" } + +# gradle plugins +kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-serialization-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } +android-gradle-plugin = { module = "com.android.tools.build:gradle", version.ref = "android-gradle" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37aef8d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..aeb74cb --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kotlinx-coroutines/build.gradle.kts b/kotlinx-coroutines/build.gradle.kts new file mode 100644 index 0000000..dc0fcb7 --- /dev/null +++ b/kotlinx-coroutines/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("kmp-library-convention") +} + +version = libs.versions.ksm.get() + +dependencies { + commonMainApi(projects.core) + commonMainImplementation(libs.kotlinx.coroutines.core) +} diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/StateContext.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/StateContext.kt new file mode 100644 index 0000000..1c98f98 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/StateContext.kt @@ -0,0 +1,29 @@ +package ksm.coroutines + +import kotlinx.coroutines.CoroutineScope +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.coroutines.interceptor.AwaitInterceptor +import ksm.coroutines.plugin.AwaitPlugin +import ksm.coroutines.plugin.CoroutinesPlugin +import ksm.plugin.plugin +import kotlin.coroutines.CoroutineContext + +public val StateContext.coroutineScope: CoroutineScope get() { + return CoroutineScope(coroutineContext) +} + +public val StateContext.coroutineContext: CoroutineContext get() { + return plugin(CoroutinesPlugin).coroutineContext(context = this) +} + +public fun StateContext.addAwaitInterceptor(interceptor: AwaitInterceptor) { + plugin(AwaitPlugin).addAwaitInterceptor( + context = this, + interceptor = interceptor + ) +} + +public suspend fun StateContext.await() { + plugin(AwaitPlugin).await(context = this) +} diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/StateController.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/StateController.kt new file mode 100644 index 0000000..37718f5 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/StateController.kt @@ -0,0 +1,7 @@ +package ksm.coroutines + +import kotlinx.coroutines.CoroutineScope +import ksm.StateController + +public val StateController.coroutineScope: CoroutineScope + get() = context.coroutineScope diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/interceptor/AwaitInterceptor.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/interceptor/AwaitInterceptor.kt new file mode 100644 index 0000000..0dccd69 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/interceptor/AwaitInterceptor.kt @@ -0,0 +1,5 @@ +package ksm.coroutines.interceptor + +public fun interface AwaitInterceptor { + public suspend fun await() +} diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/AwaitEntry.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/AwaitEntry.kt new file mode 100644 index 0000000..51dcc03 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/AwaitEntry.kt @@ -0,0 +1,22 @@ +package ksm.coroutines.plugin + +import ksm.context.StateContext +import ksm.coroutines.interceptor.AwaitInterceptor + +internal class AwaitEntry : StateContext.Element { + override val key = AwaitEntry + + private val awaitInterceptors = mutableListOf() + + suspend fun await() { + for (interceptor in awaitInterceptors) { + interceptor.await() + } + } + + fun addAwaitInterceptor(interceptor: AwaitInterceptor) { + awaitInterceptors += interceptor + } + + companion object : StateContext.Key +} diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/AwaitPlugin.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/AwaitPlugin.kt new file mode 100644 index 0000000..ebf7781 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/AwaitPlugin.kt @@ -0,0 +1,35 @@ +package ksm.coroutines.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.coroutines.interceptor.AwaitInterceptor +import ksm.plugin.Plugin + +public object AwaitPlugin : Plugin.Singleton { + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + return context + AwaitEntry() + } + } + + public suspend fun await(context: StateContext) { + context.require(AwaitEntry).await() + } + + public fun addAwaitInterceptor( + context: StateContext, + interceptor: AwaitInterceptor + ) { + context.require(AwaitEntry).addAwaitInterceptor(interceptor) + } +} diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/CoroutinesEntry.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/CoroutinesEntry.kt new file mode 100644 index 0000000..a1927d3 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/CoroutinesEntry.kt @@ -0,0 +1,33 @@ +package ksm.coroutines.plugin + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.newCoroutineContext +import ksm.context.StateContext +import ksm.coroutines.interceptor.AwaitInterceptor +import kotlin.coroutines.CoroutineContext + +internal class CoroutinesEntry( + context: StateContext, + initialCoroutineContext: CoroutineContext +) : StateContext.Element { + override val key = CoroutinesEntry + + val coroutineContext: CoroutineContext + + init { + val parentCoroutineContext = context[CoroutinesEntry] + ?.coroutineContext + ?: initialCoroutineContext + + val parentJob = parentCoroutineContext[Job] + val job = Job(parentJob) + + coroutineContext = CoroutineScope(parentCoroutineContext).newCoroutineContext(job) + } + + fun cancel() = coroutineContext.cancel() + + companion object : StateContext.Key +} diff --git a/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/CoroutinesPlugin.kt b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/CoroutinesPlugin.kt new file mode 100644 index 0000000..961b4d6 --- /dev/null +++ b/kotlinx-coroutines/src/commonMain/kotlin/ksm/coroutines/plugin/CoroutinesPlugin.kt @@ -0,0 +1,43 @@ +package ksm.coroutines.plugin + +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.plugin.Plugin +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.coroutines.interceptor.AwaitInterceptor +import ksm.lifecycle.LifecycleInterceptor +import ksm.lifecycle.addLifecycleInterceptor +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +public class CoroutinesPlugin( + private val coroutineContext: CoroutineContext +) : Plugin { + override val key: Companion = CoroutinesPlugin + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration()) + return context + } + + private inner class Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + val entry = CoroutinesEntry(context, coroutineContext) + context.addLifecycleInterceptor(Lifecycle(entry)) + return context + entry + } + } + + private inner class Lifecycle(val entry: CoroutinesEntry) : LifecycleInterceptor { + override fun onFinish(context: StateContext) { entry.cancel() } + } + + public fun coroutineContext(context: StateContext): CoroutineContext { + return context.require(CoroutinesEntry).coroutineContext + } + + public companion object : StateContext.Key, Plugin by CoroutinesPlugin(EmptyCoroutineContext) +} diff --git a/kotlinx-serialization-json/build.gradle.kts b/kotlinx-serialization-json/build.gradle.kts new file mode 100644 index 0000000..98b6457 --- /dev/null +++ b/kotlinx-serialization-json/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("kmp-library-convention") + kotlin("plugin.serialization") +} + +version = libs.versions.ksm.get() + +dependencies { + commonMainApi(projects.core) + commonMainImplementation(libs.kotlinx.serialization) +} diff --git a/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/KotlinxSerializationFormat.kt b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/KotlinxSerializationFormat.kt new file mode 100644 index 0000000..8c2a43a --- /dev/null +++ b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/KotlinxSerializationFormat.kt @@ -0,0 +1,35 @@ +package ksm.kotlinx.serialization.plugin + +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import ksm.kotlinx.serialization.plugin.serializer.GenericValueDeserializer +import ksm.kotlinx.serialization.plugin.serializer.TypedValueSerializer +import ksm.serialization.BaseSerializationFormat +import ksm.serialization.BaseSerializationStore +import ksm.typed.TypedValue +import kotlin.reflect.KType + +internal class KotlinxSerializationFormat( + private val store: BaseSerializationStore.String, + json: Json +) : BaseSerializationFormat { + private val json = Json(json) { + serializersModule = SerializersModule { + include(serializersModule) + contextual(TypedValue::class, TypedValueSerializer) + contextual(TypedValue.Generic::class, GenericValueDeserializer) + } + } + + override fun encode(value: TypedValue<*>) { + val serializer = json.serializersModule.serializer(value.type) + store.apply(json.encodeToString(serializer, value)) + } + + override fun decode(type: KType): Any? { + val string = store.get() ?: return null + val deserializer = json.serializersModule.serializer(type) + return json.decodeFromString(deserializer, string) + } +} diff --git a/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/KotlinxSerializationPlugin.kt b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/KotlinxSerializationPlugin.kt new file mode 100644 index 0000000..a4eaf61 --- /dev/null +++ b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/KotlinxSerializationPlugin.kt @@ -0,0 +1,28 @@ +package ksm.kotlinx.serialization.plugin + +import kotlinx.serialization.json.Json +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.install +import ksm.plugin.Plugin +import ksm.serialization.BaseSerializationStore +import ksm.serialization.plugin.BaseSerializationPlugin + +public class KotlinxSerializationPlugin( + private val json: Json = Json, + private val store: BaseSerializationStore.String? = null +) : Plugin { + override val key: Companion = KotlinxSerializationPlugin + + @MutateContext + override fun install(context: StateContext): StateContext { + val store = this.store + ?: context[BaseSerializationStore.String] + ?: error("Please either provide BaseSerializationStore using constructor or install the plugin that will provide it") + val format = KotlinxSerializationFormat(store, json) + val basePlugin = BaseSerializationPlugin(format) + return context.install(basePlugin) + } + + public companion object : StateContext.Key +} diff --git a/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/serializer/GenericValueDeserializer.kt b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/serializer/GenericValueDeserializer.kt new file mode 100644 index 0000000..fdf664d --- /dev/null +++ b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/serializer/GenericValueDeserializer.kt @@ -0,0 +1,32 @@ +package ksm.kotlinx.serialization.plugin.serializer + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.serializer +import ksm.typed.TypedValue + +public object GenericValueDeserializer : KSerializer> { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = SerialDescriptor( + serialName = "TypedValueSerializer", + original = JsonElement.serializer().descriptor + ) + + override fun serialize( + encoder: Encoder, + value: TypedValue.Generic<*> + ) { + error("TypedValue.Generic cannot be serialized, only deserialized") + } + + override fun deserialize(decoder: Decoder): TypedValue.Generic<*> { + return TypedValue.Generic { type -> + val deserializer = decoder.serializersModule.serializer(type) + deserializer.deserialize(decoder) + } + } +} diff --git a/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/serializer/TypedValueSerializer.kt b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/serializer/TypedValueSerializer.kt new file mode 100644 index 0000000..42f3891 --- /dev/null +++ b/kotlinx-serialization-json/src/commonMain/kotlin/ksm/kotlinx/serialization/plugin/serializer/TypedValueSerializer.kt @@ -0,0 +1,31 @@ +package ksm.kotlinx.serialization.plugin.serializer + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationStrategy +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.serializer +import ksm.typed.TypedValue + +public object TypedValueSerializer : KSerializer> { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor: SerialDescriptor = SerialDescriptor( + serialName = "TypedValueSerializer", + original = JsonElement.serializer().descriptor + ) + + override fun serialize( + encoder: Encoder, + value: TypedValue<*> + ) { + val baseSerializer = encoder.serializersModule.serializer(value.type) + baseSerializer.serialize(encoder, value.data) + } + + override fun deserialize(decoder: Decoder): TypedValue<*> { + error("This type cannot be deserialized, use TypedValue.Generic instead") + } +} diff --git a/ktgbotapi/build.gradle.kts b/ktgbotapi/build.gradle.kts new file mode 100644 index 0000000..81ff887 --- /dev/null +++ b/ktgbotapi/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("multiplatform") + id("publication-convention") +} + +kotlin { + explicitApi() + + jvmToolchain(17) + + jvm() + + js(IR) { + browser() + nodejs() + } +} + +version = libs.versions.ksm.get() + +dependencies { + commonMainApi(projects.core) + commonMainApi(projects.kotlinxCoroutines) + commonMainApi(projects.kotlinxSerializationJson) + commonMainImplementation(libs.kotlinx.coroutines.core) + commonMainImplementation(libs.ktgbotapi) +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateBuilderScope.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateBuilderScope.kt new file mode 100644 index 0000000..565ea6f --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateBuilderScope.kt @@ -0,0 +1,8 @@ +package ksm.ktgbotapi + +import ksm.state.StateScope +import ksm.state.builder.StateBuilderScope + +public fun StateBuilderScope.execute(block: suspend StateScope.() -> Unit) { + context.setExecuteBlock(block) +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateContext.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateContext.kt new file mode 100644 index 0000000..7c2367f --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateContext.kt @@ -0,0 +1,38 @@ +package ksm.ktgbotapi + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.types.message.abstracts.Message +import dev.inmo.tgbotapi.types.update.MessageUpdate +import dev.inmo.tgbotapi.types.update.abstracts.Update +import ksm.StateController +import ksm.context.StateContext +import ksm.ktgbotapi.plugin.TelegramBotApiPlugin +import ksm.plugin.plugin +import ksm.state.StateScope + +public val StateContext.update: Update get() { + return plugin(TelegramBotApiPlugin).update +} + +public val StateContext.messageUpdate: MessageUpdate get() { + return update as? MessageUpdate ?: error("Received update is not MessageUpdate") +} + +public val StateContext.message: Message get() { + return messageUpdate.data +} + +public val StateContext.telegramBot: TelegramBot get() { + return plugin(TelegramBotApiPlugin).telegramBot +} + +internal val StateContext.executeBlock: suspend StateScope.() -> Unit get() { + return plugin(TelegramBotApiPlugin).executeBlock(context = this) +} + +public fun StateContext.setExecuteBlock(block: suspend StateScope.() -> Unit) { + plugin(TelegramBotApiPlugin).setExecuteBlock( + context = this, + block = block + ) +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateScope.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateScope.kt new file mode 100644 index 0000000..0822519 --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/StateScope.kt @@ -0,0 +1,42 @@ +package ksm.ktgbotapi + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.extensions.api.send.sendMessage +import dev.inmo.tgbotapi.extensions.utils.extensions.sourceChat +import dev.inmo.tgbotapi.extensions.utils.extensions.sourceUser +import dev.inmo.tgbotapi.types.IdChatIdentifier +import dev.inmo.tgbotapi.types.buttons.KeyboardMarkup +import dev.inmo.tgbotapi.types.chat.User +import dev.inmo.tgbotapi.types.update.abstracts.Update +import dev.inmo.tgbotapi.utils.PreviewFeature +import ksm.ktgbotapi.match.MatchScope +import ksm.state.StateScope + +public val StateScope.update: Update get() { + return controller.context.update +} + +@OptIn(PreviewFeature::class) +public val StateScope.user: User get() { + return update.sourceUser() ?: error("Cannot get user from $update") +} + +@OptIn(PreviewFeature::class) +public val StateScope.chatId: IdChatIdentifier get() { + return update.sourceChat()?.id ?: error("Cannot get chatId from $update") +} + +public val StateScope.telegramBot: TelegramBot get() { + return controller.context.telegramBot +} + +public suspend fun StateScope.sendMessage( + text: String, + replyMarkup: KeyboardMarkup? = null +) { + telegramBot.sendMessage(chatId, text, replyMarkup = replyMarkup) +} + +public inline fun StateScope.matchMessage(block: MatchScope.() -> Unit) { + MatchScope(controller.context).apply(block) +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/TelegramBotKSM.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/TelegramBotKSM.kt new file mode 100644 index 0000000..cc56382 --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/TelegramBotKSM.kt @@ -0,0 +1,56 @@ +package ksm.ktgbotapi + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.types.update.abstracts.Update +import kotlinx.coroutines.flow.Flow +import ksm.StateController +import ksm.builder.StateControllerBuilder +import ksm.createRawStateController +import ksm.ktgbotapi.plugin.TelegramBotApiPlugin +import ksm.serialization.restore +import ksm.stack.lastContext +import ksm.stack.lastContextOrNull +import ksm.stack.nextContext +import ksm.stack.nextContextOrNull +import ksm.state.StateScope +import ksm.state.launch + +public class TelegramBotStateMachine( + private val builder: StateControllerBuilder.() -> Unit +) { + public suspend fun start( + startStateName: String, + telegramBot: TelegramBot, + updates: Flow, + key: (T) -> TelegramPeerKey + ) { + updates.collect { update -> + val controller = createStateController(key(update), update, telegramBot) + if (controller.context.lastContextOrNull == null) { + controller.launch(startStateName) + } + // todo: это дефолтный интерцептор, надо дать возможность + // добавлять свои интерцепторы на апдейты после восстановления состояния + // - + // Новых абстракций можно не придумывать, а в случае, когда необходимо блокировать + // выполнение цепочки, можно сделать декоратор + val lastContext = controller.context.lastContext + val lastController = StateController(lastContext) + val stateScope = StateScope(lastController) + lastContext.executeBlock.invoke(stateScope) + } + } + + private fun createStateController( + key: TelegramPeerKey, + update: Update, + telegramBot: TelegramBot + ): StateController { + return createRawStateController { + install(TelegramBotApiPlugin(key, update, telegramBot)) + builder() + }.apply { + context.restore() + } + } +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/TelegramPeerKey.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/TelegramPeerKey.kt new file mode 100644 index 0000000..21096db --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/TelegramPeerKey.kt @@ -0,0 +1,6 @@ +package ksm.ktgbotapi + +import kotlin.jvm.JvmInline + +@JvmInline +public value class TelegramPeerKey(public val string: String) diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/Command.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/Command.kt new file mode 100644 index 0000000..58baa07 --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/Command.kt @@ -0,0 +1,12 @@ +package ksm.ktgbotapi.match + +public fun MatchScope.command( + text: String, + args: Int? = null, + prefix: String = "/", + block: (List) -> Unit +) { + // todo: сделать реализацию, которая будет учитывать количество аргументов и т.п. + // возможно стоит тут задействовать интерцепторы и через них прокидывать дефолтное сообщение, + // когда команда неизвестна +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/Exact.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/Exact.kt new file mode 100644 index 0000000..9c81eb5 --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/Exact.kt @@ -0,0 +1,13 @@ +package ksm.ktgbotapi.match + +import dev.inmo.tgbotapi.extensions.utils.extensions.raw.text +import dev.inmo.tgbotapi.utils.RiskFeature +import ksm.ktgbotapi.message + +@OptIn(RiskFeature::class) +public inline fun MatchScope.exact( + text: String, + block: () -> Unit +) { + if (context.message.text == text) block() +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/MatchScope.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/MatchScope.kt new file mode 100644 index 0000000..b5fa8fa --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/match/MatchScope.kt @@ -0,0 +1,24 @@ +package ksm.ktgbotapi.match + +import ksm.context.StateContext + +public class MatchScope( + public val context: StateContext +) { + private var default: () -> Unit = { } + + public fun default(block: () -> Unit) { + default = block + } + + private var intercepted: Boolean = false + + public fun intercept() { + require(!intercepted) + intercepted = true + } + + public fun runDefault() { + if (!intercepted) default() + } +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/plugin/TelegramBotApiPlugin.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/plugin/TelegramBotApiPlugin.kt new file mode 100644 index 0000000..cef2631 --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/plugin/TelegramBotApiPlugin.kt @@ -0,0 +1,46 @@ +package ksm.ktgbotapi.plugin + +import dev.inmo.tgbotapi.bot.TelegramBot +import dev.inmo.tgbotapi.types.update.abstracts.Update +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.ktgbotapi.TelegramPeerKey +import ksm.plugin.Plugin +import ksm.state.StateScope + +public class TelegramBotApiPlugin( + public val peerKey: TelegramPeerKey, + public val update: Update, + public val telegramBot: TelegramBot +) : Plugin { + override val key: Companion = TelegramBotApiPlugin + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration) + return context + } + + private object Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + return context + TelegramBotEntry() + } + } + + public fun setExecuteBlock( + context: StateContext, + block: suspend StateScope.() -> Unit + ) { + context.require(TelegramBotEntry).execute = block + } + + public fun executeBlock(context: StateContext): suspend StateScope.() -> Unit { + return context.require(TelegramBotEntry).execute + ?: error("Please invoke `execute` in StateBuilder") + } + + public companion object : StateContext.Key +} diff --git a/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/plugin/TelegramBotEntry.kt b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/plugin/TelegramBotEntry.kt new file mode 100644 index 0000000..97032a7 --- /dev/null +++ b/ktgbotapi/src/commonMain/kotlin/ksm/ktgbotapi/plugin/TelegramBotEntry.kt @@ -0,0 +1,12 @@ +package ksm.ktgbotapi.plugin + +import ksm.context.StateContext +import ksm.state.StateScope + +internal class TelegramBotEntry : StateContext.Element { + override val key = TelegramBotEntry + + var execute: (suspend StateScope.() -> Unit)? = null + + companion object : StateContext.Key +} diff --git a/mdi/build.gradle.kts b/mdi/build.gradle.kts new file mode 100644 index 0000000..a712582 --- /dev/null +++ b/mdi/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("kmp-library-convention") +} + +version = libs.versions.ksm.get() + +dependencies { + commonMainApi(projects.core) + commonMainImplementation(libs.mdi) +} diff --git a/mdi/src/commonMain/kotlin/ksm/mdi/StateBuilderScope.kt b/mdi/src/commonMain/kotlin/ksm/mdi/StateBuilderScope.kt new file mode 100644 index 0000000..f4ae7f0 --- /dev/null +++ b/mdi/src/commonMain/kotlin/ksm/mdi/StateBuilderScope.kt @@ -0,0 +1,8 @@ +package ksm.mdi + +import app.meetacy.di.DI +import ksm.state.builder.StateBuilderScope + +public var StateBuilderScope.di: DI + get() = context.di + set(value) { context.di = value } diff --git a/mdi/src/commonMain/kotlin/ksm/mdi/StateContext.kt b/mdi/src/commonMain/kotlin/ksm/mdi/StateContext.kt new file mode 100644 index 0000000..85f90d3 --- /dev/null +++ b/mdi/src/commonMain/kotlin/ksm/mdi/StateContext.kt @@ -0,0 +1,10 @@ +package ksm.mdi + +import app.meetacy.di.DI +import ksm.mdi.plugin.DIPlugin +import ksm.context.StateContext +import ksm.plugin.plugin + +public var StateContext.di: DI + get() = plugin(DIPlugin).di(context = this) + set(value) = plugin(DIPlugin).setDI(context = this, di = value) diff --git a/mdi/src/commonMain/kotlin/ksm/mdi/StateController.kt b/mdi/src/commonMain/kotlin/ksm/mdi/StateController.kt new file mode 100644 index 0000000..ee15871 --- /dev/null +++ b/mdi/src/commonMain/kotlin/ksm/mdi/StateController.kt @@ -0,0 +1,8 @@ +package ksm.mdi + +import app.meetacy.di.DI +import ksm.StateController + +public val StateController.di: DI get() { + return context.di +} diff --git a/mdi/src/commonMain/kotlin/ksm/mdi/plugin/DIEntry.kt b/mdi/src/commonMain/kotlin/ksm/mdi/plugin/DIEntry.kt new file mode 100644 index 0000000..2d5484a --- /dev/null +++ b/mdi/src/commonMain/kotlin/ksm/mdi/plugin/DIEntry.kt @@ -0,0 +1,22 @@ +package ksm.mdi.plugin + +import app.meetacy.di.DI +import ksm.context.StateContext + +internal class DIEntry(private val root: DI) : StateContext.Element { + override val key = DIEntry + + private var di: DI? = root + + fun setDI(di: DI?) { + if (di == null) { + this.di = null + return + } + this.di = root + di + } + + fun getDI() = di + + companion object : StateContext.Key +} diff --git a/mdi/src/commonMain/kotlin/ksm/mdi/plugin/DIPlugin.kt b/mdi/src/commonMain/kotlin/ksm/mdi/plugin/DIPlugin.kt new file mode 100644 index 0000000..4f3b4ff --- /dev/null +++ b/mdi/src/commonMain/kotlin/ksm/mdi/plugin/DIPlugin.kt @@ -0,0 +1,50 @@ +package ksm.mdi.plugin + +import app.meetacy.di.DI +import app.meetacy.di.builder.di +import ksm.StateController +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.plugin.Plugin +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.lifecycle.LifecycleInterceptor +import ksm.lifecycle.addLifecycleInterceptor + +public class DIPlugin(private val root: DI) : Plugin { + override val key: Companion = DIPlugin + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration()) + return context + DIEntry(root) + } + + private inner class Configuration : ConfigurationInterceptor { + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + val entry = DIEntry(root) + context.addLifecycleInterceptor(Lifecycle(entry)) + return context + entry + } + } + + private inner class Lifecycle(val entry: DIEntry) : LifecycleInterceptor { + override fun onFinish(context: StateContext) { + entry.setDI(null) + } + } + + public fun di(context: StateContext): DI { + val base = context.require(DIEntry).getDI() ?: error("DI is not initialized") + return base + di { + val stateController by constant(StateController(context)) + } + } + + public fun setDI(context: StateContext, di: DI) { + context.require(DIEntry).setDI(di) + } + + public companion object : StateContext.Key +} diff --git a/navigation/compose-navigation/build.gradle.kts b/navigation/compose-navigation/build.gradle.kts new file mode 100644 index 0000000..a9fffbd --- /dev/null +++ b/navigation/compose-navigation/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("android-library-convention") +} + +version = libs.versions.ksm.get() + +android { + namespace = "app.meetacy.ksm.androidx" + + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } +} + +dependencies { + api(projects.mdi) + api(projects.kotlinxSerializationJson) // todo: remove; needed for example only + implementation(libs.kotlinx.serialization) + implementation(libs.mdi) + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.runtime.saveable) +} diff --git a/navigation/compose-navigation/src/main/kotlin/ksm/compose/RememberStateController.kt b/navigation/compose-navigation/src/main/kotlin/ksm/compose/RememberStateController.kt new file mode 100644 index 0000000..c37f146 --- /dev/null +++ b/navigation/compose-navigation/src/main/kotlin/ksm/compose/RememberStateController.kt @@ -0,0 +1,32 @@ +package ksm.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import app.meetacy.di.DI +import app.meetacy.di.builder.di +import ksm.StateController +import ksm.builder.StateControllerBuilder +import ksm.compose.plugin.ComposePlugin +import ksm.compose.plugin.ComposeSerializationStore +import ksm.createRawStateController +import ksm.serialization.restore + +@Composable +public fun rememberStateController( + builder: StateControllerBuilder.() -> Unit +): StateController { + val store = rememberSaveable( + builder, + saver = ComposeSerializationStore.Saver, + init = { ComposeSerializationStore() } + ) + return remember(store) { + createRawStateController { + install(ComposePlugin(store)) + builder() + }.apply { + context.restore() + } + } +} diff --git a/navigation/compose-navigation/src/main/kotlin/ksm/compose/StateBuilderScope.kt b/navigation/compose-navigation/src/main/kotlin/ksm/compose/StateBuilderScope.kt new file mode 100644 index 0000000..e16ce0a --- /dev/null +++ b/navigation/compose-navigation/src/main/kotlin/ksm/compose/StateBuilderScope.kt @@ -0,0 +1,13 @@ +package ksm.compose + +import androidx.compose.runtime.Composable +import ksm.compose.plugin.ComposePlugin +import ksm.plugin.plugin +import ksm.state.StateScope +import ksm.state.builder.StateBuilderScope + +public fun StateBuilderScope.Content( + block: @Composable StateScope.() -> Unit +) { + context.plugin(ComposePlugin).setContent(context, block) +} diff --git a/navigation/compose-navigation/src/main/kotlin/ksm/compose/host/StateHost.kt b/navigation/compose-navigation/src/main/kotlin/ksm/compose/host/StateHost.kt new file mode 100644 index 0000000..1c40b5b --- /dev/null +++ b/navigation/compose-navigation/src/main/kotlin/ksm/compose/host/StateHost.kt @@ -0,0 +1,32 @@ +package ksm.compose.host + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import ksm.StateController +import ksm.compose.plugin.ComposePlugin +import ksm.plugin.plugin +import ksm.state.StateScope +import ksm.state.launch + +@Composable +public fun StateHost( + controller: StateController, + startStateName: String +) { + Box { + val currentContext = remember(controller) { + controller.launch(startStateName) + controller.context.plugin(ComposePlugin).currentContext + }.value + + key(currentContext) { + currentContext ?: return + val childController = StateController(currentContext) + val contentScope = StateScope(childController) + val content = currentContext.plugin(ComposePlugin).content(currentContext) + content?.invoke(contentScope) + } + } +} diff --git a/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposeEntry.kt b/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposeEntry.kt new file mode 100644 index 0000000..f897d45 --- /dev/null +++ b/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposeEntry.kt @@ -0,0 +1,13 @@ +package ksm.compose.plugin + +import androidx.compose.runtime.Composable +import ksm.context.StateContext +import ksm.state.StateScope + +internal class ComposeEntry : StateContext.Element { + override val key = ComposeEntry + + var content: (@Composable StateScope.() -> Unit)? = null + + companion object : StateContext.Key +} diff --git a/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposePlugin.kt b/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposePlugin.kt new file mode 100644 index 0000000..44b281e --- /dev/null +++ b/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposePlugin.kt @@ -0,0 +1,55 @@ +package ksm.compose.plugin + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import ksm.annotation.MutateContext +import ksm.context.StateContext +import ksm.context.configuration.interceptor.ConfigurationInterceptor +import ksm.context.configuration.interceptor.addConfigurationInterceptor +import ksm.lifecycle.LifecycleInterceptor +import ksm.lifecycle.addLifecycleInterceptor +import ksm.plugin.Plugin +import ksm.serialization.BaseSerializationStore +import ksm.state.StateScope + +public class ComposePlugin( + private val store: BaseSerializationStore.String +) : Plugin { + override val key: Companion = ComposePlugin + + internal val currentContext = mutableStateOf(value = null) + + @MutateContext + override fun install(context: StateContext): StateContext { + context.addConfigurationInterceptor(Configuration()) + return context + store + } + + private inner class Configuration : ConfigurationInterceptor { + + @MutateContext + override fun onConfigure(context: StateContext): StateContext { + context.addLifecycleInterceptor(Lifecycle()) + return context + ComposeEntry() + } + } + + private inner class Lifecycle : LifecycleInterceptor { + override fun onResume(context: StateContext) { + currentContext.value = context + } + } + + internal fun setContent( + context: StateContext, + content: @Composable StateScope.() -> Unit + ) { + context.require(ComposeEntry).content = content + } + + internal fun content(context: StateContext): (@Composable StateScope.() -> Unit)? { + return context.require(ComposeEntry).content + } + + public companion object : StateContext.Key +} diff --git a/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposeSerializationStore.kt b/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposeSerializationStore.kt new file mode 100644 index 0000000..bd58129 --- /dev/null +++ b/navigation/compose-navigation/src/main/kotlin/ksm/compose/plugin/ComposeSerializationStore.kt @@ -0,0 +1,25 @@ +package ksm.compose.plugin + +import androidx.compose.runtime.saveable.SaverScope +import ksm.serialization.BaseSerializationStore +import androidx.compose.runtime.saveable.Saver as ComposeSaver + +public class ComposeSerializationStore( + internal var string: String? = null +) : BaseSerializationStore.String { + + override fun apply(string: String) { + this.string = string + } + + override fun get(): String? = string + + public object Saver : ComposeSaver { + override fun restore(value: String): ComposeSerializationStore { + return ComposeSerializationStore(value) + } + override fun SaverScope.save(value: ComposeSerializationStore): String? { + return value.string + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a2c5ece --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,36 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +rootProject.name = "ksm" + +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + google() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + maven { + url = uri("https://maven.pkg.github.com/meetacy/di") + credentials { + username = System.getenv("GITHUB_USERNAME") + password = System.getenv("GITHUB_TOKEN") + } + } + } +} + +includeBuild("build-logic") + +include( + "core", + "kotlinx-coroutines", + "kotlinx-serialization-json", + "navigation:compose-navigation", + "ktgbotapi", + "mdi" +)