diff --git a/.github/sh/validate_version_format.sh b/.github/sh/validate_version_format.sh index 272fe252..4f59a06d 100755 --- a/.github/sh/validate_version_format.sh +++ b/.github/sh/validate_version_format.sh @@ -1,6 +1,6 @@ #!/bin/bash VERSION=$1 -EXPECTED='^([0-9]+){1,3}\.([0-9]+){1,9}\.([0-9]+){1,9}$' +EXPECTED='^([0-9]+){1,3}\.([0-9]+){1,9}\.([0-9]+){1,9}(-(alpha|beta|rc)[0-9]{2})?$' if [[ ! $VERSION =~ $EXPECTED ]]; then echo "::error ::Invalid version format: $VERSION" exit 1 diff --git a/.github/workflows/codequality.yml b/.github/workflows/code-quality.yml similarity index 60% rename from .github/workflows/codequality.yml rename to .github/workflows/code-quality.yml index 72e5d524..853f7865 100644 --- a/.github/workflows/codequality.yml +++ b/.github/workflows/code-quality.yml @@ -13,6 +13,15 @@ jobs: - name: Checkout sources uses: actions/checkout@v2 + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: zulu + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + - name: Run unit tests run: ./gradlew test @@ -22,6 +31,9 @@ jobs: - name: Run android lint run: ./gradlew lint + - name : Run api compatibility check + run: ./gradlew apiCheck + - name: Build project run: ./gradlew assemble diff --git a/.github/workflows/release-plugin.yml b/.github/workflows/release-plugin.yml deleted file mode 100644 index fbf05480..00000000 --- a/.github/workflows/release-plugin.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Publish plugin release - -on: - workflow_dispatch: - inputs: - version: - description: 'Plugin release version' - required: true - plugin_upload_token: - description: 'Token for uploading plugin to jetbrains repository' - required: true - -env: - NEW_VERSION: ${{ github.event.inputs.version }} - -jobs: - validate: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Ensure main branch - run: ./.github/sh/validate_publishing_branch.sh - - - name: Validate plugin version update - run: ./.github/sh/validate_version_update.sh "pluginVersion" "$NEW_VERSION" - - release: - needs: validate - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - - name: Update plugin version - run: ./.github/sh/update_release_version.sh "pluginVersion" "$NEW_VERSION" - - - uses: stefanzweifel/git-auto-commit-action@v4 - with: - commit_message: "Update plugin version to ${{ env.NEW_VERSION }}" - file_pattern: gradle.properties - skip_dirty_check: true - - - name: Build Changelog - id: github_release - uses: mikepenz/release-changelog-builder-action@v1 - with: - toTag: HEAD - failOnError: true - configuration: .github/changelogconfig/plugin-configuration.json - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Publish intellij plugin release - run: PLUGIN_UPLOAD_TOKEN=${{ github.event.inputs.plugin_upload_token }} ./gradlew :elmslie-plugin:publishPlugin - # run: PLUGIN_UPLOAD_TOKEN=${{ secrets.PLUGIN_UPLOAD_TOKEN }} ./gradlew :elmslie-plugin:publishPlugin - - - name: Create github release - uses: ncipollo/release-action@v1 - with: - name: "Plugin ${{ env.NEW_VERSION }}" - token: "${{ secrets.GITHUB_TOKEN }}" - tag: "plugin-${{ env.NEW_VERSION }}" - body: ${{ steps.github_release.outputs.changelog }} - artifacts: "elmslie-plugin/build/distributions/*.zip" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a7dc5c7..ff56b8fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,12 @@ jobs: with: ref: ${{ env.GITHUB_REF }} + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: zulu + java-version: 17 + - name: Ensure main branch run: ./.github/sh/validate_publishing_branch.sh @@ -42,6 +48,12 @@ jobs: with: ref: ${{ env.GITHUB_REF }} + - name: Setup Java + uses: actions/setup-java@v3 + with: + distribution: zulu + java-version: 17 + - name: Update library version run: ./.github/sh/update_release_version.sh "libraryVersion" "$NEW_VERSION" @@ -60,9 +72,16 @@ jobs: failOnError: true configuration: .github/changelogconfig/configuration.json + - name: Get current git commit SHA + id: vars + run: | + calculatedSha=$(git rev-parse HEAD) + echo "::set-output name=commit_sha::$calculatedSha" + - name: Create github release uses: ncipollo/release-action@v1 with: token: "${{ secrets.GITHUB_TOKEN }}" tag: "${{ env.NEW_VERSION }}" + commit: "${{ steps.vars.outputs.commit_sha }}" body: ${{ steps.github_release.outputs.changelog }} \ No newline at end of file diff --git a/.github/workflows/update_dependencies.yml b/.github/workflows/update_dependencies.yml deleted file mode 100644 index 37390f6e..00000000 --- a/.github/workflows/update_dependencies.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Update dependencies - -on: - workflow_dispatch: - schedule: - # min hour month_day month week_day - - cron: '0 0 18 * *' - -jobs: - update_dependencies: - runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Configure git - run: | - git config user.name dklimchuk - git config user.email klimchuk.daniil@gmail.com - - - name: Update dependencies - run: ./gradlew :upgradeDependencies - diff --git a/README.md b/README.md index 06609206..55149c15 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ ![Elmslie](https://user-images.githubusercontent.com/16104123/104534649-b5defa80-5625-11eb-98b6-d761623f8964.jpeg) [![](https://jitpack.io/v/diklimchuk/test.svg)](https://jitpack.io/#diklimchuk/test) -[![Jitpack badge](https://jitpack.io/v/vivid-money/elmslie.svg)](https://jitpack.io/#vivid-money/elmslie) -[![Code quality badge](https://github.com/vivid-money/elmslie/actions/workflows/codequality.yml/badge.svg?branch=main&event=push)](https://github.com/vivid-money/elmslie/actions/workflows/codequality.yml) +[![Maven Central Version](https://img.shields.io/maven-central/v/money.vivid.elmslie/elmslie-core)](https://central.sonatype.com/artifact/money.vivid.elmslie/elmslie-core) [![License badge](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) Elmslie is a minimalistic reactive implementation of TEA/ELM written in kotlin with java support. @@ -10,7 +9,7 @@ Named after [George Grant Elmslie](https://en.wikipedia.org/wiki/George_Grant_El ## Why? - **Scalable and Reusable**: Built-in support for nesting components -- **Reactive**: Written with pure Kotlin, but has compatibility mode with RxJava2, RxJava3 and Coroutines +- **Multiplatform**: Written with pure Kotlin and Coroutines, supports KMP (Android, iOS, JS) - **Single immutable state**: Simplify state management - **UDF**: Say no to spaghetti code with Unidirectional Data Flow @@ -25,44 +24,30 @@ This is a visual representation of the architecture: For more info head to the [wiki](https://github.com/vivid-money/elmslie/wiki) ## Samples -Samples are available [here](https://github.com/vivid-money/elmslie/tree/main/elmslie-samples) -- Basic loader for android: [link](https://github.com/vivid-money/elmslie/tree/main/elmslie-samples/android-loader) -- Pure kotlin calculator: [link](https://github.com/vivid-money/elmslie/tree/main/elmslie-samples/kotlin-calculator) -- Pure java notes: [link](https://github.com/vivid-money/elmslie/tree/main/elmslie-samples/java-notes) -- Paging with compose: [link](https://github.com/vivid-money/elmslie/tree/main/elmslie-samples/compose-paging) - -## Code generation plugin for Android Studio -Plugin is available at the [Jetbrains plugin repository](https://plugins.jetbrains.com/plugin/17176-elmslie-generator/versions/stable/125661) -More info in the [wiki article](https://github.com/vivid-money/elmslie/wiki) +Samples are available [here](https://github.com/vivid-money/elmslie/tree/publish-elmslie-3.0/samples) +- Basic loader for android: [link](https://github.com/vivid-money/elmslie/tree/publish-elmslie-3.0/samples/coroutines-loader) +- Pure kotlin calculator: [link](https://github.com/vivid-money/elmslie/tree/publish-elmslie-3.0/samples/kotlin-calculator) ## Download Library is distributed through JitPack #### Add repository in the root build.gradle -``` +```kotlin allprojects { - repositories { - maven { url "https://jitpack.io" } - } + repositories { + mavenCentral() + } } ``` #### Add required modules: - Core - for pure kotlin ELM implementation -`implementation 'com.github.vivid-money.elmslie:elmslie-core:{latest-version}'` +`implementation 'money.vivid.elmslie:elmslie-core:{latest-version}'` - Android - for android apps only, simplifies lifecycle handling -`implementation 'com.github.vivid-money.elmslie:elmslie-android:{latest-version}'` - -- RxJava 2 - compatibility module (more info in the wiki [article](https://github.com/vivid-money/elmslie/wiki/RxJava-2-vs-3)) - -`implementation 'com.github.vivid-money.elmslie:elmslie-rxjava-2:{latest-version}'` - -- Jetpack Compose - for android apps only, simplifies using jetpack compose (not required) - -`implementation 'com.github.vivid-money.elmslie:elmslie-compose:{latest-version}'` +`implementation 'money.vivid.elmslie:elmslie-android:{latest-version}'` ## Related articles diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts new file mode 100644 index 00000000..52fe4df1 --- /dev/null +++ b/build-logic/build.gradle.kts @@ -0,0 +1,13 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + `kotlin-dsl` +} + +dependencies { + implementation(libs.android.gradlePlugin) + implementation(libs.detekt.gradlePlugin) + implementation(libs.dokka.gradlePlugin) + implementation(libs.kotlin.gradlePlugin) + implementation(libs.mavenPublishPlugin) +} diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts new file mode 100644 index 00000000..404e8ec1 --- /dev/null +++ b/build-logic/settings.gradle.kts @@ -0,0 +1,20 @@ +@file:Suppress("UnstableApiUsage") + +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } + versionCatalogs.create("libs").from(files("../gradle/libs.versions.toml")) +} + +rootProject.name = "build-logic" diff --git a/build-logic/src/main/kotlin/JvmTarget.kt b/build-logic/src/main/kotlin/JvmTarget.kt new file mode 100644 index 00000000..5bc6aa7f --- /dev/null +++ b/build-logic/src/main/kotlin/JvmTarget.kt @@ -0,0 +1,3 @@ +import org.gradle.api.JavaVersion + +val JvmTarget = JavaVersion.VERSION_11 \ No newline at end of file diff --git a/build-logic/src/main/kotlin/PublishingExtension.kt b/build-logic/src/main/kotlin/PublishingExtension.kt new file mode 100644 index 00000000..7b5f2846 --- /dev/null +++ b/build-logic/src/main/kotlin/PublishingExtension.kt @@ -0,0 +1,12 @@ +abstract class PublishingExtension { + + internal lateinit var pom: Pom + + fun pom( + block: Pom.() -> Unit, + ) { + pom = Pom().apply(block) + } + + data class Pom(var name: String = "", var description: String = "") +} diff --git a/build-logic/src/main/kotlin/elmslie.android-lib.gradle.kts b/build-logic/src/main/kotlin/elmslie.android-lib.gradle.kts new file mode 100644 index 00000000..592a9cc1 --- /dev/null +++ b/build-logic/src/main/kotlin/elmslie.android-lib.gradle.kts @@ -0,0 +1,36 @@ +@file:Suppress("UnstableApiUsage") + +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.dokka") + id("elmslie.base-lib") + id("elmslie.detekt") + id("elmslie.tests-convention") +} + +android { + compileSdk = 33 + buildToolsVersion = "31.0.0" + + defaultConfig { minSdk = 21 } + + lint { + checkReleaseBuilds = false + checkDependencies = true + + ignoreTestSources = true + abortOnError = true + warningsAsErrors = true + + htmlReport = true + xmlReport = false + } + + compileOptions { + targetCompatibility = JvmTarget + sourceCompatibility = JvmTarget + } +} + +val catalog = extensions.getByType().named("libs") diff --git a/build-logic/src/main/kotlin/elmslie.base-lib.gradle.kts b/build-logic/src/main/kotlin/elmslie.base-lib.gradle.kts new file mode 100644 index 00000000..c59006e0 --- /dev/null +++ b/build-logic/src/main/kotlin/elmslie.base-lib.gradle.kts @@ -0,0 +1,8 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +tasks.withType { + targetCompatibility = JvmTarget.toString() + sourceCompatibility = JvmTarget.toString() +} + +tasks.withType { kotlinOptions.jvmTarget = JvmTarget.toString() } diff --git a/build-logic/src/main/kotlin/elmslie.detekt.gradle.kts b/build-logic/src/main/kotlin/elmslie.detekt.gradle.kts new file mode 100644 index 00000000..69c25ca7 --- /dev/null +++ b/build-logic/src/main/kotlin/elmslie.detekt.gradle.kts @@ -0,0 +1,18 @@ +@file:Suppress("UnstableApiUsage") + +import io.gitlab.arturbosch.detekt.Detekt + +plugins { id("io.gitlab.arturbosch.detekt") } + +detekt { + parallel = true + config.setFrom("$rootDir/detekt/detekt.yml") +} + +tasks.withType { + reports { + html.required.set(true) + xml.required.set(false) + txt.required.set(false) + } +} diff --git a/build-logic/src/main/kotlin/elmslie.kotlin-multiplatform-lib.gradle.kts b/build-logic/src/main/kotlin/elmslie.kotlin-multiplatform-lib.gradle.kts new file mode 100644 index 00000000..5e207f0c --- /dev/null +++ b/build-logic/src/main/kotlin/elmslie.kotlin-multiplatform-lib.gradle.kts @@ -0,0 +1,38 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl + +plugins { + kotlin("multiplatform") + id("elmslie.detekt") +} + +kotlin { + applyDefaultHierarchyTemplate { + common { + group("commonWeb") { + withJs() + withWasm() + } + } + } + + jvm { + compilations.all { + compilerOptions.configure { + jvmTarget.set(JvmTarget.JVM_11) + } + } + } + + iosArm64() + iosSimulatorArm64() + iosX64() + + js(IR) { + browser() + } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } +} \ No newline at end of file diff --git a/build-logic/src/main/kotlin/elmslie.publishing.gradle.kts b/build-logic/src/main/kotlin/elmslie.publishing.gradle.kts new file mode 100644 index 00000000..bd618636 --- /dev/null +++ b/build-logic/src/main/kotlin/elmslie.publishing.gradle.kts @@ -0,0 +1,68 @@ +import com.vanniktech.maven.publish.SonatypeHost +import gradle.kotlin.dsl.accessors._089c671483fb7c20aeca81bf72df85c9.mavenPublishing +import java.lang.IllegalArgumentException + +plugins { + id("com.vanniktech.maven.publish") +} + +private val elmslieGitHubUrl = "https://github.com/vivid-money/elmslie" + +val publishingExtension = + project.extensions.create("elmsliePublishing", PublishingExtension::class.java) + +val libraryGroup: String by project +val libraryVersion: String by project + +afterEvaluate { + val pom = publishingExtension.pom + with(project.mavenPublishing) { + checkPomRequiredFields(pom) + publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + + coordinates(libraryGroup, project.name, libraryVersion) + + pom { + name.set(pom.name) + description.set(pom.description) + url.set(elmslieGitHubUrl) + + licenses { + license { + name.set("The Apache License, Version 2.0") + url.set("http://www.apache.org/licenses/LICENSE-2.0.txt") + } + } + + issueManagement { + system.set("GitHub Issues") + url.set("$elmslieGitHubUrl/issues") + } + + developers { + developer { + id.set("DeveloperMobile") + name.set("Developer Mobile") + email.set("developer.mobile@vivid.money") + } + } + + scm { + connection.set("scm:git:git://github.com/vivid-money/elmslie.git") + developerConnection.set("scm:git:ssh://github.com/vivid-money/elmslie.git") + url.set(elmslieGitHubUrl) + } + } + } +} + +fun checkPomRequiredFields(pom: PublishingExtension.Pom) { + if (pom.name.isBlank()) { + throw IllegalArgumentException( + """Pom.name cannot be empty + | Please, call elmsliePublishing { pom { name = "Lib name" } } + """.trimMargin(), + ) + } +} diff --git a/build-logic/src/main/kotlin/elmslie.tests-convention.gradle.kts b/build-logic/src/main/kotlin/elmslie.tests-convention.gradle.kts new file mode 100644 index 00000000..4b4594e9 --- /dev/null +++ b/build-logic/src/main/kotlin/elmslie.tests-convention.gradle.kts @@ -0,0 +1,8 @@ +tasks.withType { useJUnitPlatform() } + +val catalog = extensions.getByType().named("libs") + +dependencies { + val testImplementation by configurations + catalog.findLibrary("kotlin-test").ifPresent { testImplementation(it) } +} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index bfeea9c0..00000000 --- a/build.gradle +++ /dev/null @@ -1,45 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - apply from: 'gradle/dependencies.gradle' - repositories { - google() - mavenCentral() - maven { - url "https://plugins.gradle.org/m2/" - } - } - dependencies { - classpath deps.gradle.androidPlugin - classpath deps.gradle.kotlinPlugin - classpath deps.gradle.intellijPlugin - classpath deps.gradle.detektPlugin - classpath deps.gradle.releasesHub - classpath deps.gradle.dokka - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - mavenCentral() - } - - tasks.withType(JavaCompile) { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { - kotlinOptions { - jvmTarget = "11" - } - } -} - -task clean(type: Delete) { - delete rootProject.buildDir -} - -apply from: 'gradle/release-hub.gradle' diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..e1358f9a --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.kotlinMultiplatform) apply false +} \ No newline at end of file diff --git a/detekt/detekt.yml b/detekt/detekt.yml index 5307b33e..7fa1f1d7 100644 --- a/detekt/detekt.yml +++ b/detekt/detekt.yml @@ -1,6 +1,3 @@ -# https://github.com/detekt/detekt/blob/master/detekt-core/src/main/resources/default-detekt-config.yml -# Documentation is here: https://github.com/arturbosch/detekt/tree/master/docs/pages/documentation - build: maxIssues: 0 excludeCorrectable: false @@ -20,7 +17,7 @@ config: processors: active: true exclude: - # - 'DetektProgressListener' + - 'DetektProgressListener' # - 'KtFileCountProcessor' # - 'PackageCountProcessor' # - 'ClassCountProcessor' @@ -42,7 +39,7 @@ console-reports: - 'NotificationReport' - 'FindingsReport' - 'FileBasedFindingsReport' - - 'LiteFindingsReport' + # - 'LiteFindingsReport' output-reports: active: true @@ -51,6 +48,7 @@ output-reports: # - 'XmlOutputReport' # - 'HtmlOutputReport' # - 'MdOutputReport' + # - 'SarifOutputReport' comments: active: true @@ -69,7 +67,7 @@ comments: endOfSentenceFormat: '([.?!][ \t\n\r\f<])|([.?!:]$)' KDocReferencesNonPublicProperty: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] OutdatedDocumentation: active: false matchTypeParameters: true @@ -77,20 +75,20 @@ comments: allowParamOnConstructorProperties: false UndocumentedPublicClass: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] searchInNestedClass: true searchInInnerClass: true searchInInnerObject: true searchInInnerInterface: true - searchInProtectedClass: true + searchInProtectedClass: false UndocumentedPublicFunction: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] - searchProtectedFunction: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedFunction: false UndocumentedPublicProperty: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] - searchProtectedProperty: true + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] + searchProtectedProperty: false complexity: active: true @@ -124,7 +122,7 @@ complexity: - 'with' LabeledExpression: active: false - ignoredLabels: [ ] + ignoredLabels: [] LargeClass: active: true threshold: 600 @@ -137,7 +135,7 @@ complexity: constructorThreshold: 7 ignoreDefaultParameters: false ignoreDataClasses: true - ignoreAnnotatedParameter: [ ] + ignoreAnnotatedParameter: [] MethodOverloading: active: false threshold: 6 @@ -161,14 +159,14 @@ complexity: active: false StringLiteralDuplication: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] threshold: 3 ignoreAnnotation: true excludeStringsWithLessThan5Characters: true ignoreStringsRegex: '$^' TooManyFunctions: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] thresholdInFiles: 11 thresholdInClasses: 11 thresholdInInterfaces: 11 @@ -192,6 +190,8 @@ coroutines: active: true SleepInsteadOfDelay: active: true + SuspendFunSwallowedCancellation: + active: false SuspendFunWithCoroutineScopeReceiver: active: false SuspendFunWithFlowReturnType: @@ -243,7 +243,7 @@ exceptions: - 'toString' InstanceOfCheckForException: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] NotImplementedDeclaration: active: false ObjectExtendsThrowable: @@ -269,7 +269,7 @@ exceptions: active: false ThrowingExceptionsWithoutMessageOrCause: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] exceptions: - 'ArrayIndexOutOfBoundsException' - 'Exception' @@ -284,7 +284,7 @@ exceptions: active: true TooGenericExceptionCaught: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] exceptionNames: - 'ArrayIndexOutOfBoundsException' - 'Error' @@ -308,7 +308,6 @@ naming: BooleanPropertyNaming: active: false allowedPattern: '^(is|has|are)' - ignoreOverridden: true ClassNaming: active: true classPattern: '[A-Z][a-zA-Z0-9]*' @@ -317,13 +316,12 @@ naming: parameterPattern: '[a-z][A-Za-z0-9]*' privateParameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true EnumNaming: active: true enumEntryPattern: '[A-Z][_a-zA-Z0-9]*' ForbiddenClassName: active: false - forbiddenName: [ ] + forbiddenName: [] FunctionMaxLength: active: false maximumFunctionNameLength: 30 @@ -332,17 +330,13 @@ naming: minimumFunctionNameLength: 3 FunctionNaming: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] functionPattern: '[a-z][a-zA-Z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true - ignoreAnnotated: - - 'Composable' FunctionParameterNaming: active: true parameterPattern: '[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true InvalidPackageDeclaration: active: true rootPackage: '' @@ -359,7 +353,7 @@ naming: NoNameShadowing: active: true NonBooleanPropertyPrefixedWithIs: - active: true + active: false ObjectPropertyNaming: active: true constantPattern: '[A-Za-z][_A-Za-z0-9]*' @@ -367,7 +361,7 @@ naming: privatePropertyPattern: '(_)?[A-Za-z][_A-Za-z0-9]*' PackageNaming: active: true - packagePattern: '[a-z]+(\.[a-z][a-z_0-9]*)*' + packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*' TopLevelPropertyNaming: active: true constantPattern: '[A-Z][_A-Z0-9]*' @@ -384,7 +378,6 @@ naming: variablePattern: '[a-z][A-Za-z0-9]*' privateVariablePattern: '(_)?[a-z][A-Za-z0-9]*' excludeClassPattern: '$^' - ignoreOverridden: true performance: active: true @@ -395,12 +388,12 @@ performance: threshold: 3 ForEachOnRange: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] SpreadOperator: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] UnnecessaryPartOfBinaryExpression: - active: true + active: false UnnecessaryTemporaryInstantiation: active: true @@ -410,10 +403,12 @@ potential-bugs: active: true forbiddenTypePatterns: - 'kotlin.String' + CastNullableToNonNullableType: + active: false CastToNullableType: active: false Deprecation: - active: true + active: false DontDowncastCollectionTypes: active: false DoubleMutabilityForCollection: @@ -429,6 +424,7 @@ potential-bugs: - 'java.util.HashMap' ElseCaseInsteadOfExhaustiveWhen: active: false + ignoredSubjectTypes: [] EqualsAlwaysReturnsTrueOrFalse: active: true EqualsWithHashCodeExist: @@ -441,21 +437,24 @@ potential-bugs: active: true IgnoredReturnValue: active: true - restrictToAnnotatedMethods: true + restrictToConfig: true returnValueAnnotations: + - 'CheckResult' - '*.CheckResult' + - 'CheckReturnValue' - '*.CheckReturnValue' ignoreReturnValueAnnotations: + - 'CanIgnoreReturnValue' - '*.CanIgnoreReturnValue' returnValueTypes: - 'kotlin.sequences.Sequence' - 'kotlinx.coroutines.flow.*Flow' - 'java.util.stream.*Stream' - ignoreFunctionCall: [ ] + ignoreFunctionCall: [] ImplicitDefaultLocale: active: true ImplicitUnitReturnType: - active: true + active: false allowExplicitReturnType: true InvalidRange: active: true @@ -465,17 +464,19 @@ potential-bugs: active: true LateinitUsage: active: false - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] ignoreOnClassesPattern: '' MapGetWithNotNullAssertionOperator: active: true MissingPackageDeclaration: active: false - excludes: [ '**/*.kts' ] + excludes: ['**/*.kts'] NullCheckOnMutableProperty: active: false NullableToStringCall: - active: true + active: false + PropertyUsedBeforeDeclaration: + active: false UnconditionalJumpStatementInLoop: active: false UnnecessaryNotNullCheck: @@ -490,7 +491,7 @@ potential-bugs: active: true UnsafeCallOnNullableType: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**'] UnsafeCast: active: true UnusedUnaryOperator: @@ -503,9 +504,17 @@ potential-bugs: style: active: true AlsoCouldBeApply: - active: true + active: false + BracesOnIfStatements: + active: false + singleLine: 'never' + multiLine: 'always' + BracesOnWhenStatements: + active: false + singleLine: 'necessary' + multiLine: 'consistent' CanBeNonNullable: - active: true + active: false CascadingCallWrapping: active: false includeElvis: true @@ -517,36 +526,66 @@ style: active: false conversionFunctionPrefix: - 'to' + allowOperators: false DataClassShouldBeImmutable: active: false DestructuringDeclarationWithTooManyEntries: active: true maxDestructuringEntries: 3 + DoubleNegativeLambda: + active: false + negativeFunctions: + - reason: 'Use `takeIf` instead.' + value: 'takeUnless' + - reason: 'Use `all` instead.' + value: 'none' + negativeFunctionNameParts: + - 'not' + - 'non' EqualsNullCall: active: true EqualsOnSignatureLine: - active: true + active: false ExplicitCollectionElementAccessMethod: - active: true + active: false ExplicitItLambdaParameter: active: true ExpressionBodySyntax: active: false includeLineWrapping: false + ForbiddenAnnotation: + active: false + annotations: + - reason: 'it is a java annotation. Use `Suppress` instead.' + value: 'java.lang.SuppressWarnings' + - reason: 'it is a java annotation. Use `kotlin.Deprecated` instead.' + value: 'java.lang.Deprecated' + - reason: 'it is a java annotation. Use `kotlin.annotation.MustBeDocumented` instead.' + value: 'java.lang.annotation.Documented' + - reason: 'it is a java annotation. Use `kotlin.annotation.Target` instead.' + value: 'java.lang.annotation.Target' + - reason: 'it is a java annotation. Use `kotlin.annotation.Retention` instead.' + value: 'java.lang.annotation.Retention' + - reason: 'it is a java annotation. Use `kotlin.annotation.Repeatable` instead.' + value: 'java.lang.annotation.Repeatable' + - reason: 'Kotlin does not support @Inherited annotation, see https://youtrack.jetbrains.com/issue/KT-22265' + value: 'java.lang.annotation.Inherited' ForbiddenComment: active: true - values: - - 'FIXME:' - - 'STOPSHIP:' - - 'TODO:' + comments: + - reason: 'Forbidden FIXME todo marker in comment, please fix the problem.' + value: 'FIXME:' + - reason: 'Forbidden STOPSHIP todo marker in comment, please address the problem before shipping the code.' + value: 'STOPSHIP:' + - reason: 'Forbidden TODO todo marker in comment, please do the changes.' + value: 'TODO:' allowedPatterns: '' - customMessage: '' ForbiddenImport: - active: true - imports: [ ] + active: false + imports: [] forbiddenPatterns: '' ForbiddenMethodCall: - active: true + active: false methods: - reason: 'print does not allow you to configure the output stream. Use a logger instead.' value: 'kotlin.io.print' @@ -554,7 +593,7 @@ style: value: 'kotlin.io.println' ForbiddenSuppress: active: false - rules: [ ] + rules: [] ForbiddenVoid: active: true ignoreOverridden: false @@ -563,13 +602,13 @@ style: active: true ignoreOverridableFunction: true ignoreActualFunction: true - excludedFunctions: [ ] + excludedFunctions: [] LoopWithTooManyJumpStatements: active: true maxJumpCount: 1 MagicNumber: active: true - excludes: [ '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts' ] + excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/androidUnitTest/**', '**/androidInstrumentedTest/**', '**/jsTest/**', '**/iosTest/**', '**/*.kts'] ignoreNumbers: - '-1' - '0' @@ -582,13 +621,11 @@ style: ignoreCompanionObjectPropertyDeclaration: true ignoreAnnotation: false ignoreNamedArgument: true - ignoreEnums: true + ignoreEnums: false ignoreRanges: false ignoreExtensionFunctions: true - MandatoryBracesIfStatements: - active: false MandatoryBracesLoops: - active: true + active: false MaxChainedCallsOnSameLine: active: false maxChainedCalls: 5 @@ -608,6 +645,9 @@ style: MultilineRawStringIndentation: active: false indentSize: 4 + trimmingMethods: + - 'trimIndent' + - 'trimMargin' NestedClassesVisibility: active: true NewLineAtEndOfFile: @@ -621,7 +661,7 @@ style: OptionalAbstractKeyword: active: true OptionalUnit: - active: true + active: false OptionalWhenBraces: active: false PreferToOverPairSyntax: @@ -629,14 +669,14 @@ style: ProtectedMemberInFinalClass: active: true RedundantExplicitType: - active: true + active: false RedundantHigherOrderMapUsage: active: true RedundantVisibilityModifierRule: - active: true + active: false ReturnCount: active: true - max: 3 + max: 2 excludedFunctions: - 'equals' excludeLabeled: false @@ -647,15 +687,22 @@ style: SerialVersionUIDInSerializableClass: active: true SpacingBetweenPackageAndImports: - active: true + active: false + StringShouldBeRawString: + active: false + maxEscapedCharacterCount: 2 + ignoredCharacters: [] ThrowsCount: active: true max: 2 excludeGuardClauses: false TrailingWhitespace: - active: true + active: false TrimMultilineRawString: - active: true + active: false + trimmingMethods: + - 'trimIndent' + - 'trimMargin' UnderscoresInNumericLiterals: active: false acceptableLength: 4 @@ -663,11 +710,13 @@ style: UnnecessaryAbstractClass: active: true UnnecessaryAnnotationUseSiteTarget: - active: true + active: false UnnecessaryApply: active: true UnnecessaryBackticks: active: false + UnnecessaryBracesAroundTrailingLambda: + active: false UnnecessaryFilter: active: true UnnecessaryInheritance: @@ -675,19 +724,25 @@ style: UnnecessaryInnerClass: active: false UnnecessaryLet: - active: true + active: false UnnecessaryParentheses: - active: true + active: false allowForUnclearPrecedence: false UntilInsteadOfRangeTo: active: false UnusedImports: + active: false + UnusedParameter: active: true + allowedNames: 'ignored|expected' UnusedPrivateClass: active: true UnusedPrivateMember: active: true - allowedNames: '(_|ignored|expected|serialVersionUID)' + allowedNames: '' + UnusedPrivateProperty: + active: true + allowedNames: '_|ignored|expected|serialVersionUID' UseAnyOrNoneInsteadOfFind: active: true UseArrayLiteralsInAnnotations: @@ -697,16 +752,19 @@ style: UseCheckOrError: active: true UseDataClass: - active: true + active: false allowVars: false UseEmptyCounterpart: - active: true + active: false UseIfEmptyOrIfBlank: - active: true + active: false UseIfInsteadOfWhen: active: false + ignoreWhenContainingVariableDeclaration: false UseIsNullOrEmpty: active: true + UseLet: + active: false UseOrEmpty: active: true UseRequire: @@ -714,7 +772,7 @@ style: UseRequireNotNull: active: true UseSumOfInsteadOfFlatMapSize: - active: true + active: false UselessCallOnNotNull: active: true UtilityClassWithPublicConstructor: @@ -723,6 +781,6 @@ style: active: true ignoreLateinitVar: false WildcardImport: - active: false + active: true excludeImports: - 'java.util.*' \ No newline at end of file diff --git a/elmslie-android/api/elmslie-android.api b/elmslie-android/api/elmslie-android.api new file mode 100644 index 00000000..c353122d --- /dev/null +++ b/elmslie-android/api/elmslie-android.api @@ -0,0 +1,79 @@ +public final class money/vivid/elmslie/android/ElmStoreLazyKt { + public static final fun elmStore (Landroidx/activity/ComponentActivity;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lkotlin/Lazy; + public static final fun elmStore (Landroidx/fragment/app/Fragment;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lkotlin/Lazy; + public static synthetic fun elmStore$default (Landroidx/activity/ComponentActivity;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/Lazy; + public static synthetic fun elmStore$default (Landroidx/fragment/app/Fragment;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/Lazy; +} + +public final class money/vivid/elmslie/android/RetainedElmStore : androidx/lifecycle/ViewModel { + public static final field Companion Lmoney/vivid/elmslie/android/RetainedElmStore$Companion; + public static final field StateBundleKey Ljava/lang/String; + public fun (Landroidx/lifecycle/SavedStateHandle;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V + public final fun getStore ()Lmoney/vivid/elmslie/core/store/EffectCachingElmStore; +} + +public final class money/vivid/elmslie/android/RetainedElmStore$Companion { +} + +public final class money/vivid/elmslie/android/RetainedElmStoreFactory : androidx/lifecycle/AbstractSavedStateViewModelFactory { + public fun (Landroidx/savedstate/SavedStateRegistryOwner;Landroid/os/Bundle;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)V +} + +public final class money/vivid/elmslie/android/logger/DefaultLoggerConfigurationsKt { + public static final fun defaultDebugLogger (Lmoney/vivid/elmslie/core/config/ElmslieConfig;)V + public static final fun defaultReleaseLogger (Lmoney/vivid/elmslie/core/config/ElmslieConfig;)V +} + +public final class money/vivid/elmslie/android/logger/DefaultLoggerInitializer : androidx/startup/Initializer { + public fun ()V + public synthetic fun create (Landroid/content/Context;)Ljava/lang/Object; + public fun create (Landroid/content/Context;)V + public fun dependencies ()Ljava/util/List; +} + +public final class money/vivid/elmslie/android/logger/strategy/AndroidLog { + public static final field INSTANCE Lmoney/vivid/elmslie/android/logger/strategy/AndroidLog; + public final fun getD ()Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy; + public final fun getE ()Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy; + public final fun getI ()Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy; + public final fun getV ()Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy; + public final fun getW ()Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy; +} + +public final class money/vivid/elmslie/android/logger/strategy/Crash : money/vivid/elmslie/core/logger/strategy/LogStrategy { + public static final field INSTANCE Lmoney/vivid/elmslie/android/logger/strategy/Crash; + public fun log (Lmoney/vivid/elmslie/core/logger/LogSeverity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V +} + +public final class money/vivid/elmslie/android/processdeath/ProcessDeathDetector { + public static final field INSTANCE Lmoney/vivid/elmslie/android/processdeath/ProcessDeathDetector; + public final fun isRestoringAfterProcessDeath ()Z +} + +public final class money/vivid/elmslie/android/processdeath/ProcessDeathDetectorInitializer : androidx/startup/Initializer { + public fun ()V + public synthetic fun create (Landroid/content/Context;)Ljava/lang/Object; + public fun create (Landroid/content/Context;)V + public fun dependencies ()Ljava/util/List; +} + +public abstract interface class money/vivid/elmslie/android/renderer/ElmRendererDelegate { + public abstract fun handleEffect (Ljava/lang/Object;)Lkotlin/Unit; + public abstract fun mapList (Ljava/lang/Object;)Ljava/util/List; + public abstract fun render (Ljava/lang/Object;)V + public abstract fun renderList (Ljava/lang/Object;Ljava/util/List;)V +} + +public final class money/vivid/elmslie/android/renderer/ElmRendererDelegate$DefaultImpls { + public static fun handleEffect (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/Object;)Lkotlin/Unit; + public static fun mapList (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/Object;)Ljava/util/List; + public static fun renderList (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/Object;Ljava/util/List;)V +} + +public final class money/vivid/elmslie/android/renderer/ElmRendererDelegateKt { + public static final fun androidElmStore (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lkotlin/Lazy; + public static final fun androidElmStore (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lkotlin/Lazy; + public static synthetic fun androidElmStore$default (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/Lazy; + public static synthetic fun androidElmStore$default (Lmoney/vivid/elmslie/android/renderer/ElmRendererDelegate;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlin/Lazy; +} + diff --git a/elmslie-android/build.gradle b/elmslie-android/build.gradle deleted file mode 100644 index 674939ca..00000000 --- a/elmslie-android/build.gradle +++ /dev/null @@ -1,18 +0,0 @@ -plugins { - id("com.android.library") - id("kotlin-android") -} - -dependencies { - implementation(project(":elmslie-core")) - - implementation(deps.android.appcompat) - implementation(deps.android.appStartup) - implementation(deps.android.lifecycle) -} - -apply from: "../gradle/junit-5.gradle" -apply from: "../gradle/android-library.gradle" -apply from: "../gradle/android-publishing.gradle" -apply from: "../gradle/android-lint.gradle" -apply from: "../gradle/detekt.gradle" diff --git a/elmslie-android/build.gradle.kts b/elmslie-android/build.gradle.kts new file mode 100644 index 00000000..7352c648 --- /dev/null +++ b/elmslie-android/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("elmslie.android-lib") + id("elmslie.publishing") + alias(libs.plugins.binaryCompatibilityValidator) +} + +android { + namespace = "money.vivid.elmslie.android" +} + +elmsliePublishing { + pom { + name = "Elmslie Android" + description = + "Elmslie is a minimalistic reactive implementation of TEA/ELM. Android specific. https://github.com/vivid-money/elmslie/" + } +} + +dependencies { + implementation(projects.elmslieCore) + + implementation(libs.androidx.appcompat) + implementation(libs.androidx.lifecycle.runtimeKtx) + implementation(libs.androidx.lifecycle.viewmodelSavedstate) + implementation(libs.androidx.startup.runtime) +} diff --git a/elmslie-android/detekt-baseline.xml b/elmslie-android/detekt-baseline.xml new file mode 100644 index 00000000..01582f82 --- /dev/null +++ b/elmslie-android/detekt-baseline.xml @@ -0,0 +1,9 @@ + + + + + LongParameterList:ElmStoreLazy.kt$( key: String = this::class.java.canonicalName ?: this::class.java.simpleName, viewModelStoreOwner: () -> ViewModelStoreOwner = { this }, savedStateRegistryOwner: () -> SavedStateRegistryOwner = { this }, defaultArgs: () -> Bundle = { arguments ?: bundleOf() }, saveState: Bundle.(State) -> Unit = {}, storeFactory: SavedStateHandle.() -> Store<Event, Effect, State>, ) + LongParameterList:ElmStoreLazy.kt$( key: String = this::class.java.canonicalName ?: this::class.java.simpleName, viewModelStoreOwner: () -> ViewModelStoreOwner = { this }, savedStateRegistryOwner: () -> SavedStateRegistryOwner = { this }, defaultArgs: () -> Bundle = { this.intent?.extras ?: bundleOf() }, saveState: Bundle.(State) -> Unit = {}, storeFactory: SavedStateHandle.() -> Store<Event, Effect, State>, ) + LongParameterList:ElmStoreLazy.kt$( key: String, viewModelStoreOwner: () -> ViewModelStoreOwner, savedStateRegistryOwner: () -> SavedStateRegistryOwner, defaultArgs: () -> Bundle, saveState: Bundle.(State) -> Unit, storeFactory: SavedStateHandle.() -> Store<Event, Effect, State>, ) + + diff --git a/elmslie-android/src/main/AndroidManifest.xml b/elmslie-android/src/main/AndroidManifest.xml index 52802ea2..9b86c0a9 100644 --- a/elmslie-android/src/main/AndroidManifest.xml +++ b/elmslie-android/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt deleted file mode 100644 index 4cebb3b9..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmActivity.kt +++ /dev/null @@ -1,27 +0,0 @@ -package vivid.money.elmslie.android.base - -import androidx.annotation.LayoutRes -import androidx.appcompat.app.AppCompatActivity -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.android.storeholder.StoreHolder -import vivid.money.elmslie.android.util.fastLazy - -abstract class ElmActivity : - AppCompatActivity, ElmDelegate { - - constructor() : super() - - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - - @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { this } - - protected val store - get() = storeHolder.store - - override val storeHolder: StoreHolder by fastLazy { - LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - } -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt deleted file mode 100644 index d7629020..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/base/ElmFragment.kt +++ /dev/null @@ -1,27 +0,0 @@ -package vivid.money.elmslie.android.base - -import androidx.annotation.LayoutRes -import androidx.fragment.app.Fragment -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.android.storeholder.StoreHolder -import vivid.money.elmslie.android.util.fastLazy - -abstract class ElmFragment : Fragment, - ElmDelegate { - - constructor() : super() - - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - - @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { requireActivity() } - - protected val store - get() = storeHolder.store - - override val storeHolder: StoreHolder by fastLazy { - LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - } -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/DefaultLoggerConfigurations.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/DefaultLoggerConfigurations.kt deleted file mode 100644 index ff055286..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/DefaultLoggerConfigurations.kt +++ /dev/null @@ -1,18 +0,0 @@ -package vivid.money.elmslie.android.logger - -import vivid.money.elmslie.android.logger.strategy.Crash -import vivid.money.elmslie.android.logger.strategy.AndroidLog -import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.logger.strategy.IgnoreLog - -fun ElmslieConfig.defaultReleaseLogger() = logger { - fatal(Crash) - nonfatal(IgnoreLog) - debug(IgnoreLog) -} - -fun ElmslieConfig.defaultDebugLogger() = logger { - fatal(Crash) - nonfatal(AndroidLog.E) - debug(AndroidLog.E) -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/strategy/AndroidLog.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/strategy/AndroidLog.kt deleted file mode 100644 index 8e68eee6..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/strategy/AndroidLog.kt +++ /dev/null @@ -1,18 +0,0 @@ -package vivid.money.elmslie.android.logger.strategy - -import android.util.Log -import vivid.money.elmslie.core.logger.strategy.LogStrategy - -/** Uses default android logging mechanism for reporting */ -object AndroidLog { - - val E = log(Log::e) - val W = log(Log::w) - val I = log(Log::i) - val D = log(Log::d) - val V = log(Log::v) - - private fun log( - log: (String?, String?, Throwable?) -> Unit - ) = LogStrategy { _, message, error -> log(null, message, error) } -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt deleted file mode 100644 index 463ae067..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/StopElmOnProcessDeath.kt +++ /dev/null @@ -1,3 +0,0 @@ -package vivid.money.elmslie.android.processdeath - -interface StopElmOnProcessDeath diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt deleted file mode 100644 index a694247d..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmDelegate.kt +++ /dev/null @@ -1,21 +0,0 @@ -package vivid.money.elmslie.android.screen - -import vivid.money.elmslie.android.storeholder.StoreHolder -import vivid.money.elmslie.core.store.Store - -/** - * Required part of ELM implementation for each fragment - */ -interface ElmDelegate { - - val initEvent: Event - val storeHolder: StoreHolder - - @Deprecated("Use storeHolder property instead") - fun createStore(): Store? = null - fun render(state: State) - fun handleEffect(effect: Effect): Unit? = Unit - - fun mapList(state: State): List = emptyList() - fun renderList(state: State, list: List) {} -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt deleted file mode 100644 index dadb3021..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/screen/ElmScreen.kt +++ /dev/null @@ -1,90 +0,0 @@ -package vivid.money.elmslie.android.screen - -import android.app.Activity -import android.os.Handler -import android.os.Looper -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.State.RESUMED -import androidx.lifecycle.Lifecycle.State.STARTED -import vivid.money.elmslie.android.processdeath.ProcessDeathDetector.isRestoringAfterProcessDeath -import vivid.money.elmslie.android.processdeath.StopElmOnProcessDeath -import vivid.money.elmslie.android.util.bindToState -import vivid.money.elmslie.android.util.onCreate -import vivid.money.elmslie.android.util.postSingle -import vivid.money.elmslie.core.config.ElmslieConfig - -class ElmScreen( - private val delegate: ElmDelegate, - private val screenLifecycle: Lifecycle, - private val activityProvider: () -> Activity, -) { - - val store get() = delegate.storeHolder.store - - private val stateHandler = Handler(Looper.getMainLooper()) - private val effectHandler = Handler(Looper.getMainLooper()) - private val logger = ElmslieConfig.logger - private var isAfterProcessDeath = false - private val isRenderable get() = screenLifecycle.currentState.isAtLeast(STARTED) - - init { - with(screenLifecycle) { - onCreate(::saveProcessDeathState) - onCreate(::triggerInitEventIfNecessary) - bindToState(RESUMED, ::observeEffects) - bindToState(STARTED, ::observeStates) - } - } - - private fun observeEffects() = store.effects { - effectHandler.post { - catchEffectErrors { - delegate.handleEffect(it) - } - } - } - - private fun observeStates() = store.states { - val list = mapListItems(it) - stateHandler.postSingle { renderListItems(it, list) } - } - - private fun mapListItems(state: State) = catchStateErrors { - delegate.mapList(state) - } ?: emptyList() - - private fun renderListItems(state: State, list: List) = catchStateErrors { - if (isRenderable) { - delegate.renderList(state, list) - delegate.render(state) - } - } - - @Suppress("TooGenericExceptionCaught") - private fun catchStateErrors(action: () -> T?) = try { - action() - } catch (t: Throwable) { - logger.fatal("Crash while rendering state", t) - null - } - - @Suppress("TooGenericExceptionCaught") - private fun catchEffectErrors(action: () -> T?) = try { - action() - } catch (t: Throwable) { - logger.fatal("Crash while handling effect", t) - } - - private fun saveProcessDeathState() { - isAfterProcessDeath = isRestoringAfterProcessDeath - } - - private fun triggerInitEventIfNecessary() { - if (!delegate.storeHolder.isStarted && isAllowedToRun()) { - store.accept(delegate.initEvent) - } - } - - private fun isAllowedToRun() = - !isAfterProcessDeath || activityProvider() !is StopElmOnProcessDeath -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt deleted file mode 100644 index 2822e878..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/LifecycleAwareStoreHolder.kt +++ /dev/null @@ -1,25 +0,0 @@ -package vivid.money.elmslie.android.storeholder - -import androidx.lifecycle.* -import vivid.money.elmslie.android.util.fastLazy -import vivid.money.elmslie.core.store.Store - -class LifecycleAwareStoreHolder( - lifecycle: Lifecycle, - storeProvider: () -> Store, -) : StoreHolder { - - override var isStarted = false - - override val store by fastLazy { storeProvider().start().also { isStarted = true } } - - private val lifecycleObserver = object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - store.stop() - } - } - - init { - lifecycle.addObserver(lifecycleObserver) - } -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt deleted file mode 100644 index d4fd2a46..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/storeholder/StoreHolder.kt +++ /dev/null @@ -1,15 +0,0 @@ -package vivid.money.elmslie.android.storeholder - -import vivid.money.elmslie.core.store.Store - -/** - * Implementation of this interface should: - * 1. call Store::start during store creation - * 2. guarantee invariance of store while the view exists - * 3. call Store::stop when store be ready to gc - **/ -interface StoreHolder { - - val isStarted: Boolean - val store: Store -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/HandlerExt.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/util/HandlerExt.kt deleted file mode 100644 index 64d9e9fb..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/HandlerExt.kt +++ /dev/null @@ -1,17 +0,0 @@ -package vivid.money.elmslie.android.util - -import android.os.Handler -import android.os.Looper - -/** - * Runs an action on the handler thread as soon as possible without blocking. - * Ensures that only one action at a time is scheduled for executing. - */ -fun Handler.postSingle(action: () -> Unit) { - removeCallbacksAndMessages(null) - if (looper == Looper.myLooper()) { - action() - } else { - post { action() } - } -} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt b/elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt deleted file mode 100644 index 73097cdc..00000000 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/LifecycleExt.kt +++ /dev/null @@ -1,34 +0,0 @@ -package vivid.money.elmslie.android.util - -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.Event.ON_CREATE -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import vivid.money.elmslie.core.disposable.Disposable - -/** - * Executes a given [action] at [ON_CREATE] event of the provided [Lifecycle] - */ -fun Lifecycle.onCreate( - action: () -> Unit -) = addObserver(object : LifecycleEventObserver { - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == ON_CREATE) action() - } -}) - -/** - * Binds the specified [disposableProvider] to the lifecycle [state] - */ -fun Lifecycle.bindToState( - state: Lifecycle.State, - disposableProvider: () -> Disposable -) = addObserver(object : LifecycleEventObserver { - private var disposable: Disposable? = null - private val startEvent = Lifecycle.Event.upTo(state) - private val endEvent = Lifecycle.Event.downFrom(state) - override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { - if (event == startEvent) disposable = disposableProvider() - if (event == endEvent) disposable?.dispose().also { disposable = null } - } -}) diff --git a/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/ElmStoreLazy.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/ElmStoreLazy.kt new file mode 100644 index 00000000..10bc7594 --- /dev/null +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/ElmStoreLazy.kt @@ -0,0 +1,140 @@ +package money.vivid.elmslie.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.AbstractSavedStateViewModelFactory +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.savedstate.SavedStateRegistryOwner +import money.vivid.elmslie.core.store.Store +import money.vivid.elmslie.core.store.toCachedStore + +/** + * In order to access previously saved state (via [saveState]) in [storeFactory] one must use + * SavedStateHandle.get(StateBundleKey) + */ +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, +> Fragment.elmStore( + key: String = this::class.java.canonicalName ?: this::class.java.simpleName, + viewModelStoreOwner: () -> ViewModelStoreOwner = { this }, + savedStateRegistryOwner: () -> SavedStateRegistryOwner = { this }, + defaultArgs: () -> Bundle = { arguments ?: bundleOf() }, + saveState: Bundle.(State) -> Unit = {}, + storeFactory: SavedStateHandle.() -> Store, +): Lazy> = + money.vivid.elmslie.android.elmStore( + storeFactory = storeFactory, + key = key, + viewModelStoreOwner = viewModelStoreOwner, + savedStateRegistryOwner = savedStateRegistryOwner, + saveState = saveState, + defaultArgs = defaultArgs, + ) + +/** + * In order to access previously saved state (via [saveState]) in [storeFactory] one must use + * SavedStateHandle.get(StateBundleKey) + */ +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, +> ComponentActivity.elmStore( + key: String = this::class.java.canonicalName ?: this::class.java.simpleName, + viewModelStoreOwner: () -> ViewModelStoreOwner = { this }, + savedStateRegistryOwner: () -> SavedStateRegistryOwner = { this }, + defaultArgs: () -> Bundle = { this.intent?.extras ?: bundleOf() }, + saveState: Bundle.(State) -> Unit = {}, + storeFactory: SavedStateHandle.() -> Store, +): Lazy> = + money.vivid.elmslie.android.elmStore( + storeFactory = storeFactory, + key = key, + viewModelStoreOwner = viewModelStoreOwner, + savedStateRegistryOwner = savedStateRegistryOwner, + defaultArgs = defaultArgs, + saveState = saveState, + ) + +@MainThread +internal fun < + Event : Any, + Effect : Any, + State : Any, +> elmStore( + key: String, + viewModelStoreOwner: () -> ViewModelStoreOwner, + savedStateRegistryOwner: () -> SavedStateRegistryOwner, + defaultArgs: () -> Bundle, + saveState: Bundle.(State) -> Unit, + storeFactory: SavedStateHandle.() -> Store, +): Lazy> = + lazy(LazyThreadSafetyMode.NONE) { + val factory = + RetainedElmStoreFactory( + stateRegistryOwner = savedStateRegistryOwner.invoke(), + defaultArgs = defaultArgs.invoke(), + storeFactory = storeFactory, + saveState = saveState, + ) + val provider = ViewModelProvider(viewModelStoreOwner.invoke(), factory) + + @Suppress("UNCHECKED_CAST") + provider[key, RetainedElmStore::class.java].store as Store + } + +class RetainedElmStore( + savedStateHandle: SavedStateHandle, + storeFactory: SavedStateHandle.() -> Store, + saveState: Bundle.(State) -> Unit, +) : ViewModel() { + + val store = storeFactory.invoke(savedStateHandle).toCachedStore().also { it.start() } + + init { + savedStateHandle.setSavedStateProvider(StateBundleKey) { + bundleOf().apply { saveState(store.states.value) } + } + } + + override fun onCleared() { + store.stop() + } + + companion object { + + const val StateBundleKey = "elm_store_state_bundle" + } +} + +class RetainedElmStoreFactory( + stateRegistryOwner: SavedStateRegistryOwner, + defaultArgs: Bundle, + private val storeFactory: SavedStateHandle.() -> Store, + private val saveState: Bundle.(State) -> Unit, +) : AbstractSavedStateViewModelFactory(stateRegistryOwner, defaultArgs) { + + override fun create( + key: String, + modelClass: Class, + handle: SavedStateHandle, + ): T { + @Suppress("UNCHECKED_CAST") + return RetainedElmStore( + savedStateHandle = handle, + storeFactory = storeFactory, + saveState = saveState, + ) + as T + } +} diff --git a/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/DefaultLoggerConfigurations.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/DefaultLoggerConfigurations.kt new file mode 100644 index 00000000..9560446f --- /dev/null +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/DefaultLoggerConfigurations.kt @@ -0,0 +1,18 @@ +package money.vivid.elmslie.android.logger + +import money.vivid.elmslie.android.logger.strategy.AndroidLog +import money.vivid.elmslie.android.logger.strategy.Crash +import money.vivid.elmslie.core.config.ElmslieConfig +import money.vivid.elmslie.core.logger.strategy.IgnoreLog + +fun ElmslieConfig.defaultReleaseLogger() = logger { + fatal(Crash) + nonfatal(IgnoreLog) + debug(IgnoreLog) +} + +fun ElmslieConfig.defaultDebugLogger() = logger { + fatal(Crash) + nonfatal(AndroidLog.E) + debug(AndroidLog.E) +} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/DefaultLoggerInitializer.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/DefaultLoggerInitializer.kt similarity index 83% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/logger/DefaultLoggerInitializer.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/DefaultLoggerInitializer.kt index d55de519..2ee33437 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/DefaultLoggerInitializer.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/DefaultLoggerInitializer.kt @@ -1,9 +1,9 @@ -package vivid.money.elmslie.android.logger +package money.vivid.elmslie.android.logger import android.content.Context import android.content.pm.ApplicationInfo import androidx.startup.Initializer -import vivid.money.elmslie.core.config.ElmslieConfig +import money.vivid.elmslie.core.config.ElmslieConfig class DefaultLoggerInitializer : Initializer { diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/EmptyContentProvider.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/EmptyContentProvider.kt similarity index 95% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/logger/EmptyContentProvider.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/EmptyContentProvider.kt index de8813e6..d479d3b7 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/EmptyContentProvider.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/EmptyContentProvider.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.android.logger +package money.vivid.elmslie.android.logger import android.content.ContentProvider import android.content.ContentValues diff --git a/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/strategy/AndroidLog.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/strategy/AndroidLog.kt new file mode 100644 index 00000000..57f594e4 --- /dev/null +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/strategy/AndroidLog.kt @@ -0,0 +1,18 @@ +package money.vivid.elmslie.android.logger.strategy + +import android.util.Log +import money.vivid.elmslie.core.logger.strategy.LogStrategy + +/** Uses default android logging mechanism for reporting */ +object AndroidLog { + + val E = log(Log::e) + val W = log(Log::w) + val I = log(Log::i) + val D = log(Log::d) + val V = log(Log::v) + + private fun log( + log: (tag: String?, message: String?, throwable: Throwable?) -> Unit, + ) = LogStrategy { _, tag, message, error -> log(tag, message, error) } +} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/strategy/Crash.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/strategy/Crash.kt similarity index 54% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/logger/strategy/Crash.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/strategy/Crash.kt index 3c440ec5..b5709387 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/logger/strategy/Crash.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/logger/strategy/Crash.kt @@ -1,20 +1,20 @@ -package vivid.money.elmslie.android.logger.strategy +package money.vivid.elmslie.android.logger.strategy import android.os.Handler import android.os.Looper import android.os.Message -import vivid.money.elmslie.core.logger.LogSeverity -import vivid.money.elmslie.core.logger.strategy.LogStrategy +import money.vivid.elmslie.core.logger.LogSeverity +import money.vivid.elmslie.core.logger.strategy.LogStrategy /** Strategy that performs a crash on every log event it receives. Use wisely. */ object Crash : LogStrategy { private val errorHandler = Handler(Looper.getMainLooper()) { throw it.obj as Throwable } - override fun invoke(severity: LogSeverity, message: String, error: Throwable?) { + override fun log(severity: LogSeverity, tag: String?, message: String, throwable: Throwable?) { errorHandler.sendMessage( Message().apply { - obj = error ?: Exception(message) + obj = throwable ?: Exception(message) } ) } diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/EmptyActivityLifecycleCallbacks.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/EmptyActivityLifecycleCallbacks.kt similarity index 93% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/EmptyActivityLifecycleCallbacks.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/EmptyActivityLifecycleCallbacks.kt index fa72702c..93b9cddc 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/EmptyActivityLifecycleCallbacks.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/EmptyActivityLifecycleCallbacks.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.android.processdeath +package money.vivid.elmslie.android.processdeath import android.app.Activity import android.app.Application diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/ProcessDeathDetector.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/ProcessDeathDetector.kt similarity index 94% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/ProcessDeathDetector.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/ProcessDeathDetector.kt index 83291006..90a7b6b8 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/ProcessDeathDetector.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/ProcessDeathDetector.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.android.processdeath +package money.vivid.elmslie.android.processdeath import android.app.Activity import android.app.Application diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/ProcessDeathDetectorInitializer.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/ProcessDeathDetectorInitializer.kt similarity index 88% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/ProcessDeathDetectorInitializer.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/ProcessDeathDetectorInitializer.kt index be3ed2b4..e1e6d455 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/processdeath/ProcessDeathDetectorInitializer.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/processdeath/ProcessDeathDetectorInitializer.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.android.processdeath +package money.vivid.elmslie.android.processdeath import android.app.Application import android.content.Context diff --git a/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/renderer/ElmRenderer.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/renderer/ElmRenderer.kt new file mode 100644 index 00000000..945c4016 --- /dev/null +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/renderer/ElmRenderer.kt @@ -0,0 +1,93 @@ +package money.vivid.elmslie.android.renderer + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.flowWithLifecycle +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import money.vivid.elmslie.core.config.ElmslieConfig +import money.vivid.elmslie.core.store.Store + +internal class ElmRenderer( + private val store: Store<*, Effect, State>, + private val delegate: ElmRendererDelegate, + private val lifecycle: Lifecycle, +) { + + private val logger = ElmslieConfig.logger + private val ioDispatcher: CoroutineDispatcher = ElmslieConfig.ioDispatchers + private val canRender + get() = lifecycle.currentState.isAtLeast(STARTED) + + init { + with(lifecycle) { + coroutineScope.launch { + store + .effects + .flowWithLifecycle( + lifecycle = lifecycle, + minActiveState = RESUMED, + ) + .collect { effect -> catchEffectErrors { delegate.handleEffect(effect) } } + } + coroutineScope.launch { + store + .states + .flowWithLifecycle( + lifecycle = lifecycle, + minActiveState = STARTED, + ) + .map { state -> + val list = mapListItems(state) + state to list + } + .catch { + logger.fatal( + message = "Crash while mapping state", + error = it, + ) + } + .flowOn(ioDispatcher) + .collect { (state, listItems) -> + catchStateErrors { + if (canRender) { + delegate.renderList(state, listItems) + delegate.render(state) + } + } + } + } + } + } + + private fun mapListItems(state: State) = + catchStateErrors { delegate.mapList(state) } ?: emptyList() + + @Suppress("TooGenericExceptionCaught") + private fun catchStateErrors(action: () -> T?) = + try { + action() + } catch (t: Throwable) { + logger.fatal( + message = "Crash while rendering state", + error = t, + ) + null + } + + @Suppress("TooGenericExceptionCaught") + private fun catchEffectErrors(action: () -> T?) = + try { + action() + } catch (t: Throwable) { + logger.fatal( + message = "Crash while handling effect", + error = t, + ) + } +} diff --git a/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/renderer/ElmRendererDelegate.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/renderer/ElmRendererDelegate.kt new file mode 100644 index 00000000..2b0fc750 --- /dev/null +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/renderer/ElmRendererDelegate.kt @@ -0,0 +1,117 @@ +package money.vivid.elmslie.android.renderer + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.annotation.MainThread +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withCreated +import androidx.savedstate.SavedStateRegistryOwner +import kotlinx.coroutines.launch +import money.vivid.elmslie.android.elmStore +import money.vivid.elmslie.core.store.Store + +@Suppress("OptionalUnit") +interface ElmRendererDelegate { + fun render(state: State) + fun handleEffect(effect: Effect): Unit? = Unit + fun mapList(state: State): List = emptyList() + fun renderList(state: State, list: List): Unit = Unit +} + +/** + * The function makes a connection between the store and the lifecycle owner by collecting states and effects + * and calling corresponds callbacks. + * + * Store creates and connects all required entities when given lifecycle reached CREATED state. + * + * In order to access previously saved state (via [saveState]) in [storeFactory] one must use + * SavedStateHandle.get(StateBundleKey) + * + * NOTE: If you implement your own ElmRendererDelegate, you should also implement the following interfaces: + * [ViewModelStoreOwner], [SavedStateRegistryOwner], [LifecycleOwner]. + */ +@Suppress("LongParameterList") +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, + > ElmRendererDelegate.androidElmStore( + key: String = this::class.java.canonicalName ?: this::class.java.simpleName, + defaultArgs: () -> Bundle = { + val args = when (this) { + is Fragment -> arguments + is ComponentActivity -> intent.extras + else -> null + } + args ?: bundleOf() + }, + saveState: Bundle.(State) -> Unit = {}, + storeFactory: SavedStateHandle.() -> Store, +): Lazy> { + require(this is ViewModelStoreOwner) { + "Should implement [ViewModelStoreOwner]" + } + require(this is SavedStateRegistryOwner) { + "Should implement [SavedStateRegistryOwner]" + } + return androidElmStore( + key = key, + viewModelStoreOwner = { this }, + savedStateRegistryOwner = { this }, + defaultArgs = defaultArgs, + saveState = saveState, + storeFactory = storeFactory, + ) +} + +@Suppress("LongParameterList") +@MainThread +fun < + Event : Any, + Effect : Any, + State : Any, + > ElmRendererDelegate.androidElmStore( + key: String = this::class.java.canonicalName ?: this::class.java.simpleName, + viewModelStoreOwner: () -> ViewModelStoreOwner, + savedStateRegistryOwner: () -> SavedStateRegistryOwner, + defaultArgs: () -> Bundle = { + val args = when (this) { + is Fragment -> arguments + is ComponentActivity -> intent.extras + else -> null + } + args ?: bundleOf() + }, + saveState: Bundle.(State) -> Unit = {}, + storeFactory: SavedStateHandle.() -> Store, +): Lazy> { + require(this is LifecycleOwner) { + "Should implement [LifecycleOwner]" + } + val lazyStore = elmStore( + storeFactory = storeFactory, + key = key, + viewModelStoreOwner = viewModelStoreOwner, + savedStateRegistryOwner = savedStateRegistryOwner, + saveState = saveState, + defaultArgs = defaultArgs, + ) + with(this) { + lifecycleScope.launch { + withCreated { + ElmRenderer( + store = lazyStore.value, + delegate = this@with, + lifecycle = lifecycle, + ) + } + } + } + return lazyStore +} diff --git a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/FastLazy.kt b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/util/FastLazy.kt similarity index 79% rename from elmslie-android/src/main/java/vivid/money/elmslie/android/util/FastLazy.kt rename to elmslie-android/src/main/kotlin/money/vivid/elmslie/android/util/FastLazy.kt index 8296ca18..1e6667fc 100644 --- a/elmslie-android/src/main/java/vivid/money/elmslie/android/util/FastLazy.kt +++ b/elmslie-android/src/main/kotlin/money/vivid/elmslie/android/util/FastLazy.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.android.util +package money.vivid.elmslie.android.util /** * Lazy initialization without synchronization diff --git a/elmslie-compose/build.gradle b/elmslie-compose/build.gradle deleted file mode 100644 index ccbbd32d..00000000 --- a/elmslie-compose/build.gradle +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - id("com.android.library") - id("kotlin-android") -} - -android { - buildFeatures.compose = true - composeOptions.kotlinCompilerExtensionVersion = versions.compose -} - -dependencies { - implementation(project(":elmslie-android")) - implementation(project(":elmslie-core")) - - implementation(deps.android.appcompat) - - implementation(deps.compose.foundation) -} - -apply from: "../gradle/junit-5.gradle" -apply from: "../gradle/android-library.gradle" -apply from: "../gradle/android-publishing.gradle" -apply from: "../gradle/android-lint.gradle" -apply from: "../gradle/detekt.gradle" diff --git a/elmslie-compose/src/main/AndroidManifest.xml b/elmslie-compose/src/main/AndroidManifest.xml deleted file mode 100644 index 8868176c..00000000 --- a/elmslie-compose/src/main/AndroidManifest.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt deleted file mode 100644 index efd49bec..00000000 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/EffectWithKey.kt +++ /dev/null @@ -1,12 +0,0 @@ -package vivid.money.elmslie.compose - -import androidx.compose.runtime.Stable - -// Not a data class intentionally -@Stable -class EffectWithKey(val value: T) { - val key = this - - @Suppress("UNCHECKED_CAST") - inline fun takeIfInstanceOf() = takeIf { value is R } as EffectWithKey? -} diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt deleted file mode 100644 index 9e9fb7ab..00000000 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentActivity.kt +++ /dev/null @@ -1,32 +0,0 @@ -package vivid.money.elmslie.compose - -import androidx.activity.ComponentActivity -import androidx.annotation.LayoutRes -import androidx.compose.runtime.Composable -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.compose.util.subscribeAsState - -abstract class ElmComponentActivity : - ComponentActivity, ElmDelegate { - - constructor() : super() - - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - - @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { this } - - val store get() = storeHolder.store - - override val storeHolder = LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - - @Composable - fun state() = store::states.subscribeAsState(initial = store.currentState) - - @Composable - fun effect() = store::effects.subscribeAsState(::EffectWithKey, initial = null) - - final override fun render(state: State) = Unit -} diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt deleted file mode 100644 index eadb94e0..00000000 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/ElmComponentFragment.kt +++ /dev/null @@ -1,33 +0,0 @@ -package vivid.money.elmslie.compose - -import androidx.annotation.LayoutRes -import androidx.compose.runtime.* -import androidx.fragment.app.Fragment -import vivid.money.elmslie.android.screen.ElmDelegate -import vivid.money.elmslie.android.screen.ElmScreen -import vivid.money.elmslie.android.storeholder.LifecycleAwareStoreHolder -import vivid.money.elmslie.compose.util.subscribeAsState - -abstract class ElmComponentFragment : Fragment, - ElmDelegate { - - constructor() : super() - - constructor(@LayoutRes contentLayoutId: Int) : super(contentLayoutId) - - @Suppress("LeakingThis", "UnusedPrivateMember") - private val elm = ElmScreen(this, lifecycle) { requireActivity() } - - protected val store - get() = storeHolder.store - - override val storeHolder = LifecycleAwareStoreHolder(lifecycle) { createStore()!! } - - final override fun render(state: State) = Unit - - @Composable - fun state() = store::states.subscribeAsState(initial = store.currentState) - - @Composable - fun effect() = store::effects.subscribeAsState(::EffectWithKey, initial = null) -} diff --git a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt b/elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt deleted file mode 100644 index ba940e67..00000000 --- a/elmslie-compose/src/main/java/vivid/money/elmslie/compose/util/SubscribeAsState.kt +++ /dev/null @@ -1,28 +0,0 @@ -package vivid.money.elmslie.compose.util - -import androidx.compose.runtime.* -import vivid.money.elmslie.core.disposable.Disposable - -/** - * Subscribes to the callback and represents it's values via [State] - */ -@Composable -fun (((T) -> Unit) -> Disposable).subscribeAsState( - initial: T -): State = subscribeAsState({ it }, initial = initial) - -/** - * Subscribes to the callback and represents it's values via [State] with applied [transformation] - */ -@Composable -fun (((T) -> Unit) -> Disposable).subscribeAsState( - transformation: (T) -> V, - initial: V -): State { - val state = remember { mutableStateOf(initial) } - DisposableEffect(this) { - val disposable = this@subscribeAsState { state.value = transformation(it) } - onDispose { disposable.dispose() } - } - return state -} diff --git a/elmslie-core/api/elmslie-core.api b/elmslie-core/api/elmslie-core.api new file mode 100644 index 00000000..efd680f5 --- /dev/null +++ b/elmslie-core/api/elmslie-core.api @@ -0,0 +1,184 @@ +public final class money/vivid/elmslie/core/ElmScopeKt { + public static final fun ElmScope (Ljava/lang/String;)Lkotlinx/coroutines/CoroutineScope; +} + +public final class money/vivid/elmslie/core/config/ElmslieConfig { + public static final field INSTANCE Lmoney/vivid/elmslie/core/config/ElmslieConfig; + public final fun getGlobalStoreListeners ()Ljava/util/Set; + public final fun getIoDispatchers ()Lkotlinx/coroutines/CoroutineDispatcher; + public final fun getLogger ()Lmoney/vivid/elmslie/core/logger/ElmslieLogger; + public final fun getShouldStopOnProcessDeath ()Z + public final fun globalStoreListeners (Lkotlin/jvm/functions/Function0;)V + public final fun ioDispatchers (Lkotlin/jvm/functions/Function0;)V + public final fun logger (Lkotlin/jvm/functions/Function1;)V + public final fun shouldStopOnProcessDeath (Lkotlin/jvm/functions/Function0;)V +} + +public final class money/vivid/elmslie/core/logger/ElmslieLogConfiguration { + public fun ()V + public final fun always (Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy;)Lmoney/vivid/elmslie/core/logger/ElmslieLogConfiguration; + public final fun debug (Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy;)Lmoney/vivid/elmslie/core/logger/ElmslieLogConfiguration; + public final fun fatal (Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy;)Lmoney/vivid/elmslie/core/logger/ElmslieLogConfiguration; + public final fun nonfatal (Lmoney/vivid/elmslie/core/logger/strategy/LogStrategy;)Lmoney/vivid/elmslie/core/logger/ElmslieLogConfiguration; +} + +public final class money/vivid/elmslie/core/logger/ElmslieLogger { + public fun (Ljava/util/Map;)V + public final fun debug (Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun debug$default (Lmoney/vivid/elmslie/core/logger/ElmslieLogger;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public final fun fatal (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun fatal$default (Lmoney/vivid/elmslie/core/logger/ElmslieLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V + public final fun nonfatal (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V + public static synthetic fun nonfatal$default (Lmoney/vivid/elmslie/core/logger/ElmslieLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;ILjava/lang/Object;)V +} + +public final class money/vivid/elmslie/core/logger/LogSeverity : java/lang/Enum { + public static final field Debug Lmoney/vivid/elmslie/core/logger/LogSeverity; + public static final field Fatal Lmoney/vivid/elmslie/core/logger/LogSeverity; + public static final field NonFatal Lmoney/vivid/elmslie/core/logger/LogSeverity; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lmoney/vivid/elmslie/core/logger/LogSeverity; + public static fun values ()[Lmoney/vivid/elmslie/core/logger/LogSeverity; +} + +public final class money/vivid/elmslie/core/logger/strategy/IgnoreLog : money/vivid/elmslie/core/logger/strategy/LogStrategy { + public static final field INSTANCE Lmoney/vivid/elmslie/core/logger/strategy/IgnoreLog; + public fun log (Lmoney/vivid/elmslie/core/logger/LogSeverity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V +} + +public abstract interface class money/vivid/elmslie/core/logger/strategy/LogStrategy { + public abstract fun log (Lmoney/vivid/elmslie/core/logger/LogSeverity;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Throwable;)V +} + +public abstract class money/vivid/elmslie/core/store/Actor { + public fun ()V + protected final fun asSwitchFlow (Lkotlinx/coroutines/flow/Flow;Ljava/lang/Object;J)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun asSwitchFlow$default (Lmoney/vivid/elmslie/core/store/Actor;Lkotlinx/coroutines/flow/Flow;Ljava/lang/Object;JILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + protected final fun cancelSwitchFlow (Lkotlin/reflect/KClass;)Lkotlinx/coroutines/flow/Flow; + public abstract fun execute (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public final fun mapEvents (Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun mapEvents$default (Lmoney/vivid/elmslie/core/store/Actor;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class money/vivid/elmslie/core/store/EffectCachingElmStore : money/vivid/elmslie/core/store/Store { + public fun (Lmoney/vivid/elmslie/core/store/Store;)V + public fun accept (Ljava/lang/Object;)V + public fun getEffects ()Lkotlinx/coroutines/flow/Flow; + public fun getScope ()Lkotlinx/coroutines/CoroutineScope; + public fun getStartEvent ()Ljava/lang/Object; + public fun getStates ()Lkotlinx/coroutines/flow/StateFlow; + public fun start ()Lmoney/vivid/elmslie/core/store/Store; + public fun stop ()V +} + +public final class money/vivid/elmslie/core/store/ElmStore : money/vivid/elmslie/core/store/Store { + public fun (Ljava/lang/Object;Lmoney/vivid/elmslie/core/store/StateReducer;Lmoney/vivid/elmslie/core/store/Actor;Ljava/util/Set;Ljava/lang/Object;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Object;Lmoney/vivid/elmslie/core/store/StateReducer;Lmoney/vivid/elmslie/core/store/Actor;Ljava/util/Set;Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun accept (Ljava/lang/Object;)V + public fun getEffects ()Lkotlinx/coroutines/flow/Flow; + public fun getScope ()Lkotlinx/coroutines/CoroutineScope; + public fun getStartEvent ()Ljava/lang/Object; + public fun getStates ()Lkotlinx/coroutines/flow/StateFlow; + public fun start ()Lmoney/vivid/elmslie/core/store/Store; + public fun stop ()V +} + +public final class money/vivid/elmslie/core/store/ElmStoreKt { + public static final fun toCachedStore (Lmoney/vivid/elmslie/core/store/Store;)Lmoney/vivid/elmslie/core/store/EffectCachingElmStore; +} + +public final class money/vivid/elmslie/core/store/NoOpActor : money/vivid/elmslie/core/store/Actor { + public fun ()V + public fun execute (Ljava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + +public final class money/vivid/elmslie/core/store/NoOpReducer : money/vivid/elmslie/core/store/StateReducer { + public fun ()V +} + +public final class money/vivid/elmslie/core/store/Result { + public fun (Ljava/lang/Object;)V + public fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V + public synthetic fun (Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/Object;Ljava/util/List;)V + public fun (Ljava/lang/Object;Ljava/util/List;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/Object; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/List; + public final fun copy (Ljava/lang/Object;Ljava/util/List;Ljava/util/List;)Lmoney/vivid/elmslie/core/store/Result; + public static synthetic fun copy$default (Lmoney/vivid/elmslie/core/store/Result;Ljava/lang/Object;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lmoney/vivid/elmslie/core/store/Result; + public fun equals (Ljava/lang/Object;)Z + public final fun getCommands ()Ljava/util/List; + public final fun getEffects ()Ljava/util/List; + public final fun getState ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract class money/vivid/elmslie/core/store/ScreenReducer : money/vivid/elmslie/core/store/StateReducer { + public fun (Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;)V + protected abstract fun internal (Lmoney/vivid/elmslie/core/store/StateReducer$Result;Ljava/lang/Object;)Ljava/lang/Object; + protected fun reduce (Lmoney/vivid/elmslie/core/store/StateReducer$Result;Ljava/lang/Object;)V + protected abstract fun ui (Lmoney/vivid/elmslie/core/store/StateReducer$Result;Ljava/lang/Object;)Ljava/lang/Object; +} + +public abstract class money/vivid/elmslie/core/store/StateReducer { + public fun ()V + public final fun reduce (Ljava/lang/Object;Ljava/lang/Object;)Lmoney/vivid/elmslie/core/store/Result; + protected abstract fun reduce (Lmoney/vivid/elmslie/core/store/StateReducer$Result;Ljava/lang/Object;)V +} + +protected final class money/vivid/elmslie/core/store/StateReducer$Result : money/vivid/elmslie/core/store/dsl/ResultBuilder { + public fun (Lmoney/vivid/elmslie/core/store/StateReducer;Ljava/lang/Object;)V +} + +public abstract interface class money/vivid/elmslie/core/store/Store { + public abstract fun accept (Ljava/lang/Object;)V + public abstract fun getEffects ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getScope ()Lkotlinx/coroutines/CoroutineScope; + public abstract fun getStartEvent ()Ljava/lang/Object; + public abstract fun getStates ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun start ()Lmoney/vivid/elmslie/core/store/Store; + public abstract fun stop ()V +} + +public abstract interface class money/vivid/elmslie/core/store/StoreListener { + public abstract fun onActorError (Ljava/lang/String;Ljava/lang/Throwable;Ljava/lang/Object;)V + public abstract fun onAfterEvent (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V + public abstract fun onBeforeEvent (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V + public abstract fun onCommand (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V + public abstract fun onEffect (Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V + public abstract fun onReducerError (Ljava/lang/String;Ljava/lang/Throwable;Ljava/lang/Object;)V +} + +public final class money/vivid/elmslie/core/store/StoreListener$DefaultImpls { + public static fun onActorError (Lmoney/vivid/elmslie/core/store/StoreListener;Ljava/lang/String;Ljava/lang/Throwable;Ljava/lang/Object;)V + public static fun onAfterEvent (Lmoney/vivid/elmslie/core/store/StoreListener;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)V + public static fun onBeforeEvent (Lmoney/vivid/elmslie/core/store/StoreListener;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V + public static fun onCommand (Lmoney/vivid/elmslie/core/store/StoreListener;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V + public static fun onEffect (Lmoney/vivid/elmslie/core/store/StoreListener;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)V + public static fun onReducerError (Lmoney/vivid/elmslie/core/store/StoreListener;Ljava/lang/String;Ljava/lang/Throwable;Ljava/lang/Object;)V +} + +public final class money/vivid/elmslie/core/store/dsl/OperationsBuilder { + public fun ()V + public final fun unaryPlus (Ljava/lang/Object;)V +} + +public class money/vivid/elmslie/core/store/dsl/ResultBuilder { + public fun (Ljava/lang/Object;)V + public final fun commands (Lkotlin/jvm/functions/Function1;)V + public final fun effects (Lkotlin/jvm/functions/Function1;)V + public final fun getInitialState ()Ljava/lang/Object; + public final fun getState ()Ljava/lang/Object; + public final fun state (Lkotlin/jvm/functions/Function1;)V +} + +public final class money/vivid/elmslie/core/switcher/Switcher { + public fun ()V + public final fun cancel (J)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun cancel$default (Lmoney/vivid/elmslie/core/switcher/Switcher;JILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public final fun switch (JLkotlin/jvm/functions/Function0;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun switch$default (Lmoney/vivid/elmslie/core/switcher/Switcher;JLkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + diff --git a/elmslie-core/build.gradle b/elmslie-core/build.gradle deleted file mode 100644 index 130380bf..00000000 --- a/elmslie-core/build.gradle +++ /dev/null @@ -1,15 +0,0 @@ -plugins { - id("kotlin") -} - -dependencies { - testImplementation(project(":elmslie-test")) - testImplementation(deps.test.kotestAssertions) - testImplementation(deps.test.kotestProperty) - testRuntimeOnly(deps.test.kotestJunitRunner) -} - -apply from: "../gradle/junit-5.gradle" -apply from: "../gradle/kotlin-publishing.gradle" -apply from: "../gradle/detekt.gradle" -apply from: "../gradle/android-lint.gradle" diff --git a/elmslie-core/build.gradle.kts b/elmslie-core/build.gradle.kts new file mode 100644 index 00000000..2039c0c8 --- /dev/null +++ b/elmslie-core/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("elmslie.kotlin-multiplatform-lib") + id("elmslie.publishing") + alias(libs.plugins.binaryCompatibilityValidator) +} + +elmsliePublishing { + pom { + name = "Elmslie core" + description = "Elmslie is a minimalistic reactive implementation of TEA/ELM" + } +} + +kotlin { + sourceSets { + val commonMain by getting { + dependencies { + implementation(libs.kotlinx.coroutinesCore) + } + } + val commonTest by getting { + dependencies { + implementation(libs.kotlinx.coroutinesTest) + implementation(libs.kotlin.test) + } + } + } +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/ElmScope.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/ElmScope.kt new file mode 100644 index 00000000..4efd69c8 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/ElmScope.kt @@ -0,0 +1,19 @@ +package money.vivid.elmslie.core + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import money.vivid.elmslie.core.config.ElmslieConfig + +@Suppress("detekt.FunctionNaming") +fun ElmScope(name: String): CoroutineScope = + CoroutineScope( + context = + ElmslieConfig.ioDispatchers + + SupervisorJob() + + CoroutineName(name) + + CoroutineExceptionHandler { _, throwable -> + ElmslieConfig.logger.fatal("Unhandled error: $throwable") + }, + ) diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/config/ElmslieConfig.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/config/ElmslieConfig.kt new file mode 100644 index 00000000..010c2c9f --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/config/ElmslieConfig.kt @@ -0,0 +1,61 @@ +package money.vivid.elmslie.core.config + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import money.vivid.elmslie.core.logger.ElmslieLogConfiguration +import money.vivid.elmslie.core.logger.ElmslieLogger +import money.vivid.elmslie.core.logger.strategy.IgnoreLog +import money.vivid.elmslie.core.store.StoreListener +import money.vivid.elmslie.core.utils.IoDispatcher +import kotlin.concurrent.Volatile + +object ElmslieConfig { + + @Volatile + var logger: ElmslieLogger = ElmslieLogConfiguration().apply { always(IgnoreLog) }.build() + private set + + @Volatile + var ioDispatchers: CoroutineDispatcher = IoDispatcher + private set + + @Volatile + var shouldStopOnProcessDeath: Boolean = true + private set + + @Volatile + var globalStoreListeners: Set> = emptySet() + private set + + /** + * Configures logging and error handling + * + * Example: + * ``` + * ElmslieConfig.logger { + * fatal(Crash) + * nonfatal(AndroidLog) + * debug(Ignore) + * } + * ``` + */ + fun logger(config: (ElmslieLogConfiguration.() -> Unit)) { + ElmslieLogConfiguration().apply(config).build().also { logger = it } + } + + /** + * Configures CoroutineDispatcher for performing operations in background. Default is + * [Dispatchers.IO] + */ + fun ioDispatchers(builder: () -> CoroutineDispatcher) { + ioDispatchers = builder() + } + + fun shouldStopOnProcessDeath(builder: () -> Boolean) { + shouldStopOnProcessDeath = builder() + } + + fun globalStoreListeners(builder: () -> Set>) { + globalStoreListeners = builder() + } +} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/ElmslieLogConfiguration.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/ElmslieLogConfiguration.kt similarity index 82% rename from elmslie-core/src/main/java/vivid/money/elmslie/core/logger/ElmslieLogConfiguration.kt rename to elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/ElmslieLogConfiguration.kt index 38788d7d..c3220428 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/ElmslieLogConfiguration.kt +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/ElmslieLogConfiguration.kt @@ -1,6 +1,6 @@ -package vivid.money.elmslie.core.logger +package money.vivid.elmslie.core.logger -import vivid.money.elmslie.core.logger.strategy.LogStrategy +import money.vivid.elmslie.core.logger.strategy.LogStrategy class ElmslieLogConfiguration { @@ -23,7 +23,7 @@ class ElmslieLogConfiguration { /** Apply the same logging strategy to all log levels */ fun always(strategy: LogStrategy) = apply { - LogSeverity.values().forEach { strategies[it] = strategy } + LogSeverity.entries.forEach { strategies[it] = strategy } } internal fun build() = ElmslieLogger(strategies) diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/ElmslieLogger.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/ElmslieLogger.kt new file mode 100644 index 00000000..a59a40c1 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/ElmslieLogger.kt @@ -0,0 +1,53 @@ +package money.vivid.elmslie.core.logger + +import money.vivid.elmslie.core.logger.strategy.IgnoreLog +import money.vivid.elmslie.core.logger.strategy.LogStrategy + +/** Logs events happening in the Elmslie library */ +class ElmslieLogger( + private val strategy: Map, +) { + + fun fatal( + message: String = "", + tag: String? = null, + error: Throwable? = null, + ) = + handle( + severity = LogSeverity.Fatal, + message = message, + tag = tag, + error = error, + ) + + fun nonfatal( + message: String = "", + tag: String? = null, + error: Throwable? = null, + ) = + handle( + severity = LogSeverity.NonFatal, + message, + tag = tag, + error = error, + ) + + fun debug( + message: String, + tag: String? = null, + ) = handle( + severity = LogSeverity.Debug, + message, + tag = tag, + error = null, + ) + + private fun handle(severity: LogSeverity, message: String, tag: String?, error: Throwable?) { + (strategy[severity] ?: IgnoreLog).log( + severity = severity, + message = message, + tag = tag, + throwable = error, + ) + } +} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/LogSeverity.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/LogSeverity.kt similarity index 61% rename from elmslie-core/src/main/java/vivid/money/elmslie/core/logger/LogSeverity.kt rename to elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/LogSeverity.kt index 75e85339..18d75ee7 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/LogSeverity.kt +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/LogSeverity.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.core.logger +package money.vivid.elmslie.core.logger enum class LogSeverity { Fatal, diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/strategy/IgnoreLog.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/strategy/IgnoreLog.kt new file mode 100644 index 00000000..618c131c --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/strategy/IgnoreLog.kt @@ -0,0 +1,4 @@ +package money.vivid.elmslie.core.logger.strategy + +/** Ignores all log events */ +object IgnoreLog : LogStrategy by LogStrategy({ _, _, _, _ -> }) diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/strategy/LogStrategy.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/strategy/LogStrategy.kt new file mode 100644 index 00000000..b41cb3fa --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/logger/strategy/LogStrategy.kt @@ -0,0 +1,8 @@ +package money.vivid.elmslie.core.logger.strategy + +import money.vivid.elmslie.core.logger.LogSeverity + +/** Allows to provide custom logic for error handling */ +fun interface LogStrategy { + fun log(severity: LogSeverity, tag: String?, message: String, throwable: Throwable?) +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Actor.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Actor.kt new file mode 100644 index 00000000..62db5663 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Actor.kt @@ -0,0 +1,46 @@ +package money.vivid.elmslie.core.store + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import money.vivid.elmslie.core.switcher.Switcher +import kotlin.reflect.KClass + +abstract class Actor { + + private val switchers = mutableMapOf, Switcher>() + private val mutex = Mutex() + + /** + * Executes a command. This method is performed on the [Dispatchers.IO] + * [kotlinx.coroutines.Dispatchers.IO] which is set by ElmslieConfig.ioDispatchers() + */ + abstract fun execute(command: Command): Flow + + fun Flow.mapEvents( + eventMapper: (T) -> Event? = { null }, + errorMapper: (error: Throwable) -> Event? = { null }, + ) = mapNotNull { eventMapper(it) } + .catch { errorMapper(it)?.let { event -> emit(event) } ?: throw it } + + protected fun Flow.asSwitchFlow(command: Command, delayMillis: Long = 0): Flow { + return flow { + val switcher = mutex.withLock { + switchers.getOrPut(command::class) { + Switcher() + } + } + switcher.switch(delayMillis) { this@asSwitchFlow }.collect { + emit(it) + } + } + } + + protected fun cancelSwitchFlow(command: KClass): Flow { + return switchers[command]?.cancel() ?: emptyFlow() + } +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/EffectCachingElmStore.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/EffectCachingElmStore.kt new file mode 100644 index 00000000..37e1f8df --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/EffectCachingElmStore.kt @@ -0,0 +1,59 @@ +package money.vivid.elmslie.core.store + +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.onSubscription +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import money.vivid.elmslie.core.ElmScope + +/** + * Caches effects until there is at least one collector. + * + * Note, that effects from the cache are replayed only for the first one. + * + * Wrap the store with the instance of [EffectCachingElmStore] to get the desired behavior like this: + * ``` + * ``` + */ +// TODO Should be moved to android artifact? +class EffectCachingElmStore( + private val elmStore: Store, +) : Store by elmStore { + + private val effectsMutex = Mutex() + private val effectsCache = mutableListOf() + private val effectsFlow = MutableSharedFlow() + private val storeScope = ElmScope("CachedStoreScope") + + init { + storeScope.launch { + elmStore.effects.collect { effect -> + if (effectsFlow.subscriptionCount.value > 0) { + effectsFlow.emit(effect) + } else { + effectsMutex.withLock { + effectsCache.add(effect) + } + } + } + } + } + + override fun stop() { + elmStore.stop() + storeScope.cancel() + } + + override val effects: Flow = + effectsFlow.onSubscription { + effectsMutex.withLock { + for (effect in effectsCache) { + emit(effect) + } + effectsCache.clear() + } + } +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/ElmStore.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/ElmStore.kt new file mode 100644 index 00000000..09625bbc --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/ElmStore.kt @@ -0,0 +1,131 @@ +package money.vivid.elmslie.core.store + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import money.vivid.elmslie.core.ElmScope +import money.vivid.elmslie.core.config.ElmslieConfig +import money.vivid.elmslie.core.utils.resolveStoreKey + +@Suppress("TooGenericExceptionCaught") +@OptIn(ExperimentalCoroutinesApi::class) +class ElmStore( + initialState: State, + private val reducer: StateReducer, + private val actor: Actor, + storeListeners: Set>? = null, + override val startEvent: Event? = null, + private val key: String = resolveStoreKey(reducer), +) : Store { + + private val logger = ElmslieConfig.logger + private val eventDispatcher = ElmslieConfig.ioDispatchers.limitedParallelism(parallelism = 1) + + private val effectsFlow = MutableSharedFlow() + + private val statesFlow: MutableStateFlow = MutableStateFlow(initialState) + + private val storeListeners: MutableSet> = + mutableSetOf>().apply { + ElmslieConfig.globalStoreListeners.forEach(::add) + storeListeners?.forEach(::add) + } + + override val scope = ElmScope("${key}Scope") + + override val states: StateFlow = statesFlow.asStateFlow() + + override val effects: Flow = effectsFlow.asSharedFlow() + + override fun accept(event: Event) { + scope.handleEvent(event) + } + + override fun start(): Store { + startEvent?.let(::accept) + return this + } + + override fun stop() { + scope.cancel() + } + + private fun CoroutineScope.handleEvent(event: Event) = launch(eventDispatcher) { + try { + storeListeners.forEach { it.onBeforeEvent(key, event, statesFlow.value) } + logger.debug( + message = "New event: $event", + tag = key, + ) + val oldState = statesFlow.value + val (state, effects, commands) = reducer.reduce(event, statesFlow.value) + statesFlow.value = state + storeListeners.forEach { + it.onAfterEvent(key, state, oldState, event) + } + effects.forEach { effect -> if (isActive) dispatchEffect(effect) } + commands.forEach { if (isActive) executeCommand(it) } + } catch (error: CancellationException) { + throw error + } catch (t: Throwable) { + storeListeners.forEach { it.onReducerError(key, t, event) } + logger.fatal( + message = "You must handle all errors inside reducer", + tag = key, + error = t, + ) + } + } + + private suspend fun dispatchEffect(effect: Effect) { + storeListeners.forEach { it.onEffect(key, effect, statesFlow.value) } + logger.debug( + message = "New effect: $effect", + tag = key, + ) + effectsFlow.emit(effect) + } + + private fun executeCommand(command: Command) { + scope.launch { + storeListeners.forEach { it.onCommand(key, command, statesFlow.value) } + logger.debug( + message = "Executing command: $command", + tag = key, + ) + actor + .execute(command) + .onEach { + logger.debug( + message = "Command $command produces event $it", + tag = key, + ) + } + .cancellable() + .catch { throwable -> + storeListeners.forEach { it.onActorError(key, throwable, command) } + logger.nonfatal( + message = "Unhandled exception inside the command $command", + tag = key, + error = throwable, + ) + } + .collect { accept(it) } + } + } +} + +fun Store.toCachedStore() = + EffectCachingElmStore(this) diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/NoOpActor.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/NoOpActor.kt new file mode 100644 index 00000000..fab783d2 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/NoOpActor.kt @@ -0,0 +1,10 @@ +package money.vivid.elmslie.core.store + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +/** Actor that doesn't emit any events after receiving a command */ +class NoOpActor : Actor() { + + override fun execute(command: Command): Flow = emptyFlow() +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/NoOpReducer.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/NoOpReducer.kt new file mode 100644 index 00000000..f84b1c2e --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/NoOpReducer.kt @@ -0,0 +1,10 @@ +package money.vivid.elmslie.core.store + +/** + * Reducer that doesn't change state, and doesn't emit commands or effects + */ +class NoOpReducer : + StateReducer() { + + override fun Result.reduce(event: Event) = Unit +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Result.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Result.kt new file mode 100644 index 00000000..d0f3885c --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Result.kt @@ -0,0 +1,36 @@ +package money.vivid.elmslie.core.store + +/** Represents result of reduce function */ +data class Result( + val state: State, + val effects: List, + val commands: List, +) { + + constructor( + state: State, + effect: Effect? = null, + command: Command? = null, + ) : this( + state = state, + effects = listOfNotNull(effect), + commands = listOfNotNull(command), + ) + + constructor( + state: State, + commands: List, + ) : this( + state = state, + effects = emptyList(), + commands = commands, + ) + + constructor( + state: State + ) : this( + state = state, + effects = emptyList(), + commands = emptyList(), + ) +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/ScreenReducer.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/ScreenReducer.kt new file mode 100644 index 00000000..ddb7efd5 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/ScreenReducer.kt @@ -0,0 +1,23 @@ +package money.vivid.elmslie.core.store + +import kotlin.reflect.KClass + +abstract class ScreenReducer( + private val uiEventClass: KClass, + private val internalEventClass: KClass +) : StateReducer() { + + + protected abstract fun Result.ui(event: Ui): Any? + + protected abstract fun Result.internal(event: Internal): Any? + + override fun Result.reduce(event: Event) { + @Suppress("UNCHECKED_CAST") + when { + uiEventClass.isInstance(event) -> ui(event as Ui) + internalEventClass.isInstance(event) -> internal(event as Internal) + else -> error("Event ${event::class} is neither UI nor Internal") + } + } +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/StateReducer.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/StateReducer.kt new file mode 100644 index 00000000..9ee2f85d --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/StateReducer.kt @@ -0,0 +1,13 @@ +package money.vivid.elmslie.core.store + +import money.vivid.elmslie.core.store.dsl.ResultBuilder + +abstract class StateReducer { + + // Needed to type less code + protected inner class Result(state: State) : ResultBuilder(state) + + protected abstract fun Result.reduce(event: Event) + + fun reduce(event: Event, state: State) = Result(state).apply { reduce(event) }.build() +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Store.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Store.kt new file mode 100644 index 00000000..18c63312 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/Store.kt @@ -0,0 +1,49 @@ +package money.vivid.elmslie.core.store + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface Store { + + /** Event that will be emitted upon store start. */ + val startEvent: Event? + + /** Store's scope. Active for the lifetime of store. */ + val scope: CoroutineScope + + /** + * Returns the flow of [State]. Internally the store keeps the last emitted state value, so each + * new subscribers will get it. + * + * Note that there will be no emission if a state isn't changed (it's [equals] method returned + * `true`. + * + * By default, [State] is collected in [Dispatchers.IO]. + */ + val states: StateFlow + + /** + * Returns the flow of [Effect]. It's a _hot_ flow and values produced by it **don't cache**. + * + * In order to implement cache of [Effect], consider extending [Store] with appropriate + * behavior. + * + * By default, [Effect] is collected in [Dispatchers.IO]. + */ + val effects: Flow + + /** + * Starts the operations inside the store. + */ + fun start(): Store + + /** + * Stops all operations inside the store and cancels coroutines scope. + * After this any calls of [start] method has no effect. + */ + fun stop() + + /** Sends a new [Event] for the store. */ + fun accept(event: Event) +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/StoreListener.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/StoreListener.kt new file mode 100644 index 00000000..9b8d20e4 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/StoreListener.kt @@ -0,0 +1,12 @@ +package money.vivid.elmslie.core.store + +interface StoreListener { + + fun onBeforeEvent(key: String, event: Event, currentState: State) {} + fun onAfterEvent(key: String, newState: State, oldState: State, eventCause: Event) {} + fun onEffect(key: String, effect: Effect, state: State) {} + fun onCommand(key: String, command: Command, state: State) {} + + fun onReducerError(key: String, throwable: Throwable, event: Event) {} + fun onActorError(key: String, throwable: Throwable, command: Command) {} +} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/OperationsBuilder.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/dsl/OperationsBuilder.kt similarity index 83% rename from elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/OperationsBuilder.kt rename to elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/dsl/OperationsBuilder.kt index ba68748d..bb9d9ddc 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/OperationsBuilder.kt +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/dsl/OperationsBuilder.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.core.store.dsl_reducer +package money.vivid.elmslie.core.store.dsl @DslMarker internal annotation class OperationsBuilderDsl diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/ResultBuilder.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/dsl/ResultBuilder.kt similarity index 89% rename from elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/ResultBuilder.kt rename to elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/dsl/ResultBuilder.kt index c9baf773..41d2ed42 100644 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/ResultBuilder.kt +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/store/dsl/ResultBuilder.kt @@ -1,6 +1,6 @@ -package vivid.money.elmslie.core.store.dsl_reducer +package money.vivid.elmslie.core.store.dsl -import vivid.money.elmslie.core.store.Result +import money.vivid.elmslie.core.store.Result open class ResultBuilder( val initialState: State diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/switcher/Switcher.kt new file mode 100644 index 00000000..e912f6df --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/switcher/Switcher.kt @@ -0,0 +1,72 @@ +package money.vivid.elmslie.core.switcher + +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import money.vivid.elmslie.core.store.Actor + +/** + * Allows to execute requests for [Actor] implementations in a switching manner. Each request + * will cancel the previous one. + * + * Example: + * ``` + * private val switcher = Switcher() + * + * override fun execute(command: Command): Flow<*> = when (command) { + * is MyCommand -> switcher.switch { + * flowOf(123) + * } + * } + * ``` + */ +class Switcher { + + private var currentChannel: SendChannel<*>? = null + private val lock = Mutex() + + /** + * Collect given flow as a job and cancels all previous ones. + * + * @param delayMillis operation delay measured with milliseconds. Can be specified to debounce + * existing requests. + * @param action actual event source + */ + fun switch( + delayMillis: Long = 0, + action: () -> Flow, + ): Flow { + return callbackFlow { + lock.withLock { + currentChannel?.close() + currentChannel = channel + } + + delay(delayMillis) + + action.invoke() + .onEach { send(it) } + .catch { close(it) } + .collect() + + channel.close() + } + } + + fun cancel( + delayMillis: Long = 0, + ): Flow = flow { + delay(delayMillis) + lock.withLock { + currentChannel?.close() + currentChannel = null + } + } +} diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt new file mode 100644 index 00000000..e596703b --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt @@ -0,0 +1,5 @@ +package money.vivid.elmslie.core.utils + +import kotlinx.coroutines.CoroutineDispatcher + +internal expect val IoDispatcher: CoroutineDispatcher diff --git a/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt new file mode 100644 index 00000000..34ead988 --- /dev/null +++ b/elmslie-core/src/commonMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt @@ -0,0 +1,5 @@ +package money.vivid.elmslie.core.utils + +import money.vivid.elmslie.core.store.StateReducer + +internal expect fun resolveStoreKey(reducer: StateReducer<*, *, *, *>): String \ No newline at end of file diff --git a/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/EffectCachingElmStoreTest.kt b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/EffectCachingElmStoreTest.kt new file mode 100644 index 00000000..93446022 --- /dev/null +++ b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/EffectCachingElmStoreTest.kt @@ -0,0 +1,186 @@ +package money.vivid.elmslie.core.store + +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import money.vivid.elmslie.core.config.ElmslieConfig +import money.vivid.elmslie.core.testutil.model.Command +import money.vivid.elmslie.core.testutil.model.Effect +import money.vivid.elmslie.core.testutil.model.Event +import money.vivid.elmslie.core.testutil.model.State + +@OptIn(ExperimentalCoroutinesApi::class) +class EffectCachingElmStoreTest { + + @BeforeTest + fun beforeEach() { + val testDispatcher = StandardTestDispatcher() + ElmslieConfig.ioDispatchers { testDispatcher } + Dispatchers.setMain(testDispatcher) + } + + + @AfterTest + fun afterEach() { + Dispatchers.resetMain() + } + + @Test + fun `Should collect effects which are emitted before collecting flow`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .toCachedStore() + + store.start() + store.accept(Event(value = 1)) + store.accept(Event(value = 2)) + store.accept(Event(value = 2)) + advanceUntilIdle() + + val effects = mutableListOf() + val job = launch { store.effects.toList(effects) } + advanceUntilIdle() + + assertEquals( + listOf( + Effect(value = 1), + Effect(value = 2), + Effect(value = 2), + ), + effects + ) + + job.cancel() + } + + @Test + fun `Should collect effects which are emitted before collecting flow and after`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .toCachedStore() + + store.start() + store.accept(Event(value = 1)) + store.accept(Event(value = 2)) + store.accept(Event(value = 2)) + advanceUntilIdle() + + val effects = mutableListOf() + val job = launch { store.effects.toList(effects) } + store.accept(Event(value = 3)) + advanceUntilIdle() + + assertEquals( + listOf( + Effect(value = 1), + Effect(value = 2), + Effect(value = 2), + Effect(value = 3), + ), + effects + ) + + job.cancel() + } + + @Test + fun `Should emit effects from cache only for the first subscriber`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .toCachedStore() + + store.start() + store.accept(Event(value = 1)) + advanceUntilIdle() + + val effects1 = mutableListOf() + val effects2 = mutableListOf() + val job1 = launch { store.effects.toList(effects1) } + runCurrent() + val job2 = launch { store.effects.toList(effects2) } + runCurrent() + + assertEquals( + listOf( + Effect(value = 1), + ), + effects1 + ) + + assertEquals(emptyList(), effects2) + + job1.cancel() + job2.cancel() + } + + @Test + fun `Should cache effects if there is no left collectors`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .toCachedStore() + + store.start() + val effects = mutableListOf() + var job1 = launch { store.effects.toList(effects) } + runCurrent() + job1.cancel() + store.accept(Event(value = 2)) + runCurrent() + job1 = launch { store.effects.toList(effects) } + runCurrent() + + assertEquals( + listOf( + Effect(value = 2), + ), + effects + ) + + job1.cancel() + } + + private fun store( + state: State, + reducer: StateReducer = NoOpReducer(), + actor: Actor = NoOpActor() + ) = ElmStore(state, reducer, actor) +} diff --git a/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/ElmStoreTest.kt b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/ElmStoreTest.kt new file mode 100644 index 00000000..fb923b17 --- /dev/null +++ b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/ElmStoreTest.kt @@ -0,0 +1,346 @@ +package money.vivid.elmslie.core.store + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import money.vivid.elmslie.core.config.ElmslieConfig +import money.vivid.elmslie.core.testutil.model.Command +import money.vivid.elmslie.core.testutil.model.Effect +import money.vivid.elmslie.core.testutil.model.Event +import money.vivid.elmslie.core.testutil.model.State +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class ElmStoreTest { + + @BeforeTest + fun beforeEach() { + val testDispatcher = StandardTestDispatcher() + ElmslieConfig.ioDispatchers { testDispatcher } + Dispatchers.setMain(testDispatcher) + } + + + @AfterTest + fun afterEach() { + Dispatchers.resetMain() + } + + @Test + fun `Should stop the store properly`() = runTest { + val store = store(State()) + + store.start() + store.accept(Event()) + store.stop() + advanceUntilIdle() + } + + @Test + fun `Should stop getting state updates when the store is stopped`() = runTest { + val actor = object : Actor() { + override fun execute(command: Command): Flow = + flow { emit(Event()) }.onEach { delay(1000) } + } + + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + state { copy(value = state.value + 1) } + commands { +Command() } + } + }, + actor = actor, + ).start() + + val emittedStates = mutableListOf() + val collectJob = launch { store.states.toList(emittedStates) } + store.accept(Event()) + advanceTimeBy(3500) + store.stop() + + assertEquals( + mutableListOf( + State(0), // Initial state + State(1), // State after receiving trigger Event + State(2), // State after executing the first command + State(3), // State after executing the second command + State(4) // State after executing the third command + ), + emittedStates + ) + collectJob.cancel() + } + + @Test + fun `Should update state when event is received`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + state { copy(value = event.value) } + } + }, + ) + .start() + + assertEquals( + State(0), + store.states.value, + ) + store.accept(Event(value = 10)) + advanceUntilIdle() + + assertEquals(State(10), store.states.value) + } + + @Test + fun `Should not update state when it's equal to previous one`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + state { copy(value = event.value) } + } + }, + ) + .start() + + val emittedStates = mutableListOf() + val collectJob = launch { store.states.toList(emittedStates) } + + store.accept(Event(value = 0)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + State(0) // Initial state + ), + emittedStates + ) + collectJob.cancel() + } + + @Test + fun `Should collect all emitted effects`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .start() + + val effects = mutableListOf() + val collectJob = launch { store.effects.toList(effects) } + store.accept(Event(value = 1)) + store.accept(Event(value = -1)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + Effect(value = 1), // The first effect + Effect(value = -1), // The second effect + ), + effects + ) + collectJob.cancel() + } + + @Test + fun `Should skip the effect which is emitted before subscribing to effects`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .start() + + val effects = mutableListOf() + store.accept(Event(value = 1)) + runCurrent() + val collectJob = launch { store.effects.toList(effects) } + store.accept(Event(value = -1)) + runCurrent() + + assertEquals( + mutableListOf( + Effect(value = -1), + ), + effects + ) + collectJob.cancel() + } + + @Test + fun `Should collect all effects emitted once per time`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { + +Effect(value = event.value) + +Effect(value = event.value) + } + } + }, + ) + .start() + + val effects = mutableListOf() + val collectJob = launch { store.effects.toList(effects) } + store.accept(Event(value = 1)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + Effect(value = 1), // The first effect + Effect(value = 1), // The second effect + ), + effects + ) + collectJob.cancel() + } + + @Test + fun `Should collect all emitted effects by all collectors`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .start() + + val effects1 = mutableListOf() + val effects2 = mutableListOf() + val collectJob1 = launch { store.effects.toList(effects1) } + val collectJob2 = launch { store.effects.toList(effects2) } + store.accept(Event(value = 1)) + store.accept(Event(value = -1)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + Effect(value = 1), // The first effect + Effect(value = -1), // The second effect + ), + effects1 + ) + assertEquals( + mutableListOf( + Effect(value = 1), // The first effect + Effect(value = -1), // The second effect + ), + effects2 + ) + collectJob1.cancel() + collectJob2.cancel() + } + + @Test + fun `Should collect duplicated effects`() = runTest { + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + effects { +Effect(value = event.value) } + } + }, + ) + .start() + + val effects = mutableListOf() + val collectJob = launch { store.effects.toList(effects) } + store.accept(Event(value = 1)) + store.accept(Event(value = 1)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + Effect(value = 1), + Effect(value = 1), + ), + effects + ) + collectJob.cancel() + } + + @Test + fun `Should collect event caused by actor`() = runTest { + val actor = object : Actor() { + override fun execute(command: Command): Flow = flowOf(Event(command.value)) + } + val store = + store( + state = State(), + reducer = object : StateReducer() { + override fun Result.reduce(event: Event) { + state { copy(value = event.value) } + commands { + +Command(event.value - 1).takeIf { event.value > 0 } + } + } + }, + actor = actor, + ) + .start() + + val states = mutableListOf() + val collectJob = launch { store.states.toList(states) } + + store.accept(Event(3)) + advanceUntilIdle() + + assertEquals( + mutableListOf( + State(0), // Initial state + State(3), // State after receiving Event with command number + State(2), // State after executing the first command + State(1), // State after executing the second command + State(0) // State after executing the third command + ), + states + ) + + collectJob.cancel() + } + + private fun store( + state: State, + reducer: StateReducer = NoOpReducer(), + actor: Actor = NoOpActor() + ) = ElmStore(state, reducer, actor) +} diff --git a/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/DslReducerTest.kt b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/DslReducerTest.kt new file mode 100644 index 00000000..3fd9f4da --- /dev/null +++ b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/DslReducerTest.kt @@ -0,0 +1,119 @@ +package money.vivid.elmslie.core.store.dsl + +import money.vivid.elmslie.core.store.StateReducer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private object BasicDslReducer : StateReducer() { + + override fun Result.reduce(event: TestEvent) = + when (event) { + is TestEvent.One -> { + state { copy(one = 1) } + state { copy(two = 2) } + } + is TestEvent.Two -> effects { +TestEffect.One } + is TestEvent.Three -> + commands { + +TestCommand.Two + +TestCommand.One + } + is TestEvent.Four -> + if (event.flag) { + state { copy(one = 1) } + commands { +TestCommand.One } + effects { +TestEffect.One } + } else { + state { copy(one = state.two, two = state.one) } + effects { +TestEffect.One } + } + is TestEvent.Five -> applyDiff() + is TestEvent.Six -> { + commands { +TestCommand.One.takeIf { event.flag } } + } + } + + // Result editing can be done in a separate function + private fun Result.applyDiff() { + state { copy(one = 0) } + state { copy(one = initialState.one + 3) } + } +} + +internal class DslReducerTest { + + private val reducer = BasicDslReducer + + @Test + fun `Multiple state updates are executed`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.One, initialState) + assertEquals(state, TestState(one = 1, two = 2)) + assertTrue(effects.isEmpty()) + assertTrue(commands.isEmpty()) + } + + @Test + fun `Effect is added`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.Two, initialState) + assertEquals(state, initialState) + assertEquals(effects, listOf(TestEffect.One)) + assertTrue(commands.isEmpty()) + } + + @Test + fun `Multiple commands are added`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.Three, initialState) + assertEquals(state, initialState) + assertTrue(effects.isEmpty()) + assertEquals(commands, listOf(TestCommand.Two, TestCommand.One)) + } + + @Test + fun `Complex operation`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.Four(true), initialState) + assertEquals(state, TestState(one = 1, two = 0)) + assertEquals(effects, listOf(TestEffect.One)) + assertEquals(commands, listOf(TestCommand.One)) + } + + @Test + fun `Condition switches state values`() { + val initialState = TestState(one = 1, two = 2) + val (state, effects, commands) = reducer.reduce(TestEvent.Four(false), initialState) + assertEquals(state, TestState(one = 2, two = 1)) + assertEquals(effects, listOf(TestEffect.One)) + assertTrue(commands.isEmpty()) + } + + @Test + fun `Can access initial state`() { + val initialState = TestState(one = 1, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.Five, initialState) + assertEquals(state, TestState(one = 4, two = 0)) + assertTrue(effects.isEmpty()) + assertTrue(commands.isEmpty()) + } + + @Test + fun `Add command conditionally`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.Six(true), initialState) + assertEquals(state, initialState) + assertTrue(effects.isEmpty()) + assertEquals(commands, listOf(TestCommand.One)) + } + + @Test + fun `Skip command conditionally`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestEvent.Six(false), initialState) + assertEquals(state, initialState) + assertTrue(effects.isEmpty()) + assertTrue(commands.isEmpty()) + } +} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/Models.kt b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/Models.kt similarity index 92% rename from elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/Models.kt rename to elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/Models.kt index b63ae87b..a215cd8c 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/Models.kt +++ b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/Models.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.core.store.dsl_reducer +package money.vivid.elmslie.core.store.dsl data class TestState( val one: Int, diff --git a/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/ScreenReducerTest.kt b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/ScreenReducerTest.kt new file mode 100644 index 00000000..ea80efde --- /dev/null +++ b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/store/dsl/ScreenReducerTest.kt @@ -0,0 +1,79 @@ +package money.vivid.elmslie.core.store.dsl + +import money.vivid.elmslie.core.store.ScreenReducer +import money.vivid.elmslie.core.store.StateReducer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +object BasicScreenReducer : + ScreenReducer< + TestScreenEvent, + TestScreenEvent.Ui, + TestScreenEvent.Internal, + TestState, + TestEffect, + TestCommand + >(TestScreenEvent.Ui::class, TestScreenEvent.Internal::class) { + + override fun Result.ui(event: TestScreenEvent.Ui) = + when (event) { + is TestScreenEvent.Ui.One -> state { copy(one = 1, two = 2) } + } + + override fun Result.internal(event: TestScreenEvent.Internal) = + when (event) { + is TestScreenEvent.Internal.One -> + commands { + +TestCommand.One + +TestCommand.Two + } + } +} + +// The same code +object PlainScreenDslReducer : StateReducer() { + + override fun Result.reduce(event: TestScreenEvent) = + when (event) { + is TestScreenEvent.Ui -> reduce(event) + is TestScreenEvent.Internal -> reduce(event) + } + + private fun Result.reduce(event: TestScreenEvent.Ui) = + when (event) { + is TestScreenEvent.Ui.One -> state { copy(one = 1, two = 2) } + } + + private fun Result.reduce(event: TestScreenEvent.Internal) = + when (event) { + is TestScreenEvent.Internal.One -> + commands { + +TestCommand.One + +TestCommand.Two + } + } +} + +internal class ScreenReducerTest { + + private val reducer = BasicScreenReducer + + @Test + fun `Ui event is executed`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestScreenEvent.Ui.One, initialState) + assertEquals(state, TestState(one = 1, two = 2)) + assertTrue(effects.isEmpty()) + assertTrue(commands.isEmpty()) + } + + @Test + fun `Internal event is executed`() { + val initialState = TestState(one = 0, two = 0) + val (state, effects, commands) = reducer.reduce(TestScreenEvent.Internal.One, initialState) + assertEquals(state, initialState) + assertTrue(effects.isEmpty()) + assertEquals(commands, listOf(TestCommand.One, TestCommand.Two)) + } +} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/StoreModels.kt b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/testutil/model/StoreModels.kt similarity index 76% rename from elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/StoreModels.kt rename to elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/testutil/model/StoreModels.kt index 875d30d9..e6578f03 100644 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/StoreModels.kt +++ b/elmslie-core/src/commonTest/kotlin/money/vivid/elmslie/core/testutil/model/StoreModels.kt @@ -1,4 +1,4 @@ -package vivid.money.elmslie.core.testutil.model +package money.vivid.elmslie.core.testutil.model data class Event(val value: Int = 0) data class State(val value: Int = 0) diff --git a/elmslie-core/src/commonWebMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt b/elmslie-core/src/commonWebMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt new file mode 100644 index 00000000..23f8632e --- /dev/null +++ b/elmslie-core/src/commonWebMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt @@ -0,0 +1,6 @@ +package money.vivid.elmslie.core.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val IoDispatcher: CoroutineDispatcher = Dispatchers.Default \ No newline at end of file diff --git a/elmslie-core/src/commonWebMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt b/elmslie-core/src/commonWebMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt new file mode 100644 index 00000000..4a9ad0bb --- /dev/null +++ b/elmslie-core/src/commonWebMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt @@ -0,0 +1,8 @@ +package money.vivid.elmslie.core.utils + +import money.vivid.elmslie.core.store.StateReducer + +internal actual fun resolveStoreKey(reducer: StateReducer<*, *, *, *>): String = + reducer::class.simpleName + .orEmpty() + .replace("Reducer", "Store") \ No newline at end of file diff --git a/elmslie-core/src/jvmMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt b/elmslie-core/src/jvmMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt new file mode 100644 index 00000000..8bcbdf79 --- /dev/null +++ b/elmslie-core/src/jvmMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt @@ -0,0 +1,6 @@ +package money.vivid.elmslie.core.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +internal actual val IoDispatcher: CoroutineDispatcher = Dispatchers.IO \ No newline at end of file diff --git a/elmslie-core/src/jvmMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt b/elmslie-core/src/jvmMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt new file mode 100644 index 00000000..a31f58ab --- /dev/null +++ b/elmslie-core/src/jvmMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt @@ -0,0 +1,8 @@ +package money.vivid.elmslie.core.utils + +import money.vivid.elmslie.core.store.StateReducer + +internal actual fun resolveStoreKey(reducer: StateReducer<*, *, *, *>): String = + (reducer::class.qualifiedName ?: reducer::class.simpleName) + .orEmpty() + .replace("Reducer", "Store") \ No newline at end of file diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt deleted file mode 100644 index f266767d..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/config/ElmslieConfig.kt +++ /dev/null @@ -1,57 +0,0 @@ -package vivid.money.elmslie.core.config - -import vivid.money.elmslie.core.logger.ElmslieLogger -import vivid.money.elmslie.core.logger.ElmslieLogConfiguration -import vivid.money.elmslie.core.logger.strategy.IgnoreLog -import vivid.money.elmslie.core.store.StateReducer -import vivid.money.elmslie.core.switcher.Switcher -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService - -object ElmslieConfig { - - @Volatile - private lateinit var loggerInternal: ElmslieLogger - - @Volatile - private lateinit var reducerExecutorInternal: ScheduledExecutorService - - val logger: ElmslieLogger - get() = loggerInternal - - val backgroundExecutor: ScheduledExecutorService - get() = reducerExecutorInternal - - init { - logger { always(IgnoreLog) } - backgroundExecutor { Executors.newSingleThreadScheduledExecutor() } - } - - /** - * Configures logging and error handling - * - * Example: - * ``` - * ElmslieConfig.logger { - * fatal(Crash) - * nonfatal(AndroidLog) - * debug(Ignore) - * } - * ``` - */ - fun logger(config: (ElmslieLogConfiguration.() -> Unit)) { - ElmslieLogConfiguration().apply(config).build().also { loggerInternal = it } - } - - /** - * Configures an executor for running background operations for [StateReducer] and [Switcher]. - * - * Example: - * ``` - * ElmslieConfig.backgroundExecutor { Executors.newScheduledThreadPool(4) } - * ``` - */ - fun backgroundExecutor(builder: () -> ScheduledExecutorService) { - reducerExecutorInternal = builder() - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt deleted file mode 100644 index 9effa42b..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/CompositeDisposable.kt +++ /dev/null @@ -1,22 +0,0 @@ -package vivid.money.elmslie.core.disposable - -/** - * A convenient holder for multiple [Disposable]s - */ -class CompositeDisposable { - - private val disposables = mutableListOf() - - operator fun plusAssign(disposable: Disposable) = add(disposable) - - fun add(disposable: Disposable) = synchronized(this) { - disposables += disposable - } - - fun addAll(vararg disposables: Disposable) = disposables.forEach(::add) - - fun clear() = synchronized(this) { - disposables.forEach { it.dispose() } - disposables.clear() - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt deleted file mode 100644 index b14726db..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/disposable/Disposable.kt +++ /dev/null @@ -1,8 +0,0 @@ -package vivid.money.elmslie.core.disposable - -/** - * Represents a manual clean up action - */ -fun interface Disposable { - fun dispose() -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/ElmslieLogger.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/ElmslieLogger.kt deleted file mode 100644 index bd0e9190..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/ElmslieLogger.kt +++ /dev/null @@ -1,20 +0,0 @@ -package vivid.money.elmslie.core.logger - -import vivid.money.elmslie.core.logger.strategy.IgnoreLog -import vivid.money.elmslie.core.logger.strategy.LogStrategy - -/** Logs events happening in the Elmslie library */ -class ElmslieLogger( - private val strategy: Map -) { - - fun fatal(message: String = "", error: Throwable? = null) = handle(LogSeverity.Fatal, message, error) - - fun nonfatal(message: String = "", error: Throwable? = null) = handle(LogSeverity.NonFatal, message, error) - - fun debug(message: String) = handle(LogSeverity.Debug, message, null) - - private fun handle(severity: LogSeverity, message: String, error: Throwable?) { - (strategy[severity] ?: IgnoreLog)(severity, message, error) - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/strategy/IgnoreLog.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/strategy/IgnoreLog.kt deleted file mode 100644 index f1e29d65..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/strategy/IgnoreLog.kt +++ /dev/null @@ -1,4 +0,0 @@ -package vivid.money.elmslie.core.logger.strategy - -/** Ignores all log events */ -object IgnoreLog : LogStrategy by LogStrategy({ _, _, _ -> }) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/strategy/LogStrategy.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/strategy/LogStrategy.kt deleted file mode 100644 index 9778784e..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/logger/strategy/LogStrategy.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.logger.strategy - -import vivid.money.elmslie.core.logger.LogSeverity - -/** Allows to provide custom logic for error handling */ -fun interface LogStrategy : (LogSeverity, String, Throwable?) -> Unit diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt deleted file mode 100644 index 319c0814..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/DefaultActor.kt +++ /dev/null @@ -1,16 +0,0 @@ -package vivid.money.elmslie.core.store - -import vivid.money.elmslie.core.disposable.Disposable - -fun interface DefaultActor { - - /** - * Executes a command. This method is always called in ElmslieConfig.backgroundExecutor. - * Usually background thread. - */ - fun execute( - command: Command, - onEvent: (Event) -> Unit, - onError: (Throwable) -> Unit - ): Disposable -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt deleted file mode 100644 index b517994f..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/ElmStore.kt +++ /dev/null @@ -1,139 +0,0 @@ -package vivid.money.elmslie.core.store - -import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.util.distinctUntilChanged -import vivid.money.elmslie.core.store.exception.StoreAlreadyStartedException -import vivid.money.elmslie.core.disposable.CompositeDisposable -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.util.ConcurrentHashSet -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicReference - -@Suppress("TooManyFunctions", "TooGenericExceptionCaught") -class ElmStore( - initialState: State, - private val reducer: StateReducer, - private val actor: DefaultActor -) : Store { - - companion object { - private val logger = ElmslieConfig.logger - private val executor = ElmslieConfig.backgroundExecutor - } - - private val disposables = CompositeDisposable() - - private val isStartedInternal = AtomicBoolean(false) - override val isStarted: Boolean get() = isStartedInternal.get() - - private val effectBuffer = ConcurrentLinkedQueue() - private val effectBufferingListener = effectBuffer::add - private val effectListeners = ConcurrentHashSet<(Effect) -> Any?>() - private val eventListeners = ConcurrentHashSet<(Event) -> Any?>() - private val stateListeners = ConcurrentHashSet<(State) -> Any?>() - private val stateInternal = AtomicReference(initialState) - override val currentState: State get() = stateInternal.get() - - // We can't use subject to store state to keep it synchronized with children - private val stateLock = Any() - - override fun accept(event: Event) = dispatchEvent(event) - - override fun start() = this.also { - requireNotStarted() - startBuffering() - } - - override fun stop() { - isStartedInternal.set(false) - disposables.clear() - startBuffering() - } - - override fun states(onStateChange: (State) -> Unit): Disposable { - val callback = onStateChange.distinctUntilChanged() - stateListeners += callback - dispatchState(currentState) - return Disposable { stateListeners -= callback } - } - - override fun effects(onEffectEmission: (Effect) -> Unit): Disposable { - dispatchBuffer(onEffectEmission) - startBuffering() - effectListeners += onEffectEmission - return Disposable { - effectListeners -= onEffectEmission - effectBuffer.clear() - if (isStarted && effectListeners.isEmpty()) stopBuffering() - } - } - - private fun events(onEventTriggering: (Event) -> Unit): Disposable { - eventListeners += onEventTriggering - return Disposable { eventListeners -= onEventTriggering } - } - - override fun addChildStore( - childStore: Store, - eventMapper: (parentEvent: Event) -> ChildEvent?, - effectMapper: (parentState: State, childEffect: ChildEffect) -> Effect?, - stateReducer: (parentState: State, childState: ChildState) -> State - ): Store { - disposables.addAll( - // We won't lose any state or effects since they're cached - { childStore.stop() }, - events { eventMapper(it)?.let(childStore::accept) }, - childStore.effects { effectMapper(currentState, it)?.let(::dispatchEffect) }, - childStore.states { dispatchState(stateReducer(currentState, it)) }, - ) - childStore.start() - return this - } - - private fun dispatchState(state: State) = synchronized(stateLock) { - stateInternal.set(state) - stateListeners.forEach { it(state) } - } - - private fun dispatchEffect(effect: Effect) { - logger.debug("New effect: $effect") - effectListeners.forEach { it(effect) } - } - - private fun dispatchEvent(event: Event) { - executor.submit { - try { - logger.debug("New event: $event") - eventListeners.forEach { it(event) } - val result = reducer.reduce(event, currentState) - dispatchState(result.state) - result.effects.forEach(::dispatchEffect) - result.commands.forEach(::executeCommand) - } catch (t: Throwable) { - logger.fatal("You must handle all errors inside reducer", t) - } - } - } - - private fun executeCommand(command: Command) = try { - logger.debug("Executing command: $command") - disposables += actor.execute(command, ::dispatchEvent, { logger.nonfatal(error = it) }) - } catch (t: Throwable) { - logger.fatal("Unexpected actor error", t) - } - - private fun startBuffering() = effectListeners.add(effectBufferingListener) - - private fun stopBuffering() = effectListeners.remove(effectBufferingListener) - - private fun dispatchBuffer(onEffectEmission: (Effect) -> Unit) = effectBuffer - .onEach { onEffectEmission(it) } - .clear() - - private fun requireNotStarted() { - if (!isStartedInternal.compareAndSet(false, true)) { - logger.fatal("Store start error", StoreAlreadyStartedException()) - } - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt deleted file mode 100644 index dd60e06f..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/MappingActor.kt +++ /dev/null @@ -1,22 +0,0 @@ -package vivid.money.elmslie.core.store - -import vivid.money.elmslie.core.config.ElmslieConfig - -/** - * Contains internal event mapping utilities - */ -interface MappingActor { - - companion object { - private val logger = ElmslieConfig.logger - } - - fun Throwable.logErrorEvent( - errorMapper: (Throwable) -> Event? - ): Event? = errorMapper(this).also { - logger.nonfatal(error = this) - logger.debug("Failed app state: $it") - } - - fun Event.logSuccessEvent() = logger.debug("Completed app state: $this") -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt deleted file mode 100644 index d0a538d0..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpActor.kt +++ /dev/null @@ -1,14 +0,0 @@ -package vivid.money.elmslie.core.store - -import vivid.money.elmslie.core.disposable.Disposable - -/** - * Actor that doesn't emit any events after receiving a command - */ -class NoOpActor : DefaultActor { - override fun execute( - command: Command, - onEvent: (Event) -> Unit, - onError: (Throwable) -> Unit - ) = Disposable {} -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpReducer.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpReducer.kt deleted file mode 100644 index c3780e2c..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/NoOpReducer.kt +++ /dev/null @@ -1,9 +0,0 @@ -package vivid.money.elmslie.core.store - -/** - * Reducer that doesn't change state, and doesn't emit commands or effects - */ -class NoOpReducer : StateReducer { - - override fun reduce(event: Event, state: State) = Result(state) -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt deleted file mode 100644 index 5b625a55..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Result.kt +++ /dev/null @@ -1,32 +0,0 @@ -package vivid.money.elmslie.core.store - -/** - * Represents result of reduce function - */ -data class Result( - val state: State, - val effects: List, - val commands: List, -) { - - constructor( - state: State, - effect: Effect? = null, - command: Command? = null, - ) : this( - state, - effect?.let(::listOf) ?: emptyList(), - command?.let(::listOf) ?: emptyList() - ) - - constructor( - state: State, - commands: List, - ) : this( - state, - emptyList(), - commands - ) - - constructor(state: State) : this(state, emptyList(), emptyList()) -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/StateReducer.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/StateReducer.kt deleted file mode 100644 index 621eda49..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/StateReducer.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.store - -fun interface StateReducer { - - fun reduce(event: Event, state: State): Result -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt deleted file mode 100644 index 6bea1765..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/Store.kt +++ /dev/null @@ -1,60 +0,0 @@ -package vivid.money.elmslie.core.store - -import vivid.money.elmslie.core.disposable.Disposable - -interface Store { - - /** Provides access to the current store [State]. */ - val currentState: State - - /** Returns `true` for the span duration between [start] and [stop] calls. */ - val isStarted: Boolean - - /** - * Starts the operations inside the store. - * Calls fatal exception handler in case the store is already started. - */ - fun start(): Store - - /** Stops all operations inside the store. */ - fun stop() - - /** Sends a new [Event] for the store. */ - fun accept(event: Event) - - /** - * Provides ability to subscribe to state changes. - * - * State dispatching is restricted. Behavior contract: - * - The current state will be sent synchronously. - * - Every two subsequent invocation of [onStateChange] have not equal states. - * - States are **never** delivered on the main thread. - * - * @return [Disposable] For stopping [onStateChange] callback invocations. - */ - fun states(onStateChange: (State) -> Unit): Disposable - - /** - * Provides ability to subscribe to effect emissions. - * - * Effects may be buffered. Behavior contract: - * - Buffering is active when the store [isStarted]. - * - Buffering starts after disposing all [effects] listeners. - * - All buffered effects are sent to the first attached [effects] observer synchronously. - * - Examples: Before the first [effects] call, after disposing [effects] observer. - * - * Emission thread is unspecified. Behavior contract: - * - Effects are **never** delivered on the main thread. - * - * @return [Disposable] For stopping [onEffectEmission] callback invocations. - */ - fun effects(onEffectEmission: (Effect) -> Unit): Disposable - - @Deprecated("Please, use store coordination instead. This approach will be removed in future.") - fun addChildStore( - childStore: Store, - eventMapper: (parentEvent: Event) -> ChildEvent? = { null }, - effectMapper: (parentState: State, childEffect: ChildEffect) -> Effect? = { _, _ -> null }, - stateReducer: (parentState: State, childState: ChildState) -> State = { parentState, _ -> parentState } - ): Store -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt deleted file mode 100644 index 026d9405..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversationRules.kt +++ /dev/null @@ -1,46 +0,0 @@ -package vivid.money.elmslie.core.store.binding - -import vivid.money.elmslie.core.store.Store - -/** - * A store that supervises both stores and manages their lifecycle. - * - * With the following responsibilities: - * - [start] Starting both stores - * - [stop] Stopping both stores - * - * @param initiator - A store that demands data conversion - * @param responder - A store that handles data conversion - * @param expecting A conversion contract that [initiator] dispatches for the [responder] to handle - * @param receiving A conversion contract that [responder] provides to [initiator] in return - * @constructor Determines conversion rules - */ -internal class ConversationRules( - private val initiator: Store, - private val responder: Store, - expecting: ConversionContract.() -> Unit, - receiving: ConversionContract.() -> Unit -) : Store by initiator { - - private val providedContract = ConversionContract(initiator, responder).apply(expecting) - private val expectedContract = ConversionContract(responder, initiator).apply(receiving) - - override fun start(): Store { - initiator.start() - responder.start() - providedContract.apply() - expectedContract.apply() - return this - } - - override fun stop() { - providedContract.revoke() - expectedContract.revoke() - responder.stop() - initiator.stop() - } -} - diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt deleted file mode 100644 index 672a60a2..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/ConversionContract.kt +++ /dev/null @@ -1,86 +0,0 @@ -package vivid.money.elmslie.core.store.binding - -import vivid.money.elmslie.core.disposable.CompositeDisposable -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.Store - -/** - * A contract for data exchange between stores. - */ -class ConversionContract( - private val initiator: Store, - private val responder: Store, -) { - - private val disposable = CompositeDisposable() - private val contracts = mutableSetOf<() -> Disposable>() - - /** - * Defines full direct state conversion between stores. - */ - fun states( - conversion: InitiatorState.() -> ResponderEvent? = { null } - ) = states({ this }, conversion) - - /** - * Defines full direct effect conversion between stores. - */ - fun effects( - conversion: InitiatorEffect.() -> ResponderEvent? = { null } - ) = effects({ this }, conversion) - - /** - * Defines partial encrypted state conversion between stores. - */ - fun states( - cypher: InitiatorState.() -> EncryptedState?, - conversion: EncryptedState.() -> ResponderEvent? = { null } - ) = contract(initiator::states, cypher, conversion) - - /** - * Defines partial encrypted effect conversion between stores. - */ - fun effects( - cypher: InitiatorEffect.() -> EncryptedEffect?, - conversion: EncryptedEffect.() -> ResponderEvent? = { null } - ) = contract(initiator::effects, cypher, conversion) - - /** - * Defines common conversion contract for data passing. - * - * Example: - * ``` - * contract(store::states, cypher, conversion) - * ``` - */ - private fun contract( - valueProvider: ((Value) -> Unit) -> Disposable, - cypher: Value.() -> EncryptedValue?, - conversion: EncryptedValue.() -> ResponderEvent? - ) { - contracts += { - valueProvider { value -> - cypher(value) - ?.let { encrypted -> conversion(encrypted) } - ?.let(responder::accept) - } - } - } - - /** - * Starts conversion between stores by applying contracts. - */ - fun apply() { - check(initiator.isStarted) - check(responder.isStarted) - contracts.forEach { it() } - } - - /** - * Stops conversion between stores by revoking contracts. - */ - fun revoke() { - disposable.clear() - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt deleted file mode 100644 index ed8f2292..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/binding/Coordination.kt +++ /dev/null @@ -1,89 +0,0 @@ -package vivid.money.elmslie.core.store.binding - -import vivid.money.elmslie.core.store.Store - -/** - * Creates a store that coordinates two stores together. - * - * Both stores are equal in terms of conversation possibilities. - * For the period of the conversion definition the following naming is provided: - * - Store which is specified as the receiver is called **initiator** - * - Store which is coordinated is called **responder**. - * - * The resulting store will have the same interface as the **initiator** store. - * - * Example `Manager-Employee`: - * ``` - * manager.coordinates( - * employee, - * // - * // Manager defines tasks without employee's awareness and dispatches: - * // - Up-to-date task definitions periodically - * // - News about promotion occasionally - * // - * // Employee chooses the way to handle tasks internally without manager's awareness. - * // - * dispatching = { - * states { EmployeeEvent.TaskDefinition(this) } - * effects { EmployeeEffect.PromotionNews } - * }, - * // - * // Employee executes tasks without manager's awareness and updates manager with statuses. - * // - * // Manager processes task status updates without employee's awareness when: - * // - Receives up-to-date task status periodically - * // - And appreciates employee occasionally - * // - * receiving = { - * states { ManagerEvent.TaskStatus(this) } - * effects { ManagerEffect.EmployeeAppreciation } - * } - * ).start() - * ``` - * - * It's possible to define the inverted conversation with another reversed coordination call. - * - * Example `One-on-one`: - * ``` - * employee.coordinates( - * manager, - * // - * // Employee finds areas of improvement without manager's awareness and dispatches: - * // - Improvement suggestions periodically - * // - Satisfaction for the manager - * // - * // Manager chooses the time to process suggestions without employee's awareness. - * // - * dispatching = { - * states { ManagerEvent.ImprovementSuggestion(this) } - * effects { ManagerEvent.Satisfaction } - * }, - * // - * // Manager refines suggestions without employee's awareness - * // and updates employee with possibilities. - * // - * // Employee processes possibilities and makes use of them when: - * // - Receives improvement possibilities - * // - And handles responsibility expansion - * // - * receiving = { - * states { EmployeeEvent.ImprovementPossibilities(this) } - * effects { EmployeeEvent.ResponsibilityExpansion } - * } - * }.start() - * ``` - * - * @receiver - Initiates coordination - * @param responder - Supports coordination - * @param dispatching - Conversion contract implied by initiator - * @param receiving - Conversion contract implied by responder - */ -fun - Store.coordinates( - responder: Store, - dispatching: ConversionContract.() -> Unit = {}, - receiving: ConversionContract.() -> Unit = {}, -): Store = - ConversationRules(this, responder, dispatching, receiving) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/DslReducer.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/DslReducer.kt deleted file mode 100644 index 7192143d..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/DslReducer.kt +++ /dev/null @@ -1,14 +0,0 @@ -package vivid.money.elmslie.core.store.dsl_reducer - -import vivid.money.elmslie.core.store.StateReducer - -abstract class DslReducer : - StateReducer { - - // Needed to type less code - protected inner class Result(state: State) : ResultBuilder(state) - - protected abstract fun Result.reduce(event: Event): Any? - - final override fun reduce(event: Event, state: State) = Result(state).apply { reduce(event) }.build() -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/ScreenDslReducer.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/ScreenDslReducer.kt deleted file mode 100644 index 454405bc..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/dsl_reducer/ScreenDslReducer.kt +++ /dev/null @@ -1,29 +0,0 @@ -package vivid.money.elmslie.core.store.dsl_reducer - -import vivid.money.elmslie.core.store.StateReducer -import kotlin.reflect.KClass - -abstract class ScreenDslReducer( - private val uiEventClass: KClass, - private val internalEventClass: KClass -) : StateReducer { - - protected inner class Result(state: State) : ResultBuilder(state) - - protected abstract fun Result.ui(event: Ui): Any? - - protected abstract fun Result.internal(event: Internal): Any? - - final override fun reduce( - event: Event, - state: State - ): vivid.money.elmslie.core.store.Result { - val body = Result(state) - when { - uiEventClass.java.isAssignableFrom(event.javaClass) -> body.ui(event as Ui) - internalEventClass.java.isAssignableFrom(event.javaClass) -> body.internal(event as Internal) - else -> error("Event ${event.javaClass} is neither UI nor Internal") - } - return body.build() - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/exception/StoreAlreadyStartedException.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/store/exception/StoreAlreadyStartedException.kt deleted file mode 100644 index 9b8c6a70..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/store/exception/StoreAlreadyStartedException.kt +++ /dev/null @@ -1,7 +0,0 @@ -package vivid.money.elmslie.core.store.exception - -import java.lang.IllegalStateException - -class StoreAlreadyStartedException : IllegalStateException( - "Store is already started. Usually, it happens inside StoreHolder." -) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt deleted file mode 100644 index d9a98117..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/switcher/Switcher.kt +++ /dev/null @@ -1,71 +0,0 @@ -package vivid.money.elmslie.core.switcher - -import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.disposable.CompositeDisposable -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.DefaultActor -import java.util.concurrent.CancellationException -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit - -/** - * Allows to execute requests for [DefaultActor] implementations in a switching manner. - * Each request will cancel the previous one. - * - * Example: - * ``` - * private val switcher = Switcher() - * - * override fun execute(command: Command) = when (command) { - * is MyCommand -> switcher.switchInternal() { - * Observable.just(123) - * } - * } - * ``` - */ -class Switcher { - - private val disposable = CompositeDisposable() - private val tasks = mutableSetOf>() - private val service = ElmslieConfig.backgroundExecutor - - /** - * Executes [action] and cancels all previous requests scheduled on this [Switcher]. - * - * @param delayMillis Operation delay measured with milliseconds. - * Can be specified to debounce existing requests. - * @param action New operation to be executed. - */ - fun switchInternal( - delayMillis: Long = 0, - action: () -> Disposable, - ): Disposable { - disposable.clear() - synchronized(tasks) { - tasks.onEach { task -> task.cancel(true) }.clear() - } - val future = service.schedule( - { disposable.add(action()) }, - delayMillis, - TimeUnit.MILLISECONDS - ) - synchronized(tasks) { - tasks.add(future) - } - return Disposable { future.cancel(true) } - } - - /** - * Awaits completion of all scheduled background tasks. - */ - fun await( - delay: Long = 1, - timeUnit: TimeUnit = TimeUnit.DAYS - ) = tasks.forEach { task -> - try { - task.get(delay, timeUnit) - } catch (_: CancellationException) { - // Expected state - } - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt deleted file mode 100644 index 107734e2..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/ConcurrentHashSet.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.util - -import java.util.Collections.newSetFromMap -import java.util.concurrent.ConcurrentHashMap - -internal class ConcurrentHashSet : MutableSet by newSetFromMap(ConcurrentHashMap()) diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt deleted file mode 100644 index 3318d7eb..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/DistinctUntilChanged.kt +++ /dev/null @@ -1,17 +0,0 @@ -package vivid.money.elmslie.core.util - -/** - * Wraps a function with a thread safe filtering of subsequent equal values - */ -fun ((T) -> Unit).distinctUntilChanged() = object : (T) -> Unit { - - @Volatile - private var previousValue: T? = null - - override fun invoke(value: T) { - val isUpdated = synchronized(this) { - (previousValue != value).also { previousValue = value } - } - if (isUpdated) this@distinctUntilChanged(value) - } -} diff --git a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt b/elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt deleted file mode 100644 index 4d586acc..00000000 --- a/elmslie-core/src/main/java/vivid/money/elmslie/core/util/Option.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.util - -/** - * Use this wrapper in case when expected not null value but null value can be present - */ -data class Option(val value: T?) diff --git a/elmslie-core/src/nativeMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt b/elmslie-core/src/nativeMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt new file mode 100644 index 00000000..e0d2f43e --- /dev/null +++ b/elmslie-core/src/nativeMain/kotlin/money/vivid/elmslie/core/utils/DispatcherProvider.kt @@ -0,0 +1,7 @@ +package money.vivid.elmslie.core.utils + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO + +internal actual val IoDispatcher: CoroutineDispatcher = Dispatchers.IO \ No newline at end of file diff --git a/elmslie-core/src/nativeMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt b/elmslie-core/src/nativeMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt new file mode 100644 index 00000000..36053259 --- /dev/null +++ b/elmslie-core/src/nativeMain/kotlin/money/vivid/elmslie/core/utils/ResolveStoreKey.kt @@ -0,0 +1,8 @@ +package money.vivid.elmslie.core.utils + +import money.vivid.elmslie.core.store.StateReducer + +internal actual fun resolveStoreKey(reducer: StateReducer<*, *, *, *>): String = + (reducer::class.qualifiedName ?: reducer::class.simpleName) + .orEmpty() + .replace("Reducer", "Store") diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt deleted file mode 100644 index b07367a9..00000000 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreTest.kt +++ /dev/null @@ -1,191 +0,0 @@ -package vivid.money.elmslie.core.store - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.testutil.model.Command -import vivid.money.elmslie.core.testutil.model.Effect -import vivid.money.elmslie.core.testutil.model.Event -import vivid.money.elmslie.core.testutil.model.State -import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension -import java.util.concurrent.Executors - -class ElmStoreTest { - - @JvmField - @RegisterExtension - val executorExtension = MockBackgroundExecutorExtension() - - @Test - fun `Stopping the store works correctly`() { - val store = store(State()) - - store.start() - store.accept(Event()) - store.stop() - - assert(!store.isStarted) - } - - @Test - fun `Stopping the store stops state`() { - val worker = Executors.newSingleThreadExecutor() - val store = store( - State(), - { _, state -> - Result(state = state.copy(value = state.value + 1), command = Command()) - }, - { _, onEvent, _ -> - val future = worker.submit { - Thread.sleep(1000) - onEvent(Event()) - } - Disposable { future.cancel(true) } - } - ).start() - - val states = mutableListOf() - store.states(states::add) - store.accept(Event()) - Thread.sleep(3500) - store.stop() - - assertEquals( - mutableListOf( - State(0), // Initial state - State(1), // State after receiving trigger Event - State(2), // State after executing the first command - State(3), // State after executing the second command - State(4) // State after executing the third command - ), - states - ) - } - - @Test - fun `Event triggers state update`() { - val store = store( - State(), - { event, state -> Result(state = state.copy(value = event.value)) } - ).start() - - val states = mutableListOf() - store.states(states::add) - store.accept(Event(value = 10)) - - assertEquals( - mutableListOf( - State(0), // Initial state - State(10) // State after receiving initial Event - ), - states - ) - } - - @Test - fun `Not changed state is not emitted`() { - val store = store( - State(), - { event, state -> Result(state = state.copy(value = event.value)) } - ).start() - - val states = mutableListOf() - store.states(states::add) - - store.accept(Event(value = 0)) - - assertEquals( - mutableListOf( - State(0) // Initial state - ), - states - ) - } - - @Test - fun `Emitted effect is received by observers`() { - val store = store( - State(), - { event, state -> - Result(state = state, effect = Effect(value = event.value)) - } - ).start() - - val effects = mutableListOf() - store.effects(effects::add) - store.accept(Event(value = 1)) - store.accept(Event(value = -1)) - - assertEquals( - mutableListOf( - Effect(value = 1), // The first effect - Effect(value = -1), // The second effect - ), - effects - ) - } - - @Test - fun `Emitted effect that was received before subscribe to effects`() { - val store = store( - State(), - { event, state -> - Result(state = state, effect = Effect(value = event.value)) - } - ).start() - - val effects = mutableListOf() - store.accept(Event(value = 1)) - store.effects(effects::add) - store.accept(Event(value = -1)) - - assertEquals( - mutableListOf( - Effect(value = 1), // The first effect - Effect(value = -1), // The second effect - ), - effects - ) - } - - @Test - fun `Command result is observed by store`() { - val store = store( - State(), - { event, state -> - Result( - state = state.copy(value = event.value), - command = Command(event.value - 1).takeIf { event.value > 0 } - ) - }, - { command, onEvent, _ -> - onEvent(Event(command.value)) - Disposable {} - } - ).start() - - val states = mutableListOf() - store.states(states::add) - - store.accept(Event(3)) - store.stop() - - assertEquals( - mutableListOf( - State(0), // Initial state - State(3), // State after receiving Event with command number - State(2), // State after executing the first command - State(1), // State after executing the second command - State(0) // State after executing the third command - ), - states - ) - } - - private fun store( - state: State, - reducer: StateReducer = NoOpReducer(), - actor: DefaultActor = NoOpActor() - ) = ElmStore(state, reducer, actor) -} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt deleted file mode 100644 index 4073e0c3..00000000 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/ElmStoreWithChildTest.kt +++ /dev/null @@ -1,294 +0,0 @@ -package vivid.money.elmslie.core.store - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension -import vivid.money.elmslie.core.store.binding.coordinates -import vivid.money.elmslie.core.testutil.model.ChildCommand -import vivid.money.elmslie.core.testutil.model.ChildEffect -import vivid.money.elmslie.core.testutil.model.ChildEvent -import vivid.money.elmslie.core.testutil.model.ChildState -import vivid.money.elmslie.core.testutil.model.ParentCommand -import vivid.money.elmslie.core.testutil.model.ParentEffect -import vivid.money.elmslie.core.testutil.model.ParentEvent -import vivid.money.elmslie.core.testutil.model.ParentState -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.test.background.executor.MockBackgroundExecutorExtension - -class ElmStoreWithChildTest { - - @JvmField - @RegisterExtension - val executorExtension = MockBackgroundExecutorExtension() - - @Test - fun `Parent event is propagated to child and state update is received afterwards`() { - val parent = parentStore( - ParentState(), - { event, state -> - when (event) { - is ParentEvent.Plain -> - Result(state = state.copy(value = 10), effect = ParentEffect.ToChild(ChildEvent.First)) - is ParentEvent.ChildUpdated -> - Result(state = state.copy(childValue = event.state.value)) - } - } - ) - val child = childStore( - ChildState(), - { _, state -> - Result(state.copy(value = 100), effect = ChildEffect) - } - ) - val coordination = parent.coordinates( - child, - dispatching = { - states { ChildEvent.First } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - effects { ParentEvent.Plain } - } - ) - - val values = mutableListOf() - parent.states { values.add(it) } - - coordination.start() - parent.accept(ParentEvent.Plain) - - assertEquals( - mutableListOf( - ParentState(0, 0), - ParentState(0, 100), - ParentState(10, 100) - ), - values - ) - } - - @Test - fun `Parent effect is propagated to child and effect is received afterwards`() { - val parent = parentStore( - ParentState(), - { _, state -> Result(state, effect = ParentEffect.ToParent) } - ) - val child = childStore( - ChildState(), - { _, state -> Result(state.copy(value = 100), effect = ChildEffect) } - ) - parent.coordinates( - child, - dispatching = { - effects { ChildEvent.First } - } - ).start() - - val values = mutableListOf() - child.effects(values::add) - - parent.accept(ParentEvent.Plain) - - assertEquals( - mutableListOf(ChildEffect), - values, - ) - } - - @Test - fun `Child state update after action is propagated to the parent`() { - val parent = parentStore( - ParentState(), - { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result(state = state.copy(value = 10), effect = ParentEffect.ToParent) - } - } - ) - val child = childStore( - ChildState(), - { event, state -> - if (event == ChildEvent.First) { - Result(state.copy(value = 100), command = ChildCommand) - } else { - Result(state.copy(value = 200)) - } - }, - { command, onEvent, onError -> - onEvent(ChildEvent.Second) - Disposable {} - } - ) - parent.coordinates( - child, - dispatching = { - effects { ChildEvent.First } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - } - ).start() - - val values = mutableListOf() - parent.states { values.add(it) } - - parent.accept(ParentEvent.Plain) - - assertEquals( - mutableListOf( - ParentState(0, 0), - ParentState(10, 0), - ParentState(10, 100), - ParentState(10, 200) - ), - values, - ) - } - - @Test - fun `Stopping the binding stops both parent and child`() { - val parent = parentStore(ParentState()) - val child = childStore(ChildState()) - val binding = parent.coordinates(child).start() - - binding.stop() - - assert(!parent.isStarted) - assert(!child.isStarted) - } - - @Test - fun `Child command results are received by parents consecutively`() { - val parent = parentStore( - ParentState(), - { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result(state = state, effect = ParentEffect.ToParent) - } - } - ) - val child = childStore( - ChildState(), - { event, state -> - when (event) { - ChildEvent.First -> Result(state.copy(value = 100), command = ChildCommand) - ChildEvent.Second -> Result(state.copy(value = 200)) - ChildEvent.Third -> Result(state.copy(value = 300)) - } - }, - { _, onEvent, _ -> - onEvent(ChildEvent.Second) - onEvent(ChildEvent.Third) - Disposable { } - } - ) - parent.coordinates( - child, - dispatching = { - effects { ChildEvent.First } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - } - ).start() - - val values = mutableListOf() - parent.states(values::add) - - parent.accept(ParentEvent.Plain) - - assertEquals( - mutableListOf( - ParentState(0, 0), - ParentState(0, 100), - ParentState(0, 200), - ParentState(0, 300) - ), - values, - ) - } - - @Test - fun `Parent Effect is delivered when it's effect observation started`() { - val parent = parentStore( - ParentState(), - { event, state -> - if (event is ParentEvent.ChildUpdated) { - Result(state = state.copy(childValue = event.state.value)) - } else { - Result( - state = state.copy(value = 10), - commands = emptyList(), - effects = listOf( - ParentEffect.ToParent, - ParentEffect.ToChild(ChildEvent.First) - ) - ) - } - } - ) - val child = childStore( - ChildState(), - { event, state -> - when (event) { - ChildEvent.First -> Result(state.copy(value = 100)) - ChildEvent.Second -> Result(state) - ChildEvent.Third -> Result(state) - } - } - ) - val combined = parent.coordinates( - child, - dispatching = { - effects { (this as? ParentEffect.ToChild)?.childEvent } - }, - receiving = { - states { ParentEvent.ChildUpdated(this) } - } - ).start() - - combined.effects { /*Ignore*/ } - - val values = mutableListOf() - parent.states(values::add) - - parent.accept(ParentEvent.Plain) - - assertEquals( - mutableListOf( - ParentState(value = 0, childValue = 0), - ParentState(value = 10, childValue = 0), - ParentState(value = 10, childValue = 100), - ), - values, - ) - - // start observing effects later, simulating effects observing in onResume - val parentEffects = mutableListOf() - parent.effects(parentEffects::add) - - assertEquals( - mutableListOf( - ParentEffect.ToParent, - ParentEffect.ToChild(ChildEvent.First), - ), - parentEffects - ) - } - - private fun parentStore( - state: ParentState, - reducer: StateReducer = NoOpReducer(), - actor: DefaultActor = NoOpActor() - ): Store = ElmStore(state, reducer, actor) - - private fun childStore( - state: ChildState, - reducer: StateReducer = NoOpReducer(), - actor: DefaultActor = NoOpActor() - ): Store = ElmStore(state, reducer, actor) -} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/DslReducerTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/DslReducerTest.kt deleted file mode 100644 index 360c5361..00000000 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/DslReducerTest.kt +++ /dev/null @@ -1,115 +0,0 @@ -package vivid.money.elmslie.core.store.dsl_reducer - -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.shouldBe -import org.junit.jupiter.api.Test - -private object BasicDslReducer : DslReducer() { - - override fun Result.reduce(event: TestEvent) = when (event) { - is TestEvent.One -> { - state { copy(one = 1) } - state { copy(two = 2) } - } - is TestEvent.Two -> effects { +TestEffect.One } - is TestEvent.Three -> commands { - +TestCommand.Two - +TestCommand.One - } - is TestEvent.Four -> if (event.flag) { - state { copy(one = 1) } - commands { +TestCommand.One } - effects { +TestEffect.One } - } else { - state { copy(one = state.two, two = state.one) } - effects { +TestEffect.One } - } - is TestEvent.Five -> applyDiff() - is TestEvent.Six -> { - commands { +TestCommand.One.takeIf { event.flag } } - } - } - - // Result editing can be done in a separate function - private fun Result.applyDiff() { - state { copy(one = 0) } - state { copy(one = initialState.one + 3) } - } -} - -internal class DslReducerTest { - - private val reducer = BasicDslReducer - - @Test - fun `Multiple state updates are executed`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.One, initialState) - state shouldBe TestState(one = 1, two = 2) - effects.shouldBeEmpty() - commands.shouldBeEmpty() - } - - @Test - fun `Effect is added`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.Two, initialState) - state shouldBe initialState - effects shouldBe listOf(TestEffect.One) - commands.shouldBeEmpty() - } - - @Test - fun `Multiple commands are added`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.Three, initialState) - state shouldBe initialState - effects.shouldBeEmpty() - commands shouldBe listOf(TestCommand.Two, TestCommand.One) - } - - @Test - fun `Complex operation`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.Four(true), initialState) - state shouldBe TestState(one = 1, two = 0) - effects shouldBe listOf(TestEffect.One) - commands shouldBe listOf(TestCommand.One) - } - - @Test - fun `Condition switches state values`() { - val initialState = TestState(one = 1, two = 2) - val (state, effects, commands) = reducer.reduce(TestEvent.Four(false), initialState) - state shouldBe TestState(one = 2, two = 1) - effects shouldBe listOf(TestEffect.One) - commands.shouldBeEmpty() - } - - @Test - fun `Can access initial state`() { - val initialState = TestState(one = 1, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.Five, initialState) - state shouldBe TestState(one = 4, two = 0) - effects.shouldBeEmpty() - commands.shouldBeEmpty() - } - - @Test - fun `Add command conditionally`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.Six(true), initialState) - state shouldBe initialState - effects.shouldBeEmpty() - commands shouldBe listOf(TestCommand.One) - } - - @Test - fun `Skip command conditionally`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestEvent.Six(false), initialState) - state shouldBe initialState - effects.shouldBeEmpty() - commands.shouldBeEmpty() - } -} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/ScreenDslReducerTest.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/ScreenDslReducerTest.kt deleted file mode 100644 index a7142789..00000000 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/store/dsl_reducer/ScreenDslReducerTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package vivid.money.elmslie.core.store.dsl_reducer - -import io.kotest.matchers.collections.shouldBeEmpty -import io.kotest.matchers.shouldBe -import org.junit.jupiter.api.Test - -object BasicScreenDslReducer : - ScreenDslReducer( - TestScreenEvent.Ui::class, - TestScreenEvent.Internal::class - ) { - - override fun Result.ui(event: TestScreenEvent.Ui) = when (event) { - is TestScreenEvent.Ui.One -> state { copy(one = 1, two = 2) } - } - - override fun Result.internal(event: TestScreenEvent.Internal) = when (event) { - is TestScreenEvent.Internal.One -> commands { - +TestCommand.One - +TestCommand.Two - } - } -} - -// The same code -object PlainScreenDslReducer : DslReducer() { - - override fun Result.reduce(event: TestScreenEvent) = when (event) { - is TestScreenEvent.Ui -> reduce(event) - is TestScreenEvent.Internal -> reduce(event) - } - - private fun Result.reduce(event: TestScreenEvent.Ui) = when (event) { - is TestScreenEvent.Ui.One -> state { copy(one = 1, two = 2) } - } - - private fun Result.reduce(event: TestScreenEvent.Internal) = when (event) { - is TestScreenEvent.Internal.One -> commands { - +TestCommand.One - +TestCommand.Two - } - } -} - -internal class ScreenDslReducerTest { - - private val reducer = BasicScreenDslReducer - - @Test - fun `Ui event is executed`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestScreenEvent.Ui.One, initialState) - state shouldBe TestState(one = 1, two = 2) - effects.shouldBeEmpty() - commands.shouldBeEmpty() - } - - @Test - fun `Internal event is executed`() { - val initialState = TestState(one = 0, two = 0) - val (state, effects, commands) = reducer.reduce(TestScreenEvent.Internal.One, initialState) - state shouldBe initialState - effects.shouldBeEmpty() - commands shouldBe listOf(TestCommand.One, TestCommand.Two) - } -} diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/ChildModels.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/ChildModels.kt deleted file mode 100644 index 69eda4fc..00000000 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/ChildModels.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.core.testutil.model - -enum class ChildEvent { First, Second, Third } -object ChildEffect -object ChildCommand -data class ChildState(val value: Int = 0) diff --git a/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/ParentModels.kt b/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/ParentModels.kt deleted file mode 100644 index c00b8d84..00000000 --- a/elmslie-core/src/test/java/vivid/money/elmslie/core/testutil/model/ParentModels.kt +++ /dev/null @@ -1,15 +0,0 @@ -package vivid.money.elmslie.core.testutil.model - -sealed class ParentEvent { - object Plain : ParentEvent() - data class ChildUpdated(val state: ChildState) : ParentEvent() -} - -data class ParentState(val value: Int = 0, val childValue: Int = 0) - -sealed interface ParentEffect { - object ToParent : ParentEffect - data class ToChild(val childEvent: ChildEvent) : ParentEffect -} - -object ParentCommand diff --git a/elmslie-coroutines/build.gradle b/elmslie-coroutines/build.gradle deleted file mode 100644 index efec5182..00000000 --- a/elmslie-coroutines/build.gradle +++ /dev/null @@ -1,20 +0,0 @@ -plugins { - id("kotlin") -} - -dependencies { - implementation(project(":elmslie-core")) - implementation(deps.coroutines.core) -} - -// Enable channel flow and delicate apis. -tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all { - kotlinOptions.freeCompilerArgs += [ - "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-Xuse-experimental=kotlinx.coroutines.DelicateCoroutinesApi", - ] -} - -apply from: "../gradle/junit-5.gradle" -apply from: "../gradle/kotlin-publishing.gradle" -apply from: "../gradle/detekt.gradle" diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/Actor.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/Actor.kt deleted file mode 100644 index 7a3bb6fc..00000000 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/Actor.kt +++ /dev/null @@ -1,18 +0,0 @@ -package vivid.money.elmslie.coroutines - -import kotlinx.coroutines.flow.Flow - -/** - * Actor that supports event mappings for coroutines - */ -fun interface Actor : MappingActorCompat { - - /** - * Executes a command. - * - * Contract for implementations: - * - Implementations don't have to call subscribeOn - * - By default subscription will be on the `io` scheduler - */ - fun execute(command: Command): Flow -} diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt deleted file mode 100644 index 3ddd2ec3..00000000 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/ElmStoreCompat.kt +++ /dev/null @@ -1,64 +0,0 @@ -package vivid.money.elmslie.coroutines - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.Dispatchers.Unconfined -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.DefaultActor -import vivid.money.elmslie.core.store.StateReducer -import vivid.money.elmslie.core.store.ElmStore -import vivid.money.elmslie.core.store.Store - -/** - * Compatibility [Store] implementation applying coroutines for multithreading. - */ -class ElmStoreCompat( - initialState: State, - reducer: StateReducer, - actor: Actor -) : Store by ElmStore( - initialState = initialState, - reducer = reducer, - actor = actor.toActor() -) - -@Suppress("TooGenericExceptionCaught", "RethrowCaughtException") -private fun Actor.toActor() = - DefaultActor { command, onEvent, onError -> - val job = GlobalScope.launch(Unconfined) { - try { - execute(command) - .flowOn(IO) - .collect { event -> onEvent(event) } - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - onError(t) - } - } - Disposable { job.cancel() } - } - -/** - * Extension for accessing [Store] states as a [Flow]. - */ -val Store.states: Flow - get() = callbackFlow { - val disposable = states { state -> channel.trySend(state) } - awaitClose(disposable::dispose) - } - -/** - * Extension for accessing [Store] effects as a [Flow]. - */ -val Store.effects: Flow - get() = callbackFlow { - val disposable = effects { effect -> channel.trySend(effect) } - awaitClose(disposable::dispose) - } diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/MappingActorCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/MappingActorCompat.kt deleted file mode 100644 index e238494b..00000000 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/MappingActorCompat.kt +++ /dev/null @@ -1,77 +0,0 @@ -package vivid.money.elmslie.coroutines - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach -import vivid.money.elmslie.core.store.MappingActor - -/** - * Contains internal event mapping helpers for coroutines - */ -@Suppress("ComplexInterface", "TooManyFunctions") -interface MappingActorCompat : MappingActor { - - fun Flow.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (error: Throwable) -> Event? = { null } - ) = mapNotNull { eventMapper(it) } - .onEach { it.logSuccessEvent() } - .catch { it.logErrorEvent(errorMapper)?.let { emit(it) } ?: throw it } - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents({ successEvent }, { errorEvent })") - ) - fun Flow.mapEvents( - successEvent: Event, - errorEvent: Event - ) = mapEvents({ successEvent }, { errorEvent }) - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents(eventMapper, { errorEvent })") - ) - fun Flow.mapEvents( - eventMapper: (T) -> Event, - errorEvent: Event - ) = mapEvents(eventMapper, { errorEvent }) - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents { successEvent }") - ) - fun Flow.mapSuccessEvent( - successEvent: Event - ) = mapEvents({ successEvent }) - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents(eventMapper)") - ) - fun Flow.mapSuccessEvent( - eventMapper: (T) -> Event - ) = mapEvents(eventMapper) - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents(errorMapper = { errorEvent })") - ) - fun Flow.mapErrorEvent( - errorEvent: Event - ) = mapEvents(errorMapper = { errorEvent }) - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents(errorMapper = errorMapper)") - ) - fun Flow.mapErrorEvent( - errorMapper: (Throwable) -> Event - ) = mapEvents(errorMapper = errorMapper) - - @Deprecated( - "Use mapEvents with mapping", - ReplaceWith("this.mapEvents()") - ) - fun Flow.ignoreEvents() = mapEvents() -} diff --git a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt b/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt deleted file mode 100644 index b7c3539b..00000000 --- a/elmslie-coroutines/src/main/java/vivid/money/elmslie/coroutines/SwitcherCompat.kt +++ /dev/null @@ -1,40 +0,0 @@ -package vivid.money.elmslie.coroutines - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.flowOf -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.switcher.Switcher - -/** - * Cancels all scheduled actions after [delayMillis] pass. - * - * @param delayMillis Cancellation delay measured with milliseconds. - */ -fun Switcher.cancel(delayMillis: Long = 0) = switch(delayMillis) { flowOf() } - -/** - * @see [Switcher] - */ -@Suppress("TooGenericExceptionCaught", "RethrowCaughtException") -fun Switcher.switch( - delayMillis: Long = 0, - action: () -> Flow, -): Flow = channelFlow { - try { - val disposable = switchInternal(delayMillis) { - val job = launch { - action().collect { trySend(it) } - } - Disposable(job::cancel) - } - awaitClose(disposable::dispose) - } catch (t: CancellationException) { - throw t - } catch (t: Throwable) { - // Next action cancelled this before starting. Or some error happened while running action. - close(t) - } -} diff --git a/elmslie-plugin/README.md b/elmslie-plugin/README.md deleted file mode 100644 index 63669b90..00000000 --- a/elmslie-plugin/README.md +++ /dev/null @@ -1 +0,0 @@ -Plugin which helps to generate presentation layer. \ No newline at end of file diff --git a/elmslie-plugin/build.gradle b/elmslie-plugin/build.gradle deleted file mode 100644 index 29453826..00000000 --- a/elmslie-plugin/build.gradle +++ /dev/null @@ -1,34 +0,0 @@ -plugins { - id("org.jetbrains.intellij") - id("kotlin") -} - -group 'vivid.money' -version pluginVersion - -dependencies { - api(deps.plugin.freemarker) -} - -// See https://github.com/JetBrains/gradle-intellij-plugin/ -intellij { - version = '2018.3' - updateSinceUntilBuild = false - plugins = ['android', 'Kotlin'] - -} -// runIde.ideDir = layout.projectDirectory.dir('C://Program Files/Android/Android Studio') // for Windows -runIde.ideDir = layout.projectDirectory.dir('/Applications/Android Studio.app').getAsFile() // for MacOS - -patchPluginXml { - changeNotes = """ - Can create simple presentation layer
- """ -} - -publishPlugin { - token = "$System.env.PLUGIN_UPLOAD_TOKEN" - channels = ["stable"] -} - -apply from: "../gradle/detekt.gradle" diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/ext/ProjectExt.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/ext/ProjectExt.kt deleted file mode 100644 index 84e849a2..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/ext/ProjectExt.kt +++ /dev/null @@ -1,16 +0,0 @@ -package vivid.money.elmslie.plugin.core.ext - -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.command.CommandProcessor -import com.intellij.openapi.project.Project - -fun Project.runWriteAction(action: () -> Unit) { - ApplicationManager.getApplication().runWriteAction { - CommandProcessor.getInstance().executeCommand( - this, - { action() }, - "", - null - ) - } -} diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/ext/PsiDirectoryExt.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/ext/PsiDirectoryExt.kt deleted file mode 100644 index cabdc3c3..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/ext/PsiDirectoryExt.kt +++ /dev/null @@ -1,19 +0,0 @@ -package vivid.money.elmslie.plugin.core.ext - -import com.intellij.psi.PsiDirectory - -private const val DOT = "." - -fun PsiDirectory.formPackageName(): String { - val result = StringBuilder(this.name) - var parent: PsiDirectory = this.parent ?: return result.toString() - - while (parent.name != "java" && parent.name != "kotlin") { - result.insert(0, parent.name) - result.insert(parent.name.length, DOT) - - parent = parent.parent ?: return result.toString() - } - - return result.toString() -} diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/FileTemplateData.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/FileTemplateData.kt deleted file mode 100644 index 2a8a19f2..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/FileTemplateData.kt +++ /dev/null @@ -1,11 +0,0 @@ -package vivid.money.elmslie.plugin.core.templates - -import com.intellij.openapi.fileTypes.FileType -import com.intellij.psi.PsiDirectory - -data class FileTemplateData( - val templateFileName: String, - val outputFileName: String, - val outputFileType: FileType, - val outputFilePsiDirectory: PsiDirectory? -) diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/ModuleFileFactory.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/ModuleFileFactory.kt deleted file mode 100644 index 89cc2275..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/ModuleFileFactory.kt +++ /dev/null @@ -1,38 +0,0 @@ -package vivid.money.elmslie.plugin.core.templates - -import com.intellij.openapi.project.Project -import com.intellij.psi.PsiFile -import com.intellij.psi.PsiFileFactory -import freemarker.template.Configuration -import freemarker.template.TemplateExceptionHandler -import java.io.StringWriter - -class ModuleFileFactory(private val project: Project) { - - private val psiFileFactory by lazy { - PsiFileFactory.getInstance(project) - } - - private val freeMarkerConfig by lazy { - Configuration(Configuration.VERSION_2_3_27).apply { - setClassForTemplateLoading(javaClass, "/templates") - - defaultEncoding = Charsets.UTF_8.name() - templateExceptionHandler = TemplateExceptionHandler.RETHROW_HANDLER - logTemplateExceptions = false - wrapUncheckedExceptions = true - } - } - - - fun createFromTemplate(templateData: FileTemplateData, templateProperties: Map): PsiFile { - val template = freeMarkerConfig.getTemplate(templateData.templateFileName) - - val text = StringWriter().use { writer -> - template.process(templateProperties, writer) - writer.buffer.toString() - } - - return psiFileFactory.createFileFromText(templateData.outputFileName, templateData.outputFileType, text) - } -} diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/TemplatesConstants.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/TemplatesConstants.kt deleted file mode 100644 index 1383e52c..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/core/templates/TemplatesConstants.kt +++ /dev/null @@ -1,15 +0,0 @@ -package vivid.money.elmslie.plugin.core.templates - -object TemplatesConstants { - - // region PRESENTATION LAYER - const val PRESENTATION_MODELS_CLASS_TEMPLATE = "PresentationModels.kt.ftl" - const val PRESENTATION_MODELS_CLASS_OUTPUT_SUFFIX = "Models.kt" - - const val PRESENTATION_ACTOR_CLASS_TEMPLATE = "PresentationActor.kt.ftl" - const val PRESENTATION_ACTOR_CLASS_OUTPUT_SUFFIX = "Actor.kt" - - const val PRESENTATION_REDUCER_CLASS_TEMPLATE = "PresentationReducer.kt.ftl" - const val PRESENTATION_REDUCER_CLASS_OUTPUT_SUFFIX = "Reducer.kt" - // endregion -} diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/PresentationLayerAction.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/PresentationLayerAction.kt deleted file mode 100644 index 3c84c559..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/PresentationLayerAction.kt +++ /dev/null @@ -1,46 +0,0 @@ -package vivid.money.elmslie.plugin.presentation - -import com.intellij.openapi.actionSystem.AnAction -import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.ui.DialogBuilder -import com.intellij.openapi.vfs.VirtualFile -import vivid.money.elmslie.plugin.core.ext.runWriteAction -import vivid.money.elmslie.plugin.presentation.controller.PresentationLayerController -import vivid.money.elmslie.plugin.presentation.views.CreatePresentationLayerDialog - -private const val ERROR_SOMETHING_WRONG = "Something goes wrong during generation: " - -class PresentationLayerAction : AnAction() { - - private val regex = Regex("(java|kotlin)") - - @Suppress("TooGenericExceptionCaught") - override fun actionPerformed(e: AnActionEvent) { - e.project?.let { project -> - val target = e.dataContext.getData(PlatformDataKeys.VIRTUAL_FILE) as VirtualFile - - val dialog = CreatePresentationLayerDialog { model -> - project.runWriteAction { - try { - PresentationLayerController(project).generate(model, target) - } catch (error: Exception) { - val errorDialog = DialogBuilder() - errorDialog.setErrorText(ERROR_SOMETHING_WRONG + error.message) - errorDialog.show() - } - } - } - dialog.show() - } - } - - override fun update(e: AnActionEvent) { - val selectedFile = e.dataContext.getData(PlatformDataKeys.VIRTUAL_FILE) as VirtualFile - - val isDirectory = selectedFile.isDirectory - val isFeatureModule = selectedFile.path.contains(regex) - e.presentation.isVisible = isDirectory && isFeatureModule - e.presentation.isEnabled = isDirectory && isFeatureModule - } -} diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/controller/PresentationLayerController.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/controller/PresentationLayerController.kt deleted file mode 100644 index a655d305..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/controller/PresentationLayerController.kt +++ /dev/null @@ -1,67 +0,0 @@ -package vivid.money.elmslie.plugin.presentation.controller - -import com.intellij.openapi.project.Project -import com.intellij.openapi.vfs.VirtualFile -import com.intellij.psi.PsiDirectory -import org.jetbrains.kotlin.idea.KotlinFileType -import org.jetbrains.kotlin.idea.refactoring.toPsiDirectory -import vivid.money.elmslie.plugin.core.ext.formPackageName -import vivid.money.elmslie.plugin.core.templates.FileTemplateData -import vivid.money.elmslie.plugin.core.templates.ModuleFileFactory -import vivid.money.elmslie.plugin.core.templates.TemplatesConstants -import vivid.money.elmslie.plugin.presentation.model.PresentationLayerModel - -class PresentationLayerController(private val project: Project) { - - fun generate(model: PresentationLayerModel, target: VirtualFile) { - val targetPsiDirectory = target.toPsiDirectory(project) ?: return - val presentationPsiFolder = targetPsiDirectory.findSubdirectory("presentation") - ?: targetPsiDirectory.createSubdirectory("presentation") - - val templates = getTemplates(model, presentationPsiFolder) - val moduleFileFactory = ModuleFileFactory(project) - - val properties = mutableMapOf( - "presentation_package_name" to presentationPsiFolder.formPackageName(), - "domain_name" to model.className, - "split_events" to model.addSplittingEvents - ) - - templates.forEach { templateData -> - val psiFile = moduleFileFactory.createFromTemplate(templateData, properties) - templateData.outputFilePsiDirectory?.let { directory -> - directory.findFile(psiFile.name) ?: directory.add(psiFile) - } - } - } - - private fun getTemplates( - model: PresentationLayerModel, - presentationPsiDirectory: PsiDirectory - ): MutableList { - val templates = mutableListOf() - - FileTemplateData( - templateFileName = TemplatesConstants.PRESENTATION_MODELS_CLASS_TEMPLATE, - outputFileName = "${model.className}${TemplatesConstants.PRESENTATION_MODELS_CLASS_OUTPUT_SUFFIX}", - outputFileType = KotlinFileType.INSTANCE, - outputFilePsiDirectory = presentationPsiDirectory - ).let(templates::add) - - FileTemplateData( - templateFileName = TemplatesConstants.PRESENTATION_ACTOR_CLASS_TEMPLATE, - outputFileName = "${model.className}${TemplatesConstants.PRESENTATION_ACTOR_CLASS_OUTPUT_SUFFIX}", - outputFileType = KotlinFileType.INSTANCE, - outputFilePsiDirectory = presentationPsiDirectory - ).let(templates::add) - - FileTemplateData( - templateFileName = TemplatesConstants.PRESENTATION_REDUCER_CLASS_TEMPLATE, - outputFileName = "${model.className}${TemplatesConstants.PRESENTATION_REDUCER_CLASS_OUTPUT_SUFFIX}", - outputFileType = KotlinFileType.INSTANCE, - outputFilePsiDirectory = presentationPsiDirectory - ).let(templates::add) - - return templates - } -} diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/model/PresentationLayerModel.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/model/PresentationLayerModel.kt deleted file mode 100644 index 0c4acce0..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/model/PresentationLayerModel.kt +++ /dev/null @@ -1,6 +0,0 @@ -package vivid.money.elmslie.plugin.presentation.model - -data class PresentationLayerModel( - val className: String = "", - val addSplittingEvents: Boolean = false -) diff --git a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/views/CreatePresentationLayerDialog.kt b/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/views/CreatePresentationLayerDialog.kt deleted file mode 100644 index 0ec025f3..00000000 --- a/elmslie-plugin/src/main/kotlin/vivid/money/elmslie/plugin/presentation/views/CreatePresentationLayerDialog.kt +++ /dev/null @@ -1,53 +0,0 @@ -package vivid.money.elmslie.plugin.presentation.views - -import com.intellij.openapi.ui.DialogWrapper -import com.intellij.openapi.ui.ValidationInfo -import org.jdesktop.swingx.VerticalLayout -import vivid.money.elmslie.plugin.presentation.model.PresentationLayerModel -import javax.swing.* - -class CreatePresentationLayerDialog( - private var okButtonClickListener: ((PresentationLayerModel) -> Unit)? = null -) : DialogWrapper(true) { - - private val classNameTextField = JTextField() - private val addSplittingEventsCheckBox = JCheckBox() - - init { - super.init() - title = "Generate store" - } - - override fun createCenterPanel(): JComponent? { - val dialogPanel = JPanel(VerticalLayout()) - - val nameLabel = JLabel() - nameLabel.text = "Type class name" - dialogPanel.add(nameLabel) - - dialogPanel.add(classNameTextField) - - addSplittingEventsCheckBox.text = "Split Events to Ui/Internal events" - dialogPanel.add(addSplittingEventsCheckBox) - - return dialogPanel - } - - override fun doOKAction() { - val model = PresentationLayerModel( - className = classNameTextField.text, - addSplittingEvents = addSplittingEventsCheckBox.isSelected - ) - okButtonClickListener?.invoke(model) - super.doOKAction() - } - - override fun doValidate(): ValidationInfo? = - ValidationInfo("Field shouldn't be empty") - .takeIf { classNameTextField.text.isNullOrBlank() } - - override fun dispose() { - okButtonClickListener = null - super.dispose() - } -} diff --git a/elmslie-plugin/src/main/resources/META-INF/plugin.xml b/elmslie-plugin/src/main/resources/META-INF/plugin.xml deleted file mode 100644 index 1f917d9d..00000000 --- a/elmslie-plugin/src/main/resources/META-INF/plugin.xml +++ /dev/null @@ -1,40 +0,0 @@ - - vivid.money.elmslie.plugin.codegenerator - ELMSLIE GENERATOR - Vivid money - - - - Elmslie architecture for android: https://github.com/vivid-money/elmslie -
- How to use: -
-
    -
  • Click on the package with your source code where you want the code to be generated (Inside the project view)
  • -
  • Go to Tools/Elmslie/Generate store
  • -
  • Enter you base class name (i.e. for SomeFeatureReducer enter just "SomeFeature")
  • -
  • (Optional) Select the checkbox if you want to use the reducer dsl for ui features
  • -
  • Click OK
  • -
- ]]>
- - - com.intellij.modules.lang - org.jetbrains.kotlin - org.jetbrains.android - - - - - - - - - -
\ No newline at end of file diff --git a/elmslie-plugin/src/main/resources/templates/PresentationActor.kt.ftl b/elmslie-plugin/src/main/resources/templates/PresentationActor.kt.ftl deleted file mode 100644 index 69c4481b..00000000 --- a/elmslie-plugin/src/main/resources/templates/PresentationActor.kt.ftl +++ /dev/null @@ -1,22 +0,0 @@ -package ${presentation_package_name} - -import vivid.money.elmslie.core.store.Actor -import ${presentation_package_name}.${domain_name}Command.* -<#if split_events> -import ${presentation_package_name}.${domain_name}Event.Internal -<#else> -import ${presentation_package_name}.${domain_name}Event.* - -import io.reactivex.rxjava3.core.Observable -import javax.inject.Inject - -internal class ${domain_name}Actor @Inject constructor( - // your dependencies -) : Actor<${domain_name}Command, <#if split_events>Internal<#else>${domain_name}Event> { - - override fun execute( - command: ${domain_name}Command - ): Observable<<#if split_events>Internal<#else>${domain_name}Event> = when (command) { - // your code - } -} \ No newline at end of file diff --git a/elmslie-plugin/src/main/resources/templates/PresentationModels.kt.ftl b/elmslie-plugin/src/main/resources/templates/PresentationModels.kt.ftl deleted file mode 100644 index 93da27ba..00000000 --- a/elmslie-plugin/src/main/resources/templates/PresentationModels.kt.ftl +++ /dev/null @@ -1,37 +0,0 @@ -package ${presentation_package_name} - -internal data class ${domain_name}State( - // your code -) - -internal sealed class ${domain_name}Effect { - // your code -} - -internal sealed class ${domain_name}Command { - // your code -} - -internal sealed class ${domain_name}Event { - <#if split_events> - sealed class Internal : ${domain_name}Event() { - // your code - } - - sealed class Ui : ${domain_name}Event() { - object System { - object Init : Ui() - } - - object Click { - // your code - } - - object Action { - // your code - } - } - <#else> - // your code - -} \ No newline at end of file diff --git a/elmslie-plugin/src/main/resources/templates/PresentationReducer.kt.ftl b/elmslie-plugin/src/main/resources/templates/PresentationReducer.kt.ftl deleted file mode 100644 index 3a56e9e9..00000000 --- a/elmslie-plugin/src/main/resources/templates/PresentationReducer.kt.ftl +++ /dev/null @@ -1,33 +0,0 @@ -package ${presentation_package_name} - -import vivid.money.elmslie.core.store.Result -<#if split_events> -import vivid.money.elmslie.core.store.dsl_reducer.ScreenDslReducer -<#else> -import vivid.money.elmslie.core.store.dsl_reducer.DslReducer - -import ${presentation_package_name}.${domain_name}Command.* -<#if split_events> -import ${presentation_package_name}.${domain_name}Event.Internal -import ${presentation_package_name}.${domain_name}Event.Ui -<#else> -import ${presentation_package_name}.${domain_name}Event.* - - -internal object ${domain_name}Reducer : <#if split_events>ScreenDslReducer<#else>DslReducer<${domain_name}Event, <#if split_events>Ui, Internal, ${domain_name}State, - ${domain_name}Effect, ${domain_name}Command>(<#if split_events>Ui::class, Internal::class) { - -<#if split_events> - override fun Result.internal(event: Internal) = when (event) { - else -> TODO("Not yet implemented") - } - - override fun Result.ui(event: Ui) = when (event) { - else -> TODO("Not yet implemented") - } -<#else> - override fun Result.reduce(event: ${domain_name}Event) = when (event) { - else -> TODO("Not yet implemented") - } - -} \ No newline at end of file diff --git a/elmslie-rxjava-2/build.gradle b/elmslie-rxjava-2/build.gradle deleted file mode 100644 index 62284f53..00000000 --- a/elmslie-rxjava-2/build.gradle +++ /dev/null @@ -1,13 +0,0 @@ -plugins { - id("kotlin") -} - -dependencies { - implementation(project(":elmslie-core")) - implementation(deps.rx.rxJava2) -} - -apply from: "../gradle/junit-5.gradle" -apply from: "../gradle/kotlin-publishing.gradle" -apply from: "../gradle/android-lint.gradle" -apply from: "../gradle/detekt.gradle" diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/Actor.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/Actor.kt deleted file mode 100644 index 3578456c..00000000 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/Actor.kt +++ /dev/null @@ -1,18 +0,0 @@ -package vivid.money.elmslie.rx2 - -import io.reactivex.Observable - -/** - * Actor that supports event mappings for RxJava 2 - */ -fun interface Actor : MappingActorCompat { - - /** - * Executes a command. - * - * Contract for implementations: - * - Implementations don't have to call subscribeOn - * - By default subscription will be on the `io` scheduler - */ - fun execute(command: Command): Observable -} diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt deleted file mode 100644 index 873648b3..00000000 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/ElmStoreCompat.kt +++ /dev/null @@ -1,52 +0,0 @@ -package vivid.money.elmslie.rx2 - -import io.reactivex.Observable -import io.reactivex.schedulers.Schedulers -import vivid.money.elmslie.core.store.StateReducer -import vivid.money.elmslie.core.store.ElmStore -import vivid.money.elmslie.core.store.Store -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.DefaultActor - -/** - * A [Store] implementation that uses RxJava2 for multithreading - */ -class ElmStoreCompat( - initialState: State, - reducer: StateReducer, - actor: Actor -) : Store by ElmStore( - initialState = initialState, - reducer = reducer, - actor = actor.toActor() -) - -private fun Actor.toActor() = - DefaultActor { command, onEvent, onError -> - val disposable = execute(command) - .subscribeOn(Schedulers.io()) - .subscribe( - onEvent, - onError, - { } - ) - Disposable { disposable.dispose() } - } - -/** - * An extension for accessing [Store] states as an [Observable] - */ -val Store.states: Observable - get() = Observable.create { emitter -> - val disposable = states(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } - -/** - * An extension for accessing [Store] effects as an [Observable] - */ -val Store.effects: Observable - get() = Observable.create { emitter -> - val disposable = effects(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/MappingActorCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/MappingActorCompat.kt deleted file mode 100644 index a422ce9d..00000000 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/MappingActorCompat.kt +++ /dev/null @@ -1,226 +0,0 @@ -package vivid.money.elmslie.rx2 - -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Observable -import io.reactivex.Single -import vivid.money.elmslie.core.store.MappingActor - -/** - * Contains internal event mapping helpers for RxJava2 - */ -@Suppress("ComplexInterface", "TooManyFunctions") -interface MappingActorCompat : MappingActor { - - fun Completable.mapEvents( - completionEvent: Event? = null, - errorMapper: (Throwable) -> Event? = { null } - ): Observable = toObservable().mapEvents({ null }, errorMapper, completionEvent) - - fun Single.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (Throwable) -> Event? = { null }, - completionEvent: Event? = null, - ): Observable = toObservable().mapEvents(eventMapper, errorMapper, completionEvent) - - fun Maybe.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (throwable: Throwable) -> Event? = { null }, - completionEvent: Event? = null, - ): Observable = toObservable().mapEvents(eventMapper, errorMapper, completionEvent) - - fun Observable.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (throwable: Throwable) -> Event? = { null }, - completionEvent: Event? = null, - ): Observable = flatMapMaybe { Maybe.fromCallable { eventMapper(it) } } - .switchIfEmpty(Maybe.fromCallable { completionEvent }.toObservable()) - .doOnNext { it.logSuccessEvent() } - .onErrorResumeNext { t: Throwable -> - Maybe.fromCallable { t.logErrorEvent(errorMapper) }.toObservable() - } - - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(successEvent, { errorEvent })") - ) - fun Completable.mapEvents( - successEvent: Event, - errorEvent: Event - ): Observable = mapEvents(successEvent, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(successEvent)") - ) - fun Completable.mapSuccessEvent( - successEvent: Event - ): Observable = mapEvents(successEvent) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = { errorEvent })") - ) - fun Completable.mapErrorEvent( - errorEvent: Event - ): Observable = mapEvents(errorMapper = { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = errorMapper)") - ) - fun Completable.mapErrorEvent( - errorMapper: (Throwable) -> Event - ): Observable = mapEvents(errorMapper = errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents()") - ) - fun Completable.ignoreEvents(): Observable = mapEvents() - - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, { failureEvent })") - ) - fun Single.mapEvents( - successEvent: Event, - failureEvent: Event - ): Observable = mapEvents({ successEvent }, { failureEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, errorMapper)") - ) - fun Single.mapEvents( - successEvent: Event, - errorMapper: (Throwable) -> Event - ): Observable = mapEvents({ successEvent }, errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, { errorMapper })") - ) - fun Single.mapEvents( - eventMapper: (T) -> Event?, - errorEvent: Event - ): Observable = mapEvents(eventMapper, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent })") - ) - fun Single.mapSuccessEvent( - successEvent: Event - ): Observable = mapEvents({ successEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper)") - ) - fun Single.mapSuccessEvent( - eventMapper: (T) -> Event - ): Observable = mapEvents(eventMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = { errorEvent })") - ) - fun Single.mapErrorEvent( - errorEvent: Event - ): Observable = mapEvents(errorMapper = { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = errorMapper)") - ) - fun Single.mapErrorEvent( - errorMapper: (Throwable) -> Event - ): Observable = mapEvents(errorMapper = errorMapper) - - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, completionEvent = eventMapper(null)") - ) - fun Maybe.mapSuccessEvent( - eventMapper: (T?) -> Event - ): Observable = mapEvents(eventMapper, completionEvent = eventMapper(null)) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, eventMapper(null)") - ) - fun Maybe.mapOnlySuccessEvent( - eventMapper: (T) -> Event - ): Observable = mapEvents(eventMapper) - - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, { errorEvent }, completionEvent)") - ) - fun Maybe.mapEvents( - eventMapper: (T) -> Event, - completionEvent: Event, - errorEvent: Event - ): Observable = mapEvents(eventMapper, { errorEvent }, completionEvent) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(successEvent, errorMapper = { failureEvent )") - ) - fun Observable.mapEvents( - successEvent: Event, - errorEvent: Event - ): Observable = mapEvents({ successEvent }, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, errorMapper = { errorEvent )") - ) - fun Observable.mapEvents( - eventMapper: (T) -> Event, - errorEvent: Event - ): Observable = mapEvents(eventMapper, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent })") - ) - fun Observable.mapSuccessEvent( - successEvent: Event - ): Observable = mapEvents({ successEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper)") - ) - fun Observable.mapSuccessEvent( - eventMapper: (T) -> Event - ): Observable = mapEvents(eventMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = { errorEvent })") - ) - fun Observable.mapErrorEvent( - errorEvent: Event - ): Observable = mapEvents(errorMapper = { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = errorMapper)") - ) - fun Observable.mapErrorEvent( - errorMapper: (Throwable) -> Event - ): Observable = mapEvents(errorMapper = errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents()") - ) - fun Observable.ignoreEvents(): Observable = mapEvents() -} diff --git a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt b/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt deleted file mode 100644 index cbf6d989..00000000 --- a/elmslie-rxjava-2/src/main/java/vivid/money/elmslie/rx2/switcher/SwitcherCompat.kt +++ /dev/null @@ -1,57 +0,0 @@ -package vivid.money.elmslie.rx2.switcher - -import io.reactivex.Completable -import io.reactivex.Maybe -import io.reactivex.Observable -import io.reactivex.Single -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.switcher.Switcher - -/** - * Cancels all scheduled actions after [delayMillis] pass. - * - * @param delayMillis Cancellation delay measured with milliseconds. - */ -fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observable.empty() } - -/** - * Executes an [action] and cancels all previous requests scheduled for this [Switcher]. - * - * @param delayMillis Operation delay measured with milliseconds. - * Can be specified to debounce requests. - * @param action Operation to be executed. - */ -fun Switcher.observable( - delayMillis: Long = 0, - action: () -> Observable, -): Observable = Observable.create { emitter -> - val disposable = switchInternal(delayMillis) { - val rxDisposable = action().subscribe(emitter::onNext, emitter::onError) - Disposable { rxDisposable.dispose() } - } - emitter.setCancellable { disposable.dispose() } -} - -/** - * Same as [observable], but for [Single]. - */ -fun Switcher.single( - delayMillis: Long = 0, - action: () -> Single, -): Single = observable(delayMillis) { action().toObservable() }.firstOrError() - -/** - * Same as [observable], but for [Maybe]. - */ -fun Switcher.maybe( - delayMillis: Long = 0, - action: () -> Maybe, -): Maybe = observable(delayMillis) { action().toObservable() }.firstElement() - -/** - * Same as [observable], but for [Completable]. - */ -fun Switcher.completable( - delayMillis: Long = 0, - action: () -> Completable, -): Completable = observable(delayMillis) { action().toObservable() }.ignoreElements() diff --git a/elmslie-rxjava-3/build.gradle b/elmslie-rxjava-3/build.gradle deleted file mode 100644 index 1c95382f..00000000 --- a/elmslie-rxjava-3/build.gradle +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - id("kotlin") -} - -dependencies { - implementation(project(":elmslie-core")) - implementation(deps.rx.rxJava3) - - testImplementation(project(":elmslie-test-rxjava-3")) -} - -apply from: "../gradle/junit-5.gradle" -apply from: "../gradle/kotlin-publishing.gradle" -apply from: "../gradle/detekt.gradle" diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/Actor.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/Actor.kt deleted file mode 100644 index 9b35f7a3..00000000 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/Actor.kt +++ /dev/null @@ -1,18 +0,0 @@ -package vivid.money.elmslie.rx3 - -import io.reactivex.rxjava3.core.Observable - -/** - * Actor that supports event mappings for RxJava 3 - */ -fun interface Actor : MappingActorCompat { - - /** - * Executes a command. - * - * Contract for implementations: - * - Implementations don't have to call subscribeOn - * - By default subscription will be on the `io` scheduler - */ - fun execute(command: Command): Observable -} diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt deleted file mode 100644 index de667303..00000000 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/ElmStoreCompat.kt +++ /dev/null @@ -1,51 +0,0 @@ -package vivid.money.elmslie.rx3 - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers -import vivid.money.elmslie.core.store.StateReducer -import vivid.money.elmslie.core.store.ElmStore -import vivid.money.elmslie.core.store.Store -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.store.DefaultActor - -/** - * A [Store] implementation that uses RxJava3 for multithreading - */ -class ElmStoreCompat( - initialState: State, - reducer: StateReducer, - actor: Actor -) : Store by ElmStore( - initialState = initialState, - reducer = reducer, - actor = actor.toActor() -) - -private fun Actor.toActor() = - DefaultActor { command, onEvent, onError -> - val disposable = execute(command) - .observeOn(Schedulers.io()) - .subscribe( - onEvent, - onError, - ) - Disposable { disposable.dispose() } - } - -/** - * An extension for accessing [Store] states as an [Observable] - */ -val Store.states: Observable - get() = Observable.create { emitter -> - val disposable = states(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } - -/** - * An extension for accessing [Store] effects as an [Observable] - */ -val Store.effects: Observable - get() = Observable.create { emitter -> - val disposable = effects(emitter::onNext) - emitter.setCancellable { disposable.dispose() } - } diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/MappingActorCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/MappingActorCompat.kt deleted file mode 100644 index 84bd3e36..00000000 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/MappingActorCompat.kt +++ /dev/null @@ -1,221 +0,0 @@ -package vivid.money.elmslie.rx3 - -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Maybe -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import vivid.money.elmslie.core.store.MappingActor - -/** - * Contains internal event mapping helpers for RxJava3 - */ -@Suppress("ComplexInterface", "TooManyFunctions") -interface MappingActorCompat : MappingActor { - - fun Completable.mapEvents( - completionEvent: Event? = null, - errorMapper: (Throwable) -> Event? = { null } - ): Observable = toObservable().mapEvents({ null }, errorMapper, completionEvent) - - fun Single.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (Throwable) -> Event? = { null }, - completionEvent: Event? = null, - ): Observable = toObservable().mapEvents(eventMapper, errorMapper, completionEvent) - - fun Maybe.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (throwable: Throwable) -> Event? = { null }, - completionEvent: Event? = null, - ): Observable = toObservable().mapEvents(eventMapper, errorMapper, completionEvent) - - fun Observable.mapEvents( - eventMapper: (T) -> Event? = { null }, - errorMapper: (throwable: Throwable) -> Event? = { null }, - completionEvent: Event? = null, - ): Observable = flatMapMaybe { Maybe.fromCallable { eventMapper(it) } } - .switchIfEmpty(Maybe.fromCallable { completionEvent }.toObservable()) - .doOnNext { it.logSuccessEvent() } - .onErrorResumeNext { Observable.fromIterable(listOfNotNull(it.logErrorEvent(errorMapper))) } - - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, { errorEvent })") - ) - fun Completable.mapEvents( - successEvent: Event, - errorEvent: Event - ): Observable = mapEvents(successEvent) { errorEvent } - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents()") - ) - fun Completable.ignoreEvents(): Observable = mapEvents() - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent })") - ) - fun Completable.mapSuccessEvent( - successEvent: Event - ): Observable = mapEvents(successEvent) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = { errorEvent })") - ) - fun Completable.mapErrorEvent( - errorEvent: Event - ): Observable = mapEvents(null, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = errorMapper)") - ) - fun Completable.mapErrorEvent( - errorMapper: (Throwable) -> Event - ): Observable = mapEvents(errorMapper = errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, { errorEvent })") - ) - fun Single.mapEvents( - successEvent: Event, - errorEvent: Event - ): Observable = mapEvents({ successEvent }, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, errorMapper)") - ) - fun Single.mapEvents( - successEvent: Event, - errorMapper: (Throwable) -> Event - ): Observable = mapEvents({ successEvent }, errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, { errorEvent })") - ) - fun Single.mapEvents( - eventMapper: (T) -> Event?, - errorEvent: Event - ): Observable = mapEvents(eventMapper, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents { successEvent }") - ) - fun Single.mapSuccessEvent( - successEvent: Event - ): Observable = mapEvents({ successEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper)") - ) - fun Single.mapSuccessEvent( - eventMapper: (T) -> Event - ): Observable = mapEvents(eventMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = { errorEvent })") - ) - fun Single.mapErrorEvent( - errorEvent: Event - ): Observable = mapEvents(eventMapper = { it }, errorMapper = { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, { errorEvent })") - ) - fun Single.mapErrorEvent( - errorMapper: (Throwable) -> Event - ): Observable = mapEvents(eventMapper = { it }, errorMapper = errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, eventMapper(null))") - ) - fun Maybe.mapSuccessEvent( - eventMapper: (T?) -> Event? - ): Observable = mapEvents(eventMapper, completionEvent = eventMapper(null)) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper)") - ) - fun Maybe.mapOnlySuccessEvent( - eventMapper: (T) -> Event - ): Observable = mapEvents(eventMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, completionEvent, { errorEvent })") - ) - fun Maybe.mapEvents( - eventMapper: (T) -> Event, - completionEvent: Event, - errorEvent: Event - ): Observable = mapEvents(eventMapper, { errorEvent }, completionEvent) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent }, { errorEvent })") - ) - fun Observable.mapEvents( - successEvent: Event, - errorEvent: Event - ): Observable = mapEvents({ successEvent }, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper, { errorEvent })") - ) - fun Observable.mapEvents( - eventMapper: (T) -> Event, - errorEvent: Event - ): Observable = mapEvents(eventMapper, { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents({ successEvent })") - ) - fun Observable.mapSuccessEvent( - successEvent: Event - ): Observable = mapEvents({ successEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(eventMapper)") - ) - fun Observable.mapSuccessEvent( - eventMapper: (T) -> Event - ): Observable = mapEvents(eventMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorMapper = { errorEvent })") - ) - fun Observable.mapErrorEvent( - errorEvent: Event - ): Observable = mapEvents(eventMapper = { it }, errorMapper = { errorEvent }) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents(errorEvent = { errorEvent })") - ) - fun Observable.mapErrorEvent( - errorMapper: (Throwable) -> Event - ): Observable = mapEvents(eventMapper = { it }, errorMapper = errorMapper) - - @Deprecated( - "Please, use the default mapEvents method", - ReplaceWith("mapEvents()") - ) - fun Observable.ignoreEvents(): Observable = mapEvents() -} diff --git a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt b/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt deleted file mode 100644 index 32538d53..00000000 --- a/elmslie-rxjava-3/src/main/java/vivid/money/elmslie/rx3/switcher/SwitcherCompat.kt +++ /dev/null @@ -1,80 +0,0 @@ -package vivid.money.elmslie.rx3.switcher - -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Maybe -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.core.Single -import vivid.money.elmslie.core.disposable.Disposable -import vivid.money.elmslie.core.switcher.Switcher - -/** - * Cancels all scheduled actions after [delayMillis] pass. - * - * @param delayMillis Cancellation delay measured with milliseconds. - */ -fun Switcher.cancel(delayMillis: Long = 0) = observable(delayMillis) { Observable.empty() } - -/** - * Executes [action] and cancels all previous requests scheduled on this [Switcher] - * - * @param delayMillis Operation delay in milliseconds. Can be used to debounce requests - * @param action Operation to be executed - */ -fun Switcher.observable( - delayMillis: Long = 0, - action: () -> Observable, -): Observable = Observable.create { emitter -> - val disposable = switchInternal(delayMillis) { - val rxDisposable = action() - .doOnComplete(emitter::onComplete) - .subscribe(emitter::onNext, emitter::onError) - Disposable { - emitter.onComplete() - rxDisposable.dispose() - } - } - emitter.setCancellable(disposable::dispose) -} - -/** - * Same as [observable], but for [Single]. - */ -fun Switcher.single( - delayMillis: Long = 0, - action: () -> Single, -): Single = observable(delayMillis) { action().toObservable() }.firstOrError() - -/** - * Same as [observable], but for [Maybe]. - */ -fun Switcher.maybe( - delayMillis: Long = 0, - action: () -> Maybe, -): Maybe = observable(delayMillis) { action().toObservable() }.firstElement() - -/** - * Same as [observable], but for [Completable]. - */ -fun Switcher.completable( - delayMillis: Long = 0, - action: () -> Completable, -): Completable = observable(delayMillis) { action().toObservable() }.ignoreElements() - -@Deprecated( - "Please, use property methods", - ReplaceWith("observable(delayMillis, action)") -) -fun Switcher.switch( - delayMillis: Long = 0, - action: () -> Observable, -) = observable(delayMillis, action) - -@Deprecated( - "Please use instance methods", - ReplaceWith("switcher.observable(delayMillis, action)") -) -fun switchOn( - switcher: Switcher, - delayMillis: Long = 0, - action: () -> Observable -) = switcher.observable(delayMillis, action) diff --git a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt b/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt deleted file mode 100644 index 70c5ac03..00000000 --- a/elmslie-rxjava-3/src/test/java/vivid/money/elmslie/rx3/switcher/SwitcherCompatTest.kt +++ /dev/null @@ -1,160 +0,0 @@ -package vivid.money.elmslie.rx3.switcher - -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.TestScheduler -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension -import vivid.money.elmslie.core.switcher.Switcher -import vivid.money.elmslie.test.TestSchedulerExtension -import java.util.concurrent.TimeUnit - -/** - * Area for improvement: assert negative scenarios. - * There's no reason to test other methods since they're very similar. - */ -internal class SwitcherCompatTest { - - private val scheduler = TestScheduler() - - @JvmField - @RegisterExtension - val extension = TestSchedulerExtension(scheduler) - - object Event - - @Test - fun `Switcher executes immediate action`() { - val switcher = Switcher() - - val observer = switcher.observable(0) { - Observable.just(Event) - }.test() - - switcher.await() - scheduler.triggerActions() - - observer.assertResult(Event) - } - - @Test - fun `Switcher cancels previous request`() { - val switcher = Switcher() - - val firstObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() - - switcher.await() - scheduler.triggerActions() - - val secondObserver = switcher.observable(0) { - Observable.just(Event) - }.test() - - switcher.await() - scheduler.triggerActions() - - secondObserver.assertResult(Event) - firstObserver.assertResult() - } - - @Test - fun `Switcher executes sequential requests`() { - val switcher = Switcher() - - val firstObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() - - switcher.await() - scheduler.advanceTimeBy(2, TimeUnit.SECONDS) - - val secondObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() - - switcher.await() - scheduler.advanceTimeBy(2, TimeUnit.SECONDS) - - firstObserver.assertResult(Event) - secondObserver.assertResult(Event) - } - - @Test - fun `Switcher cancels delayed request`() { - val switcher = Switcher() - - val firstObserver = switcher.observable(1000L) { - Observable.just(Event) - }.test() - - val secondObserver = switcher.observable(0L) { - Observable.just(Event) - }.test() - - switcher.await() - scheduler.triggerActions() - - firstObserver.assertValuesOnly() - secondObserver.assertResult(Event) - } - - @Test - fun `Switcher cancels pending requests`() { - val switcher = Switcher() - - val firstObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() - - val secondObserver = switcher.observable(0) { - Observable.timer(2, TimeUnit.SECONDS).map { Event } - }.test() - - val thirdObserver = switcher.observable(0) { - Observable.just(Event) - }.test() - - switcher.await() - scheduler.advanceTimeBy(1, TimeUnit.SECONDS) - - firstObserver.assertValuesOnly() - secondObserver.assertValuesOnly() - thirdObserver.assertResult(Event) - } - - @Test - fun `Switcher cancels consecutive requests`() { - val switcher = Switcher() - - val firstObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() - - scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - - val secondObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() - - scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - - val thirdObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() - - scheduler.advanceTimeBy(250, TimeUnit.MILLISECONDS) - - val fourthObserver = switcher.observable(300L) { - Observable.just(Event) - }.test() - - scheduler.advanceTimeBy(1000, TimeUnit.MILLISECONDS) - switcher.await() - - firstObserver.assertValuesOnly() - secondObserver.assertValuesOnly() - thirdObserver.assertValuesOnly() - fourthObserver.assertResult(Event) - } -} diff --git a/elmslie-samples/android-loader/build.gradle b/elmslie-samples/android-loader/build.gradle deleted file mode 100644 index cc328500..00000000 --- a/elmslie-samples/android-loader/build.gradle +++ /dev/null @@ -1,25 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") -} - -android { - buildFeatures.buildConfig = true - defaultConfig.multiDexEnabled true -} - -dependencies { - implementation(project(":elmslie-android")) - implementation(project(":elmslie-core")) - implementation(project(":elmslie-rxjava-2")) - - implementation(deps.android.appcompat) - implementation(deps.android.material) - implementation(deps.android.multidex) - implementation(deps.rx.rxJava2) -} - -apply from: "../../gradle/junit-5.gradle" -apply from: "../../gradle/android-library.gradle" -apply from: "../../gradle/detekt.gradle" -apply from: "../../gradle/android-lint.gradle" diff --git a/elmslie-samples/android-loader/src/main/AndroidManifest.xml b/elmslie-samples/android-loader/src/main/AndroidManifest.xml deleted file mode 100644 index f1633c7b..00000000 --- a/elmslie-samples/android-loader/src/main/AndroidManifest.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt b/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt deleted file mode 100644 index 5cb115f6..00000000 --- a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/App.kt +++ /dev/null @@ -1,13 +0,0 @@ -package vivid.money.elmslie.samples.android.loader - -import androidx.multidex.MultiDexApplication -import vivid.money.elmslie.core.config.ElmslieConfig -import vivid.money.elmslie.core.logger.strategy.IgnoreLog - -class App : MultiDexApplication() { - - override fun onCreate() { - super.onCreate() - ElmslieConfig.apply { if (BuildConfig.DEBUG) logger { always(IgnoreLog) } else logger { always(IgnoreLog) } } - } -} diff --git a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt b/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt deleted file mode 100644 index 796b733e..00000000 --- a/elmslie-samples/android-loader/src/main/java/vivid/money/elmslie/samples/android/loader/MainActivity.kt +++ /dev/null @@ -1,35 +0,0 @@ -package vivid.money.elmslie.samples.android.loader - -import android.os.Bundle -import android.widget.Button -import android.widget.TextView -import com.google.android.material.snackbar.Snackbar -import vivid.money.elmslie.android.base.ElmActivity -import vivid.money.elmslie.samples.android.loader.elm.Effect -import vivid.money.elmslie.samples.android.loader.elm.Event -import vivid.money.elmslie.samples.android.loader.elm.State -import vivid.money.elmslie.samples.android.loader.elm.storeFactory - -class MainActivity : ElmActivity(R.layout.activity_main) { - - override val initEvent: Event = Event.Ui.Init - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - findViewById