diff --git a/artifacts.json b/artifacts.json index 3890a37a8..8c9f90989 100644 --- a/artifacts.json +++ b/artifacts.json @@ -188,6 +188,60 @@ "javaVersion": 8, "publicationName": "maven" }, + { + "gradlePath": ":workflow-ui:core", + "group": "com.squareup.workflow1", + "artifactId": "workflow-ui-core-iosarm64", + "description": "Workflow UI Core", + "packaging": "klib", + "javaVersion": 8, + "publicationName": "iosArm64" + }, + { + "gradlePath": ":workflow-ui:core", + "group": "com.squareup.workflow1", + "artifactId": "workflow-ui-core-iossimulatorarm64", + "description": "Workflow UI Core", + "packaging": "klib", + "javaVersion": 8, + "publicationName": "iosSimulatorArm64" + }, + { + "gradlePath": ":workflow-ui:core", + "group": "com.squareup.workflow1", + "artifactId": "workflow-ui-core-iosx64", + "description": "Workflow UI Core", + "packaging": "klib", + "javaVersion": 8, + "publicationName": "iosX64" + }, + { + "gradlePath": ":workflow-ui:core", + "group": "com.squareup.workflow1", + "artifactId": "workflow-ui-core-js", + "description": "Workflow UI Core", + "packaging": "klib", + "javaVersion": 8, + "publicationName": "js" + }, + { + "gradlePath": ":workflow-ui:core", + "group": "com.squareup.workflow1", + "artifactId": "workflow-ui-core-jvm", + "description": "Workflow UI Core", + "packaging": "jar", + "javaVersion": 8, + "publicationName": "jvm" + }, + { + "gradlePath": ":workflow-ui:core", + "group": "com.squareup.workflow1", + "artifactId": "workflow-ui-core", + "description": "Workflow UI Core", + "packaging": "jar", + "javaVersion": 8, + "publicationName": "kotlinMultiplatform" + }, { "gradlePath": ":workflow-ui:core-android", "group": "com.squareup.workflow1", diff --git a/settings.gradle.kts b/settings.gradle.kts index 0cc8d2b26..f0d7719eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -69,6 +69,7 @@ include( ":workflow-tracing", ":workflow-ui:compose", ":workflow-ui:compose-tooling", + ":workflow-ui:core", ":workflow-ui:core-common", ":workflow-ui:core-android", ":workflow-ui:internal-testing-android", diff --git a/workflow-ui/core/api/core.api b/workflow-ui/core/api/core.api new file mode 100644 index 000000000..28781a928 --- /dev/null +++ b/workflow-ui/core/api/core.api @@ -0,0 +1,277 @@ +public abstract interface class com/squareup/workflow1/ui/Compatible { + public static final field Companion Lcom/squareup/workflow1/ui/Compatible$Companion; + public abstract fun getCompatibilityKey ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/Compatible$Companion { + public final fun keyFor (Ljava/lang/Object;Ljava/lang/String;)Ljava/lang/String; + public static synthetic fun keyFor$default (Lcom/squareup/workflow1/ui/Compatible$Companion;Ljava/lang/Object;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/CompatibleKt { + public static final fun compatible (Ljava/lang/Object;Ljava/lang/Object;)Z +} + +public abstract interface class com/squareup/workflow1/ui/Container { + public abstract fun asSequence ()Lkotlin/sequences/Sequence; + public abstract fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Container; +} + +public final class com/squareup/workflow1/ui/EnvironmentScreen : com/squareup/workflow1/ui/Screen, com/squareup/workflow1/ui/Wrapper { + public fun (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)V + public synthetic fun (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun asSequence ()Lkotlin/sequences/Sequence; + public fun getCompatibilityKey ()Ljava/lang/String; + public fun getContent ()Lcom/squareup/workflow1/ui/Screen; + public synthetic fun getContent ()Ljava/lang/Object; + public final fun getEnvironment ()Lcom/squareup/workflow1/ui/ViewEnvironment; + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Container; + public fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/EnvironmentScreen; + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper; +} + +public final class com/squareup/workflow1/ui/EnvironmentScreenKt { + public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/EnvironmentScreen; + public static final fun withEnvironment (Lcom/squareup/workflow1/ui/Screen;Lkotlin/Pair;)Lcom/squareup/workflow1/ui/EnvironmentScreen; + public static synthetic fun withEnvironment$default (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/EnvironmentScreen; + public static final fun withRegistry (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/EnvironmentScreen; +} + +public final class com/squareup/workflow1/ui/NamedScreen : com/squareup/workflow1/ui/Screen, com/squareup/workflow1/ui/Wrapper { + public fun (Lcom/squareup/workflow1/ui/Screen;Ljava/lang/String;)V + public fun asSequence ()Lkotlin/sequences/Sequence; + public final fun component1 ()Lcom/squareup/workflow1/ui/Screen; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Lcom/squareup/workflow1/ui/Screen;Ljava/lang/String;)Lcom/squareup/workflow1/ui/NamedScreen; + public static synthetic fun copy$default (Lcom/squareup/workflow1/ui/NamedScreen;Lcom/squareup/workflow1/ui/Screen;Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/NamedScreen; + public fun equals (Ljava/lang/Object;)Z + public fun getCompatibilityKey ()Ljava/lang/String; + public fun getContent ()Lcom/squareup/workflow1/ui/Screen; + public synthetic fun getContent ()Ljava/lang/Object; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Container; + public fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/NamedScreen; + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper; + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/squareup/workflow1/ui/Screen { +} + +public abstract interface class com/squareup/workflow1/ui/TextController { + public abstract fun getOnTextChanged ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getTextValue ()Ljava/lang/String; + public abstract fun setTextValue (Ljava/lang/String;)V +} + +public final class com/squareup/workflow1/ui/TextControllerKt { + public static final fun TextController (Ljava/lang/String;)Lcom/squareup/workflow1/ui/TextController; + public static synthetic fun TextController$default (Ljava/lang/String;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/TextController; +} + +public final class com/squareup/workflow1/ui/ViewEnvironment { + public static final field Companion Lcom/squareup/workflow1/ui/ViewEnvironment$Companion; + public fun equals (Ljava/lang/Object;)Z + public final fun get (Lcom/squareup/workflow1/ui/ViewEnvironmentKey;)Ljava/lang/Object; + public final fun getMap ()Ljava/util/Map; + public fun hashCode ()I + public final fun plus (Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/ViewEnvironment; + public final fun plus (Lkotlin/Pair;)Lcom/squareup/workflow1/ui/ViewEnvironment; + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/ViewEnvironment$Companion { + public final fun getEMPTY ()Lcom/squareup/workflow1/ui/ViewEnvironment; +} + +public abstract class com/squareup/workflow1/ui/ViewEnvironmentKey { + public fun ()V + public fun combine (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public final fun equals (Ljava/lang/Object;)Z + public abstract fun getDefault ()Ljava/lang/Object; + public final fun hashCode ()I +} + +public abstract interface class com/squareup/workflow1/ui/ViewRegistry { + public static final field Companion Lcom/squareup/workflow1/ui/ViewRegistry$Companion; + public abstract fun getEntryFor (Lcom/squareup/workflow1/ui/ViewRegistry$Key;)Lcom/squareup/workflow1/ui/ViewRegistry$Entry; + public abstract fun getKeys ()Ljava/util/Set; +} + +public final class com/squareup/workflow1/ui/ViewRegistry$Companion : com/squareup/workflow1/ui/ViewEnvironmentKey { + public fun combine (Lcom/squareup/workflow1/ui/ViewRegistry;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/ViewRegistry; + public synthetic fun combine (Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object; + public fun getDefault ()Lcom/squareup/workflow1/ui/ViewRegistry; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +public abstract interface class com/squareup/workflow1/ui/ViewRegistry$Entry { + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; +} + +public final class com/squareup/workflow1/ui/ViewRegistry$Key { + public fun (Lkotlin/reflect/KClass;Lkotlin/reflect/KClass;)V + public fun equals (Ljava/lang/Object;)Z + public final fun getFactoryType ()Lkotlin/reflect/KClass; + public final fun getRenderingType ()Lkotlin/reflect/KClass; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/ViewRegistryKt { + public static final fun ViewRegistry ()Lcom/squareup/workflow1/ui/ViewRegistry; + public static final fun ViewRegistry ([Lcom/squareup/workflow1/ui/ViewRegistry$Entry;)Lcom/squareup/workflow1/ui/ViewRegistry; + public static final fun merge (Lcom/squareup/workflow1/ui/ViewRegistry;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/ViewRegistry; + public static final fun plus (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/ViewEnvironment; + public static final fun plus (Lcom/squareup/workflow1/ui/ViewRegistry;Lcom/squareup/workflow1/ui/ViewRegistry$Entry;)Lcom/squareup/workflow1/ui/ViewRegistry; + public static final fun plus (Lcom/squareup/workflow1/ui/ViewRegistry;Lcom/squareup/workflow1/ui/ViewRegistry;)Lcom/squareup/workflow1/ui/ViewRegistry; +} + +public abstract interface annotation class com/squareup/workflow1/ui/WorkflowUiExperimentalApi : java/lang/annotation/Annotation { +} + +public abstract interface class com/squareup/workflow1/ui/Wrapper : com/squareup/workflow1/ui/Compatible, com/squareup/workflow1/ui/Container { + public abstract fun asSequence ()Lkotlin/sequences/Sequence; + public abstract fun getCompatibilityKey ()Ljava/lang/String; + public abstract fun getContent ()Ljava/lang/Object; + public abstract fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper; +} + +public final class com/squareup/workflow1/ui/Wrapper$DefaultImpls { + public static fun asSequence (Lcom/squareup/workflow1/ui/Wrapper;)Lkotlin/sequences/Sequence; + public static fun getCompatibilityKey (Lcom/squareup/workflow1/ui/Wrapper;)Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/navigation/AlertOverlay : com/squareup/workflow1/ui/navigation/ModalOverlay { + public fun (Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/Map; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Z + public final fun component5 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/AlertOverlay; + public static synthetic fun copy$default (Lcom/squareup/workflow1/ui/navigation/AlertOverlay;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/navigation/AlertOverlay; + public fun equals (Ljava/lang/Object;)Z + public final fun getButtons ()Ljava/util/Map; + public final fun getCancelable ()Z + public final fun getMessage ()Ljava/lang/String; + public final fun getOnEvent ()Lkotlin/jvm/functions/Function1; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/navigation/AlertOverlay$Button : java/lang/Enum { + public static final field NEGATIVE Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; + public static final field NEUTRAL Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; + public static final field POSITIVE Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; + public static fun values ()[Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; +} + +public abstract class com/squareup/workflow1/ui/navigation/AlertOverlay$Event { +} + +public final class com/squareup/workflow1/ui/navigation/AlertOverlay$Event$ButtonClicked : com/squareup/workflow1/ui/navigation/AlertOverlay$Event { + public fun (Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button;)V + public final fun component1 ()Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; + public final fun copy (Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button;)Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Event$ButtonClicked; + public static synthetic fun copy$default (Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Event$ButtonClicked;Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button;ILjava/lang/Object;)Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Event$ButtonClicked; + public fun equals (Ljava/lang/Object;)Z + public final fun getButton ()Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Button; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/navigation/AlertOverlay$Event$Canceled : com/squareup/workflow1/ui/navigation/AlertOverlay$Event { + public static final field INSTANCE Lcom/squareup/workflow1/ui/navigation/AlertOverlay$Event$Canceled; +} + +public final class com/squareup/workflow1/ui/navigation/BackStackConfig : java/lang/Enum { + public static final field Companion Lcom/squareup/workflow1/ui/navigation/BackStackConfig$Companion; + public static final field First Lcom/squareup/workflow1/ui/navigation/BackStackConfig; + public static final field None Lcom/squareup/workflow1/ui/navigation/BackStackConfig; + public static final field Other Lcom/squareup/workflow1/ui/navigation/BackStackConfig; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/squareup/workflow1/ui/navigation/BackStackConfig; + public static fun values ()[Lcom/squareup/workflow1/ui/navigation/BackStackConfig; +} + +public final class com/squareup/workflow1/ui/navigation/BackStackConfig$Companion : com/squareup/workflow1/ui/ViewEnvironmentKey { + public fun getDefault ()Lcom/squareup/workflow1/ui/navigation/BackStackConfig; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +public final class com/squareup/workflow1/ui/navigation/BackStackConfigKt { + public static final fun plus (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/navigation/BackStackConfig;)Lcom/squareup/workflow1/ui/ViewEnvironment; +} + +public final class com/squareup/workflow1/ui/navigation/BackStackScreen : com/squareup/workflow1/ui/Container, com/squareup/workflow1/ui/Screen { + public static final field Companion Lcom/squareup/workflow1/ui/navigation/BackStackScreen$Companion; + public fun (Lcom/squareup/workflow1/ui/Screen;[Lcom/squareup/workflow1/ui/Screen;)V + public fun asSequence ()Lkotlin/sequences/Sequence; + public fun equals (Ljava/lang/Object;)Z + public final fun get (I)Lcom/squareup/workflow1/ui/Screen; + public final fun getBackStack ()Ljava/util/List; + public final fun getFrames ()Ljava/util/List; + public final fun getTop ()Lcom/squareup/workflow1/ui/Screen; + public fun hashCode ()I + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Container; + public fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; + public final fun mapIndexed (Lkotlin/jvm/functions/Function2;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; + public fun toString ()Ljava/lang/String; +} + +public final class com/squareup/workflow1/ui/navigation/BackStackScreen$Companion { + public final fun fromList (Ljava/util/List;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; + public final fun fromListOrNull (Ljava/util/List;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; +} + +public final class com/squareup/workflow1/ui/navigation/BackStackScreenKt { + public static final fun plus (Lcom/squareup/workflow1/ui/navigation/BackStackScreen;Lcom/squareup/workflow1/ui/navigation/BackStackScreen;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; + public static final fun toBackStackScreen (Ljava/util/List;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; + public static final fun toBackStackScreenOrNull (Ljava/util/List;)Lcom/squareup/workflow1/ui/navigation/BackStackScreen; +} + +public final class com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen : com/squareup/workflow1/ui/Compatible, com/squareup/workflow1/ui/Screen { + public fun (Lcom/squareup/workflow1/ui/Screen;Ljava/util/List;Ljava/lang/String;)V + public synthetic fun (Lcom/squareup/workflow1/ui/Screen;Ljava/util/List;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBody ()Lcom/squareup/workflow1/ui/Screen; + public fun getCompatibilityKey ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getOverlays ()Ljava/util/List; + public final fun mapBody (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen; + public final fun mapOverlays (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen; +} + +public final class com/squareup/workflow1/ui/navigation/FullScreenModal : com/squareup/workflow1/ui/navigation/ModalOverlay, com/squareup/workflow1/ui/navigation/ScreenOverlay { + public fun (Lcom/squareup/workflow1/ui/Screen;)V + public fun asSequence ()Lkotlin/sequences/Sequence; + public fun getCompatibilityKey ()Ljava/lang/String; + public fun getContent ()Lcom/squareup/workflow1/ui/Screen; + public synthetic fun getContent ()Ljava/lang/Object; + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Container; + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper; + public fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/FullScreenModal; + public synthetic fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/ScreenOverlay; +} + +public abstract interface class com/squareup/workflow1/ui/navigation/ModalOverlay : com/squareup/workflow1/ui/navigation/Overlay { +} + +public abstract interface class com/squareup/workflow1/ui/navigation/Overlay { +} + +public abstract interface class com/squareup/workflow1/ui/navigation/ScreenOverlay : com/squareup/workflow1/ui/Wrapper, com/squareup/workflow1/ui/navigation/Overlay { + public abstract fun getContent ()Lcom/squareup/workflow1/ui/Screen; + public abstract fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/navigation/ScreenOverlay; +} + +public final class com/squareup/workflow1/ui/navigation/ScreenOverlay$DefaultImpls { + public static fun asSequence (Lcom/squareup/workflow1/ui/navigation/ScreenOverlay;)Lkotlin/sequences/Sequence; + public static fun getCompatibilityKey (Lcom/squareup/workflow1/ui/navigation/ScreenOverlay;)Ljava/lang/String; +} + diff --git a/workflow-ui/core/build.gradle.kts b/workflow-ui/core/build.gradle.kts new file mode 100644 index 000000000..e8b653e87 --- /dev/null +++ b/workflow-ui/core/build.gradle.kts @@ -0,0 +1,27 @@ +import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 + +plugins { + id("kotlin-multiplatform") + id("published") +} + +kotlin { + val targets = project.findProperty("workflow.targets") ?: "kmp" + if (targets == "kmp" || targets == "ios") { + iosWithSimulatorArm64(project) + } + if (targets == "kmp" || targets == "jvm") { + jvm { withJava() } + } + if (targets == "kmp" || targets == "js") { + js(IR) { browser() } + } +} + +dependencies { + commonMainApi(libs.kotlin.jdk6) + commonMainApi(libs.kotlinx.coroutines.core) + + commonTestImplementation(libs.kotlinx.coroutines.test.common) + commonTestImplementation(libs.kotlin.test.jdk) +} diff --git a/workflow-ui/core/dependencies/jsRuntimeClasspath.txt b/workflow-ui/core/dependencies/jsRuntimeClasspath.txt new file mode 100644 index 000000000..5494404ea --- /dev/null +++ b/workflow-ui/core/dependencies/jsRuntimeClasspath.txt @@ -0,0 +1,9 @@ +org.jetbrains.kotlin:kotlin-bom:1.9.10 +org.jetbrains.kotlin:kotlin-dom-api-compat:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib-js:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib:1.9.10 +org.jetbrains.kotlin:kotlinx-atomicfu-runtime:1.8.20 +org.jetbrains.kotlinx:atomicfu-js:0.21.0 +org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.7.3 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3 +org.jetbrains:annotations:13.0 diff --git a/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt b/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt new file mode 100644 index 000000000..fe39ce5b6 --- /dev/null +++ b/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt @@ -0,0 +1,8 @@ +org.jetbrains.kotlin:kotlin-bom:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib:1.9.10 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3 +org.jetbrains:annotations:23.0.0 diff --git a/workflow-ui/core/dependencies/runtimeClasspath.txt b/workflow-ui/core/dependencies/runtimeClasspath.txt new file mode 100644 index 000000000..1adc1c1b1 --- /dev/null +++ b/workflow-ui/core/dependencies/runtimeClasspath.txt @@ -0,0 +1,9 @@ +org.jetbrains.kotlin:kotlin-bom:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10 +org.jetbrains.kotlin:kotlin-stdlib:1.9.10 +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3 +org.jetbrains:annotations:23.0.0 diff --git a/workflow-ui/core/gradle.properties b/workflow-ui/core/gradle.properties new file mode 100644 index 000000000..d15563e9a --- /dev/null +++ b/workflow-ui/core/gradle.properties @@ -0,0 +1,3 @@ +POM_ARTIFACT_ID=workflow-ui-core +POM_NAME=Workflow UI Core +POM_PACKAGING=jar diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Compatible.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Compatible.kt new file mode 100644 index 000000000..7f15aeb50 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Compatible.kt @@ -0,0 +1,57 @@ +package com.squareup.workflow1.ui + +/** + * Normally returns true if [me] and [you] are instances of the same class. + * If that common class implements [Compatible], both instances must also + * have the same [Compatible.compatibilityKey]. + * + * A convenient way to take control over the matching behavior of objects that + * don't implement [Compatible] is to wrap them with [NamedScreen]. + */ +@WorkflowUiExperimentalApi +public fun compatible( + me: Any, + you: Any +): Boolean { + return when { + me::class != you::class -> false + me !is Compatible -> true + else -> me.compatibilityKey == (you as Compatible).compatibilityKey + } +} + +/** + * Implemented by objects whose [compatibility][compatible] requires more nuance + * than just being of the same type. + * + * Renderings that don't implement this interface directly can be distinguished + * by wrapping them with [NamedScreen]. + */ +@WorkflowUiExperimentalApi +public interface Compatible { + /** + * Instances of the same type are [compatible] iff they have the same [compatibilityKey]. + */ + public val compatibilityKey: String + + public companion object { + /** + * Calculates a suitable [Compatible.compatibilityKey] for a given [value], incorporating + * [name] if that is not blank. Includes the [compatibilityKey] for [value] if it + * implements [Compatible], to support recursion from wrapping. + * + * Style note: [name] is given more prominence than the key generate + */ + public fun keyFor( + value: Any, + name: String = "" + ): String { + var key = (value as? Compatible)?.compatibilityKey + if (key == null) { + key = value::class.toString() + } + + return name.takeIf { it.isNotEmpty() }?.let { "$name($key)" } ?: key + } + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/CompositeViewRegistry.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/CompositeViewRegistry.kt new file mode 100644 index 000000000..44501fe2f --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/CompositeViewRegistry.kt @@ -0,0 +1,61 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key + +/** + * A [ViewRegistry] that contains only other registries and delegates to their [getEntryFor] + * methods. + * + * Whenever any registries are combined using the [ViewRegistry] factory functions or `plus` + * operators, an instance of this class is returned. All registries' keys are checked at + * construction to ensure that no duplicate keys exist. + * + * The implementation of [getEntryFor] consists of a single layer of indirection – the responsible + * [ViewRegistry] is looked up in a map by key, and then that registry's [getEntryFor] is called. + * + * When multiple [CompositeViewRegistry]s are combined, they are flattened, so that there is never + * more than one layer of indirection. In other words, a [CompositeViewRegistry] will never contain + * a reference to another [CompositeViewRegistry]. + */ +@WorkflowUiExperimentalApi +internal class CompositeViewRegistry private constructor( + private val registriesByKey: Map, ViewRegistry> +) : ViewRegistry { + + constructor (vararg registries: ViewRegistry) : this(mergeRegistries(*registries)) + + override val keys: Set> get() = registriesByKey.keys + + override fun getEntryFor( + key: Key + ): Entry? = registriesByKey[key]?.getEntryFor(key) + + override fun toString(): String { + return "CompositeViewRegistry(${registriesByKey.values.toSet().map { it.toString() }})" + } + + companion object { + private fun mergeRegistries(vararg registries: ViewRegistry): Map, ViewRegistry> { + val registriesByKey = mutableMapOf, ViewRegistry>() + + fun putAllUnique(other: Map, ViewRegistry>) { + val duplicateKeys = registriesByKey.keys.intersect(other.keys) + require(duplicateKeys.isEmpty()) { + "Must not have duplicate entries: $duplicateKeys. Use merge to replace existing entries." + } + registriesByKey.putAll(other) + } + + registries.forEach { registry -> + if (registry is CompositeViewRegistry) { + // Try to keep the composite registry as flat as possible. + putAllUnique(registry.registriesByKey) + } else { + putAllUnique(registry.keys.associateWith { registry }) + } + } + return registriesByKey.toMap() + } + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Container.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Container.kt new file mode 100644 index 000000000..277d7b382 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Container.kt @@ -0,0 +1,74 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.Compatible.Companion.keyFor + +/** + * A rendering type comprised of a set of other renderings. + * + * Why two parameter types? The separate [BaseT] type allows implementations + * and sub-interfaces to constrain the types that [map] is allowed to + * transform [C] to. E.g., it allows `FooWrapper` to declare + * that [map] is only able to transform `S` to other types of `Screen`. + * + * @param BaseT the invariant base type of the contents of such a container, + * usually [Screen] or [Overlay][com.squareup.workflow1.ui.navigation.Overlay]. + * It is common for the [Container] itself to implement [BaseT], but that is + * not a requirement. E.g., [ScreenOverlay][com.squareup.workflow1.ui.navigation.ScreenOverlay] + * is an [Overlay][com.squareup.workflow1.ui.navigation.Overlay], but it + * wraps a [Screen]. + * + * @param C the specific subtype of [BaseT] collected by this [Container]. + */ +@WorkflowUiExperimentalApi +public interface Container { + public fun asSequence(): Sequence + + /** + * Returns a [Container] with the [transform]ed contents of the receiver. + * It is expected that an implementation will take advantage of covariance + * to declare its own type as the return type, rather than plain old [Container]. + * This requirement is not enforced because recursive generics are a fussy nuisance. + * + * For example, suppose we want to create `LoggingScreen`, one that wraps any + * other screen to add some logging calls. Its implementation of this method + * would be expected to have a return type of `LoggingScreen` rather than `Container`: + * + * override fun map(transform: (C) -> D): LoggingScreen = + * LoggingScreen(transform(content)) + * + * By requiring all [Container] types to implement [map], we ensure that their + * contents can be repackaged in interesting ways, e.g.: + * + * val childBackStackScreen = renderChild(childWorkflow) { ... } + * val loggingBackStackScreen = childBackStackScreen.map { LoggingScreen(it) } + */ + public fun map(transform: (C) -> D): Container +} + +/** + * A [Container] rendering that wraps exactly one other rendering, its [content]. These are + * typically used to "add value" to the [content], e.g. an + * [EnvironmentScreen][com.squareup.workflow1.ui.EnvironmentScreen] that allows + * changes to be made to the [ViewEnvironment]. + * + * Usually a [Wrapper] is [Compatible] only with others of the same type with + * [Compatible] [content]. In aid of that, this interface extends [Compatible] and + * provides a convenient default implementation of [compatibilityKey]. + */ +@WorkflowUiExperimentalApi +public interface Wrapper : Container, Compatible { + public val content: C + + /** + * Default implementation makes this [Wrapper] compatible with others of the same type, + * and which wrap compatible [content]. + */ + public override val compatibilityKey: String + get() = keyFor(content, this::class.simpleName ?: "Wrapper") + + public override fun asSequence(): Sequence = sequenceOf(content) + + public override fun map( + transform: (C) -> D + ): Wrapper +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/EnvironmentScreen.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/EnvironmentScreen.kt new file mode 100644 index 000000000..cfbbe8ea2 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/EnvironmentScreen.kt @@ -0,0 +1,64 @@ +package com.squareup.workflow1.ui + +/** + * Pairs a [content] rendering with a [environment] to support its display. + * Typically the rendering type (`RenderingT`) of the root of a UI workflow, + * but can be used at any point to modify the [ViewEnvironment] received from + * a parent view. + * + * UI kits are expected to provide handling for this class by default. + */ +@WorkflowUiExperimentalApi +public class EnvironmentScreen( + public override val content: C, + public val environment: ViewEnvironment = ViewEnvironment.EMPTY +) : Wrapper, Screen { + override fun map(transform: (C) -> D): EnvironmentScreen = + EnvironmentScreen(transform(content), environment) +} + +/** + * Returns an [EnvironmentScreen] derived from the receiver, whose + * [EnvironmentScreen.environment] includes [viewRegistry]. + * + * If the receiver is an [EnvironmentScreen], uses + * [ViewRegistry.merge][com.squareup.workflow1.ui.merge] to preserve the [ViewRegistry] + * entries of both. + */ +@WorkflowUiExperimentalApi +public fun Screen.withRegistry(viewRegistry: ViewRegistry): EnvironmentScreen<*> { + return withEnvironment(ViewEnvironment.EMPTY + viewRegistry) +} + +/** + * Returns an [EnvironmentScreen] derived from the receiver, + * whose [EnvironmentScreen.environment] includes the values in the given [environment]. + * + * If the receiver is an [EnvironmentScreen], uses + * [ViewRegistry.merge][com.squareup.workflow1.ui.merge] to preserve the [ViewRegistry] + * entries of both. + */ +@WorkflowUiExperimentalApi +public fun Screen.withEnvironment( + environment: ViewEnvironment = ViewEnvironment.EMPTY +): EnvironmentScreen<*> { + return when (this) { + is EnvironmentScreen<*> -> { + if (environment.map.isEmpty()) { + this + } else { + EnvironmentScreen(content, this.environment + environment) + } + } + else -> EnvironmentScreen(this, environment) + } +} + +/** + * Returns an [EnvironmentScreen] derived from the receiver, + * whose [EnvironmentScreen.environment] includes the given entry. + */ +@WorkflowUiExperimentalApi +public fun Screen.withEnvironment( + entry: Pair, T> +): EnvironmentScreen<*> = withEnvironment(ViewEnvironment.EMPTY + entry) diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/NamedScreen.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/NamedScreen.kt new file mode 100644 index 000000000..ad3e64715 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/NamedScreen.kt @@ -0,0 +1,29 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.Compatible.Companion + +/** + * Allows [Screen] renderings that do not implement [Compatible] themselves to be distinguished + * by more than just their type. Instances are [compatible] if they have the same name + * and have [compatible] [content] fields. + * + * UI kits are expected to provide handling for this class by default. + */ +@WorkflowUiExperimentalApi +public data class NamedScreen( + override val content: C, + val name: String +) : Screen, Wrapper { + init { + require(name.isNotBlank()) { "name must not be blank." } + } + + override val compatibilityKey: String = Companion.keyFor(content, "NamedScreen:$name") + + override fun map(transform: (C) -> D): NamedScreen = + NamedScreen(transform(content), name) + + override fun toString(): String { + return "${super.toString()}: $compatibilityKey" + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Screen.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Screen.kt new file mode 100644 index 000000000..37b586e8a --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Screen.kt @@ -0,0 +1,7 @@ +package com.squareup.workflow1.ui + +/** + * Marker interface implemented by renderings that map to a UI system's 2d view class. + */ +@WorkflowUiExperimentalApi +public interface Screen diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TextController.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TextController.kt new file mode 100644 index 000000000..bef82ccff --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TextController.kt @@ -0,0 +1,90 @@ +package com.squareup.workflow1.ui + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop + +/** + * Helper class for keeping a workflow in sync with editable text in a UI, + * without interfering with the user's typing. + * + * ## Usage + * + * 1. For every editable string in your state, create a property of type [TextController]. + * ``` + * data class State(val text: TextController = TextController()) + * ``` + * 2. Create a matching property in your rendering type. + * ``` + * data class Rendering(val text: TextController) + * ``` + * 3. In your `render` method, copy each [TextController] from your state to your rendering: + * ``` + * return Rendering(state.text) + * ``` + * 4. In your view code's `showRendering` method, call the appropriate extension + * function for your UI platform, e.g.: + * + * - `control()` for an Android EditText view + * - `asMutableState()` from an Android `@Composable` function + * + * If your workflow needs to access or change the current text value, get the value from [textValue]. + * If your workflow needs to react to changes, it can observe [onTextChanged] by converting it to a + * worker. + */ +@WorkflowUiExperimentalApi +public interface TextController { + + /** + * A [Flow] that emits the text value whenever it changes -- and only when it changes, the current value + * is not provided at subscription time. Workflows can safely observe changes by + * converting this value to a worker. (When using multiple instances, remember to provide unique + * key values to each `asWorker` call.) + * + * If you can do processing that doesn't require running a `WorkflowAction` or triggering a render + * pass, it can be done in regular Flow operators before converting to a worker. + */ + public val onTextChanged: Flow + + /** + * The current text value. + */ + public var textValue: String +} + +/** + * Create instance for default implementation of [TextController]. + */ +@WorkflowUiExperimentalApi +public fun TextController(initialValue: String = ""): TextController { + return TextControllerImpl(initialValue) +} + +/** + * Default implementation of [TextController]. + */ +@WorkflowUiExperimentalApi +private class TextControllerImpl(initialValue: String) : TextController { + + /** + * This flow is not exposed as a StateFlow intentionally. Doing so would encourage observing it from + * workflows, which is not desirable since StateFlows emit immediately upon subscription, which means + * that for a workflow runtime running N workflows that each observe M [TextController]s, the first + * render pass would trigger NxM useless render passes. + * + * Instead, only text _change_ events are exposed, as [onTextChanged], which is suitable for use as a + * worker. The current value is exposed as a separate var, [textValue]. + * + * Subscriptions from the view layer that need the initial value can call [textValue] + * to prime the pump manually. + */ + private val _textValue: MutableStateFlow = MutableStateFlow(initialValue) + + override val onTextChanged: Flow = _textValue.drop(1) + + override var textValue: String + get() = _textValue.value + set(value) { + _textValue.value = value + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TypedViewRegistry.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TypedViewRegistry.kt new file mode 100644 index 000000000..161a5ad4b --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TypedViewRegistry.kt @@ -0,0 +1,43 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key +import kotlin.reflect.KClass + +/** + * A [ViewRegistry] that contains a set of [Entry]s, keyed by the [KClass]es of the + * rendering types. + */ +@WorkflowUiExperimentalApi +internal class TypedViewRegistry private constructor( + private val bindings: Map, Entry<*>> +) : ViewRegistry { + + constructor(vararg bindings: Entry<*>) : this( + bindings.associateBy { + require(it.key.factoryType.isInstance(it)) { + "Factory $it must be of the type declared in its key, ${it.key.factoryType}" + } + it.key + } + .apply { + check(keys.size == bindings.size) { + "${bindings.map { it.key }} must not have duplicate entries." + } + } as Map, Entry<*>> + ) + + override val keys: Set> get() = bindings.keys + + override fun getEntryFor( + key: Key + ): Entry? { + @Suppress("UNCHECKED_CAST") + return bindings[key] as? Entry + } + + override fun toString(): String { + val map = bindings.map { "${it.key}=${it.value::class}" } + return "TypedViewRegistry(bindings=$map)" + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewEnvironment.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewEnvironment.kt new file mode 100644 index 000000000..e7f891713 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewEnvironment.kt @@ -0,0 +1,86 @@ +package com.squareup.workflow1.ui + +/** + * Immutable map of values that a parent view can pass down to + * its children. Allows containers to give descendants information about + * the context in which they're drawing. + * + * Calling [Screen.withEnvironment][com.squareup.workflow1.ui.withEnvironment] + * on a [Screen] is the easiest way to customize its environment before rendering it. + */ +@WorkflowUiExperimentalApi +public class ViewEnvironment +private constructor( + public val map: Map, Any> = emptyMap() +) { + public operator fun get(key: ViewEnvironmentKey): T = getOrNull(key) ?: key.default + + public operator fun plus(pair: Pair, T>): ViewEnvironment { + val (newKey, newValue) = pair + val newPair = getOrNull(newKey) + ?.let { oldValue -> newKey to newKey.combine(oldValue, newValue) } + ?: pair + return ViewEnvironment(map + newPair) + } + + public operator fun plus(other: ViewEnvironment): ViewEnvironment { + if (this == other) return this + if (other.map.isEmpty()) return this + if (map.isEmpty()) return other + val newMap = map.toMutableMap() + other.map.entries.forEach { (key, value) -> + @Suppress("UNCHECKED_CAST") + newMap[key] = getOrNull(key as ViewEnvironmentKey) + ?.let { oldValue -> key.combine(oldValue, value) } + ?: value + } + return ViewEnvironment(newMap) + } + + override fun toString(): String = "ViewEnvironment($map)" + + override fun equals(other: Any?): Boolean = + (other as? ViewEnvironment)?.let { it.map == map } ?: false + + override fun hashCode(): Int = map.hashCode() + + @Suppress("UNCHECKED_CAST") + private fun getOrNull(key: ViewEnvironmentKey): T? = map[key] as? T + + public companion object { + public val EMPTY: ViewEnvironment = ViewEnvironment() + } +} + +/** + * Defines a value type [T] that can be provided by a [ViewEnvironment] map, + * and specifies its [default] value. + * + * It is hard to imagine a useful implementation of this that is not a Kotlin `object`. + * Preferred use is to have the `companion object` of [T] extend this class. See + * [BackStackConfig.Companion][com.squareup.workflow1.ui.navigation.BackStackConfig.Companion] + * for an example. + */ +@WorkflowUiExperimentalApi +public abstract class ViewEnvironmentKey { + /** + * Defines the default value for this key. It is a grievous error for this value to be + * dynamic in any way. + */ + public abstract val default: T + + /** + * Applied from [ViewEnvironment.plus] when the receiving environment already contains + * a value for this key. The default implementation replaces [left] with [right]. + */ + public open fun combine( + left: T, + right: T + ): T = right + + final override fun equals(other: Any?): Boolean { + return this === other || (other != null && this::class == other::class) + } + + final override fun hashCode(): Int = this::class.hashCode() +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewRegistry.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewRegistry.kt new file mode 100644 index 000000000..0c641971b --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewRegistry.kt @@ -0,0 +1,214 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key +import kotlin.js.JsName +import kotlin.reflect.KClass +import kotlin.reflect.safeCast + +/** + * The [ViewEnvironment] service that can be used to display the stream of renderings + * from a workflow tree as [View] instances. This is the engine behind [AndroidViewRendering], + * [WorkflowViewStub] and [ViewFactory]. Most apps can ignore [ViewRegistry] as an implementation + * detail, by using [AndroidViewRendering] to tie their rendering classes to view code. + * + * To avoid that coupling between workflow code and the Android runtime, registries can + * be loaded with [ViewFactory] instances at runtime, and provided as an optional parameter to + * [WorkflowLayout.start]. + * + * For example: + * + * val AuthViewFactories = ViewRegistry( + * AuthorizingLayoutRunner, LoginLayoutRunner, SecondFactorLayoutRunner + * ) + * + * val TicTacToeViewFactories = ViewRegistry( + * NewGameLayoutRunner, GamePlayLayoutRunner, GameOverLayoutRunner + * ) + * + * val ApplicationViewFactories = ViewRegistry(ApplicationLayoutRunner) + + * AuthViewFactories + TicTacToeViewFactories + * + * override fun onCreate(savedInstanceState: Bundle?) { + * super.onCreate(savedInstanceState) + * + * val model: MyViewModel by viewModels() + * setContentView( + * WorkflowLayout(this).apply { start(model.renderings, ApplicationViewFactories) } + * ) + * } + * + * /** As always, use an androidx ViewModel for state that survives config change. */ + * class MyViewModel(savedState: SavedStateHandle) : ViewModel() { + * val renderings: StateFlow by lazy { + * renderWorkflowIn( + * workflow = rootWorkflow, + * scope = viewModelScope, + * savedStateHandle = savedState + * ) + * } + * } + * + * In the above example, it is assumed that the `companion object`s of the various + * decoupled [LayoutRunner] classes honor a convention of implementing [ViewFactory], in + * aid of this kind of assembly. + * + * class GamePlayLayoutRunner(view: View) : LayoutRunner { + * + * // ... + * + * companion object : ViewFactory by LayoutRunner.bind( + * R.layout.game_layout, ::GameLayoutRunner + * ) + * } + */ +@WorkflowUiExperimentalApi +public interface ViewRegistry { + /** + * Identifies a UI factory [Entry] in a [ViewRegistry]. + * + * @param renderingType the type of view model for which [factoryType] instances can build UI + * @param factoryType the type of the UI factory that can build UI for [renderingType] + */ + public class Key( + public val renderingType: KClass, + public val factoryType: KClass + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null) return false + if (this::class != other::class) return false + + other as Key<*, *> + + if (renderingType != other.renderingType) return false + return factoryType == other.factoryType + } + + override fun hashCode(): Int { + var result = renderingType.hashCode() + result = 31 * result + factoryType.hashCode() + return result + } + + override fun toString(): String { + return "Key(renderingType=$renderingType, factoryType=$factoryType)" + } + } + + /** + * Implemented by a factory that can build some kind of UI for view models + * of type [RenderingT], and which can be listed in a [ViewRegistry]. The + * [Key.factoryType] field of [key] must be the type of this [Entry]. + */ + public interface Entry { + public val key: Key + } + + /** + * The set of unique keys which this registry can derive from the renderings passed to + * [getEntryFor] and for which it knows how to create UI. + * + * Used to ensure that duplicate bindings are never registered. + */ + public val keys: Set> + + /** + * Returns the [Entry] that was registered for the given [key], or null + * if none was found. + */ + public fun getEntryFor( + key: Key + ): Entry? + + public companion object : ViewEnvironmentKey() { + override val default: ViewRegistry get() = ViewRegistry() + override fun combine( + left: ViewRegistry, + right: ViewRegistry + ): ViewRegistry = left.merge(right) + } +} + +@WorkflowUiExperimentalApi +public inline fun ViewRegistry.getFactoryFor( + rendering: RenderingT +): FactoryT? { + return FactoryT::class.safeCast(getEntryFor(Key(rendering::class, FactoryT::class))) +} + +@WorkflowUiExperimentalApi +public inline fun < + reified RenderingT : Any, + reified FactoryT : Any + > ViewRegistry.getFactoryFor(): FactoryT? { + return FactoryT::class.safeCast(getEntryFor(Key(RenderingT::class, FactoryT::class))) +} + +@WorkflowUiExperimentalApi +public inline operator fun ViewRegistry.get( + key: Key +): FactoryT? = FactoryT::class.safeCast(getEntryFor(key)) + +@WorkflowUiExperimentalApi +public fun ViewRegistry(vararg bindings: Entry<*>): ViewRegistry = + TypedViewRegistry(*bindings) + +/** + * Returns a [ViewRegistry] that contains no bindings. + * + * Exists as a separate overload from the other two functions to disambiguate between them. + */ +@WorkflowUiExperimentalApi +@JsName("CreateViewRegistry") +public fun ViewRegistry(): ViewRegistry = TypedViewRegistry() + +/** + * Transforms the receiver to add [entry], throwing [IllegalArgumentException] if the receiver + * already has a matching [entry]. Use [merge] to replace an existing entry with a new one. + */ +@WorkflowUiExperimentalApi +public operator fun ViewRegistry.plus(entry: Entry<*>): ViewRegistry = + this + ViewRegistry(entry) + +/** + * Transforms the receiver to add all entries from [other]. + * + * @throws [IllegalArgumentException] if the receiver already has an matching [Entry]. + * Use [merge] to replace existing entries instead. + */ +@WorkflowUiExperimentalApi +public operator fun ViewRegistry.plus(other: ViewRegistry): ViewRegistry { + if (other.keys.isEmpty()) return this + if (this.keys.isEmpty()) return other + return CompositeViewRegistry(this, other) +} + +/** + * Returns a new [ViewEnvironment] that adds [registry] to the receiver. + * If the receiver already has a [ViewRegistry], [ViewEnvironmentKey.combine] + * is applied as usual to [merge] its entries. + */ +@WorkflowUiExperimentalApi +public operator fun ViewEnvironment.plus(registry: ViewRegistry): ViewEnvironment { + if (this[ViewRegistry] === registry) return this + if (registry.keys.isEmpty()) return this + return this + (ViewRegistry to registry) +} + +/** + * Combines the receiver with [other]. If there are conflicting entries, + * those in [other] are preferred. + */ +@WorkflowUiExperimentalApi +public infix fun ViewRegistry.merge(other: ViewRegistry): ViewRegistry { + if (this === other) return this + if (other.keys.isEmpty()) return this + if (this.keys.isEmpty()) return other + + return (keys + other.keys).asSequence() + .map { other.getEntryFor(it) ?: getEntryFor(it)!! } + .toList() + .toTypedArray() + .let { ViewRegistry(*it) } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/WorkflowUiExperimentalApi.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/WorkflowUiExperimentalApi.kt new file mode 100644 index 000000000..0f8f0fab7 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/WorkflowUiExperimentalApi.kt @@ -0,0 +1,25 @@ +@file:JvmMultifileClass +@file:JvmName("Workflows") + +package com.squareup.workflow1.ui + +import kotlin.RequiresOptIn.Level.ERROR +import kotlin.annotation.AnnotationRetention.BINARY +import kotlin.jvm.JvmMultifileClass +import kotlin.jvm.JvmName + +/** + * Marks Workflow user interface APIs which are still in flux. Annotated code SHOULD NOT be used + * in library code or app code that you are not prepared to update when changing even minor + * workflow versions. Proceed with caution, and be ready to have the rug pulled out from under you. + */ +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.PROPERTY, + AnnotationTarget.FUNCTION, + AnnotationTarget.TYPEALIAS +) +@MustBeDocumented +@Retention(value = BINARY) +@RequiresOptIn(level = ERROR) +public annotation class WorkflowUiExperimentalApi diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/AlertOverlay.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/AlertOverlay.kt new file mode 100644 index 000000000..3486f3bf0 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/AlertOverlay.kt @@ -0,0 +1,50 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Models a typical "You sure about that?" alert box. + * + * UI kits are expected to provide handling for this class by default. + */ +@WorkflowUiExperimentalApi +public data class AlertOverlay( + val buttons: Map = emptyMap(), + val message: String = "", + val title: String = "", + val cancelable: Boolean = true, + val onEvent: (Event) -> Unit +) : ModalOverlay { + public enum class Button { + POSITIVE, + NEGATIVE, + NEUTRAL + } + + public sealed class Event { + public data class ButtonClicked(val button: Button) : Event() + + public object Canceled : Event() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null) return false + if (this::class != other::class) return false + + other as AlertOverlay + + return buttons == other.buttons && + message == other.message && + title == other.title && + cancelable == other.cancelable + } + + override fun hashCode(): Int { + var result = buttons.hashCode() + result = 31 * result + message.hashCode() + result = 31 * result + title.hashCode() + result = 31 * result + cancelable.hashCode() + return result + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackConfig.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackConfig.kt new file mode 100644 index 000000000..28702b918 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackConfig.kt @@ -0,0 +1,39 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.navigation.BackStackConfig.First +import com.squareup.workflow1.ui.navigation.BackStackConfig.Other + +/** + * Informs views whether they're children of a [BackStackScreen], + * and if so whether they're the [first frame][First] or [not][Other]. + */ +@WorkflowUiExperimentalApi +public enum class BackStackConfig { + /** + * There is no [BackStackScreen] above here. + */ + None, + + /** + * This rendering is the first frame in a [BackStackScreen]. + * Useful as a hint to disable "go back" behavior, or replace it with "go up" behavior. + */ + First, + + /** + * This rendering is in a [BackStackScreen] but is not the first frame. + * Useful as a hint to enable "go back" behavior. + */ + Other; + + public companion object : ViewEnvironmentKey() { + override val default: BackStackConfig = None + } +} + +@WorkflowUiExperimentalApi +public operator fun ViewEnvironment.plus(config: BackStackConfig): ViewEnvironment = + this + (BackStackConfig to config) diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackScreen.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackScreen.kt new file mode 100644 index 000000000..ccddf6381 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackScreen.kt @@ -0,0 +1,115 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Container +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.navigation.BackStackScreen.Companion +import com.squareup.workflow1.ui.navigation.BackStackScreen.Companion.fromList +import com.squareup.workflow1.ui.navigation.BackStackScreen.Companion.fromListOrNull + +/** + * Represents an active screen ([top]), and a set of previously visited screens to which we may + * return ([backStack]). By rendering the entire history we allow the UI to do things like maintain + * cached view state, implement drag-back gestures without waiting for the workflow, etc. + * + * Effectively a list that can never be empty. + * + * UI kits are expected to provide handling for this class by default. + * + * @see fromList + * @see fromListOrNull + */ +@WorkflowUiExperimentalApi +public class BackStackScreen internal constructor( + public val frames: List +) : Screen, Container { + /** + * Creates a screen with elements listed from the [bottom] to the top. + */ + public constructor( + bottom: StackedT, + vararg rest: StackedT + ) : this(listOf(bottom) + rest) + + override fun asSequence(): Sequence = frames.asSequence() + + /** + * The active screen. + */ + public val top: StackedT = frames.last() + + /** + * Screens to which we may return. + */ + public val backStack: List = frames.subList(0, frames.size - 1) + + public operator fun get(index: Int): StackedT = frames[index] + + public override fun map( + transform: (StackedT) -> StackedU + ): BackStackScreen { + return frames.map(transform).toBackStackScreen() + } + + public fun mapIndexed(transform: (index: Int, StackedT) -> R): BackStackScreen { + return frames.mapIndexed(transform) + .toBackStackScreen() + } + + override fun equals(other: Any?): Boolean { + return (other as? BackStackScreen<*>)?.frames == frames + } + + override fun hashCode(): Int { + return frames.hashCode() + } + + override fun toString(): String { + return "${this::class.simpleName}($frames)" + } + + public companion object { + /** + * Builds a [BackStackScreen] from a non-empty list of [frames]. + * + * @throws IllegalArgumentException is [frames] is empty + */ + public fun fromList(frames: List): BackStackScreen { + require(frames.isNotEmpty()) { + "A BackStackScreen must have at least one frame." + } + return BackStackScreen(frames) + } + + /** + * Builds a [BackStackScreen] from a list of [frames], or returns `null` + * if [frames] is empty. + */ + public fun fromListOrNull(frames: List): BackStackScreen? { + return when { + frames.isEmpty() -> null + else -> BackStackScreen(frames) + } + } + } +} + +/** + * Returns a new [BackStackScreen] with the [BackStackScreen.frames] of [other] added + * to those of the receiver. [other] is nullable for convenience when using with + * [toBackStackScreenOrNull]. + */ +@WorkflowUiExperimentalApi +public operator fun BackStackScreen.plus( + other: BackStackScreen? +): BackStackScreen { + return other?.let { BackStackScreen(frames + it.frames) } ?: this +} + +@WorkflowUiExperimentalApi +public fun List.toBackStackScreenOrNull(): BackStackScreen? = + fromListOrNull(this) + +@WorkflowUiExperimentalApi +public fun List.toBackStackScreen(): BackStackScreen = + Companion.fromList(this) diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BodyAndOverlaysScreen.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BodyAndOverlaysScreen.kt new file mode 100644 index 000000000..db22e4af3 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BodyAndOverlaysScreen.kt @@ -0,0 +1,90 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Compatible.Companion.keyFor +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * A screen that may stack a number of [Overlay]s over a body. + * If any members of [overlays] are [ModalOverlay], the body and + * lower-indexed members of that list are expected to ignore input + * events -- touch, keyboard, etc. + * + * UI kits are expected to provide handling for this class by default. + * + * Any [overlays] shown are expected to have their bounds restricted + * to the area above the [body]. For example, consider a layout where + * we want the option to show a tutorial bar below the main UI: + * + * +-------------------------+ + * | MyMainScreen | + * | | + * | | + * +-------------------------+ + * | MyTutorialScreen | + * +-------------------------+ + * + * And we want to ensure that any modal windows do not obscure the tutorial, if + * it's showing: + * + * +----+=============+------+ + * | My| | | + * | | MyEditModal | | + * | | | | + * +----+=============+------+ + * | MyTutorialScreen | + * +-------------------------+ + * + * We could model that this way: + * + * MyBodyAndBottomBarScreen( + * body = BodyAndOverlaysScreen( + * body = mainScreen, + * overlays = listOfNotNull(editModalOrNull) + * ), + * bar = tutorialScreenOrNull, + * ) + * + * It is also possible to nest [BodyAndOverlaysScreen] instances. For example, + * to show a higher priority modal that covers both `MyMainScreen` and `MyTutorialScreen`, + * we could render this: + * + * BodyAndOverlaysScreen( + * overlays = listOfNotNull(fullScreenModalOrNull), + * body = MyBodyAndBottomBarScreen( + * body = BodyAndOverlaysScreen( + * body = mainScreen, + * overlays = listOfNotNull(editModalOrNull) + * ), + * bar = tutorialScreenOrNull, + * ) + * ) + * + * Whatever structure you settle on for your root rendering, it is important + * to render the same structure every time. If your app will ever want to show + * an [Overlay], it should always render [BodyAndOverlaysScreen], even when + * there is no [Overlay] to show. Otherwise your entire view tree will be rebuilt, + * since the view built for a `MyBodyAndBottomBarScreen` cannot be updated to show + * a [BodyAndOverlaysScreen] rendering. + * + * @param name included in the [compatibilityKey] of this screen, for ease + * of nesting -- on Android, view state persistence support requires each + * BodyAndOverlaysScreen in a hierarchy to have a unique key + */ +@WorkflowUiExperimentalApi +public class BodyAndOverlaysScreen( + public val body: B, + public val overlays: List = emptyList(), + public val name: String = "" +) : Screen, Compatible { + override val compatibilityKey: String = keyFor(this, name) + + public fun mapBody(transform: (B) -> S): BodyAndOverlaysScreen { + return BodyAndOverlaysScreen(transform(body), overlays, name) + } + + public fun mapOverlays(transform: (O) -> N): BodyAndOverlaysScreen { + return BodyAndOverlaysScreen(body, overlays.map(transform), name) + } +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/FullScreenModal.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/FullScreenModal.kt new file mode 100644 index 000000000..1fe9e25b3 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/FullScreenModal.kt @@ -0,0 +1,17 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * A basic [ScreenOverlay] that covers its container with the wrapped [content] [Screen]. + * + * UI kits are expected to provide handling for this class by default. + */ +@WorkflowUiExperimentalApi +public class FullScreenModal( + public override val content: C +) : ScreenOverlay, ModalOverlay { + override fun map(transform: (C) -> D): FullScreenModal = + FullScreenModal(transform(content)) +} diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ModalOverlay.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ModalOverlay.kt new file mode 100644 index 000000000..4d4c9f4b0 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ModalOverlay.kt @@ -0,0 +1,10 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Marker interface identifying [Overlay] renderings whose presence + * indicates that events are blocked from lower layers. + */ +@WorkflowUiExperimentalApi +public interface ModalOverlay : Overlay diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/Overlay.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/Overlay.kt new file mode 100644 index 000000000..f98b593de --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/Overlay.kt @@ -0,0 +1,22 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Marker interface implemented by window-like renderings that map to a layer above + * a base [Screen][com.squareup.workflow1.ui.Screen] by being placed in a + * [BodyAndOverlaysScreen.overlays] list. See [BodyAndOverlaysScreen] for more details. + * + * An [Overlay] can be any window-like part of the UI that visually floats in a layer + * above the main UI, or above other Overlays. Possible examples include alerts, drawers, + * and tooltips. + * + * Note in particular that an [Overlay] is not necessarily a modal window -- that is, + * one that prevents covered views and windows from processing UI events. + * Rendering types can opt into modality by extending [ModalOverlay]. + * + * See [ScreenOverlay] to define an [Overlay] whose content is provided by a wrapped + * [Screen][com.squareup.workflow1.ui.Screen]. + */ +@WorkflowUiExperimentalApi +public interface Overlay diff --git a/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ScreenOverlay.kt b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ScreenOverlay.kt new file mode 100644 index 000000000..b42122f82 --- /dev/null +++ b/workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ScreenOverlay.kt @@ -0,0 +1,15 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.Wrapper + +/** + * An [Overlay] built around a root [content] [Screen]. + */ +@WorkflowUiExperimentalApi +public interface ScreenOverlay : Overlay, Wrapper { + public override val content: ContentT + + override fun map(transform: (ContentT) -> ContentU): ScreenOverlay +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompatibleTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompatibleTest.kt new file mode 100644 index 000000000..b62fb0d1e --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompatibleTest.kt @@ -0,0 +1,35 @@ +package com.squareup.workflow1.ui + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(WorkflowUiExperimentalApi::class) +class CompatibleTest { + @Test fun different_types_do_not_match() { + val able = object : Any() {} + val baker = object : Any() {} + + assertFalse { compatible(able, baker) } + } + + @Test fun same_type_matches() { + assertTrue { compatible("Able", "Baker") } + } + + @Test fun isCompatibleWith_is_honored() { + data class K(override val compatibilityKey: String) : Compatible + + assertTrue { compatible(K("hey"), K("hey")) } + assertFalse { compatible(K("hey"), K("ho")) } + } + + @Test fun different_Compatible_types_do_not_match() { + abstract class A : Compatible + + class Able(override val compatibilityKey: String) : A() + class Alpha(override val compatibilityKey: String) : A() + + assertFalse { compatible(Able("Hey"), Alpha("Hey")) } + } +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompositeViewRegistryTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompositeViewRegistryTest.kt new file mode 100644 index 000000000..8368515d3 --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompositeViewRegistryTest.kt @@ -0,0 +1,90 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(WorkflowUiExperimentalApi::class) +internal class CompositeViewRegistryTest { + + @Test fun constructor_throws_on_duplicates() { + val fooBarRegistry = TestRegistry(setOf(FooRendering::class, BarRendering::class)) + val barBazRegistry = TestRegistry(setOf(BarRendering::class, BazRendering::class)) + + val error = assertFailsWith { + fooBarRegistry + barBazRegistry + } + assertTrue { error.message!!.startsWith("Must not have duplicate entries: ") } + assertTrue { error.message!!.contains(BarRendering::class.toString()) } + } + + @Test fun getFactoryFor_delegates_to_composite_registries() { + val fooFactory = TestEntry(FooRendering::class) + val barFactory = TestEntry(BarRendering::class) + val bazFactory = TestEntry(BazRendering::class) + val fooBarRegistry = TestRegistry( + mapOf( + fooFactory.key to fooFactory, + barFactory.key to barFactory + ) + ) + val bazRegistry = TestRegistry(factories = mapOf(bazFactory.key to bazFactory)) + val registry = fooBarRegistry + bazRegistry + + assertSame(fooFactory, registry.getEntryFor(Key(FooRendering::class, TestEntry::class))) + assertSame(barFactory, registry.getEntryFor(Key(BarRendering::class, TestEntry::class))) + assertSame(bazFactory, registry.getEntryFor(Key(BazRendering::class, TestEntry::class))) + } + + @Test fun getFactoryFor_returns_null_on_missing_registry() { + val fooRegistry = TestRegistry(setOf(FooRendering::class)) + val registry = CompositeViewRegistry(ViewRegistry(), fooRegistry) + + assertNull(registry.getEntryFor(Key(BarRendering::class, TestEntry::class))) + } + + @Test fun keys_includes_all_composite_registries_keys() { + val fooBarRegistry = TestRegistry(setOf(FooRendering::class, BarRendering::class)) + val bazRegistry = TestRegistry(setOf(BazRendering::class)) + val registry = CompositeViewRegistry(fooBarRegistry, bazRegistry) + + assertEquals( + setOf( + Key(FooRendering::class, TestEntry::class), + Key(BarRendering::class, TestEntry::class), + Key(BazRendering::class, TestEntry::class) + ), + registry.keys + ) + } + + private class TestEntry(type: KClass) : Entry { + override val key = Key(type, TestEntry::class) + } + + private object FooRendering + private object BarRendering + private object BazRendering + + private class TestRegistry(private val factories: Map, Entry<*>>) : ViewRegistry { + constructor(keys: Set>) : this( + keys.associate { + val entry = TestEntry(it) + entry.key to entry + } + ) + + override val keys: Set> get() = factories.keys + + @Suppress("UNCHECKED_CAST") + override fun getEntryFor( + key: Key + ): Entry = factories.getValue(key) as Entry + } +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/EnvironmentScreenTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/EnvironmentScreenTest.kt new file mode 100644 index 000000000..4632d9fb9 --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/EnvironmentScreenTest.kt @@ -0,0 +1,115 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY +import com.squareup.workflow1.ui.ViewRegistry.Key +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +@OptIn(WorkflowUiExperimentalApi::class) +internal class EnvironmentScreenTest { + private class TestFactory( + type: KClass + ) : ViewRegistry.Entry { + override val key = Key(type, TestFactory::class) + } + + private data class TestValue(val value: String) { + companion object : ViewEnvironmentKey() { + override val default: TestValue get() = error("Set a default") + } + } + + private operator fun ViewEnvironment.plus(other: TestValue): ViewEnvironment { + return this + (TestValue to other) + } + + private object FooScreen : Screen + private object BarScreen : Screen + + @Test fun screen_withRegistry_works() { + val fooFactory = TestFactory(FooScreen::class) + val viewRegistry = ViewRegistry(fooFactory) + val envScreen = FooScreen.withRegistry(viewRegistry) + + assertSame( + fooFactory, + envScreen.environment[ViewRegistry].getFactoryFor>(FooScreen) + + ) + + assertNull( + envScreen.environment[ViewRegistry].getFactoryFor>(BarScreen) + ) + } + + @Test fun screen_withEnvironment_works() { + val fooFactory = TestFactory(FooScreen::class) + val viewRegistry = ViewRegistry(fooFactory) + val envScreen = FooScreen.withEnvironment( + EMPTY + viewRegistry + TestValue("foo") + ) + + assertSame( + fooFactory, + envScreen.environment[ViewRegistry].getFactoryFor>(FooScreen) + ) + assertNull( + envScreen.environment[ViewRegistry].getFactoryFor>(BarScreen) + ) + assertEquals( + TestValue("foo"), + envScreen.environment[TestValue] + ) + } + + @Test fun environmentScreen_withRegistry_merges() { + val fooFactory1 = TestFactory(FooScreen::class) + val fooFactory2 = TestFactory(FooScreen::class) + val barFactory = TestFactory(BarScreen::class) + + val left = FooScreen.withRegistry(ViewRegistry(fooFactory1, barFactory)) + val union = left.withRegistry(ViewRegistry(fooFactory2)) + + assertSame( + fooFactory2, + union.environment[ViewRegistry].getFactoryFor>(FooScreen) + ) + + assertSame( + barFactory, + union.environment[ViewRegistry].getFactoryFor>(BarScreen) + ) + } + + @Test fun environmentScreen_withEnvironment_merges() { + val fooFactory1 = TestFactory(FooScreen::class) + val fooFactory2 = TestFactory(FooScreen::class) + val barFactory = TestFactory(BarScreen::class) + + val left = FooScreen.withEnvironment( + EMPTY + ViewRegistry(fooFactory1, barFactory) + TestValue("left") + ) + + val union = left.withEnvironment( + EMPTY + ViewRegistry(fooFactory2) + TestValue("right") + ) + + assertSame( + fooFactory2, + union.environment[ViewRegistry].getFactoryFor>(FooScreen) + ) + assertSame( + barFactory, + union.environment[ViewRegistry].getFactoryFor>(BarScreen), + ) + assertEquals(TestValue("right"), union.environment[TestValue]) + } + + @Test fun keep_existing_instance_on_vacuous_merge() { + val left = FooScreen.withEnvironment(EMPTY + TestValue("whatever")) + assertSame(left, left.withEnvironment()) + } +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/NamedScreenTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/NamedScreenTest.kt new file mode 100644 index 000000000..cf816834c --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/NamedScreenTest.kt @@ -0,0 +1,105 @@ +package com.squareup.workflow1.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@OptIn(WorkflowUiExperimentalApi::class) +internal class NamedScreenTest { + object Whut : Screen + object Hey : Screen + + @Test fun same_type_same_name_matches() { + assertTrue { + compatible(NamedScreen(Hey, "eh"), NamedScreen(Hey, "eh")) + } + } + + @Test fun same_type_diff_name_matches() { + assertFalse { + compatible(NamedScreen(Hey, "blam"), NamedScreen(Hey, "bloom")) + } + } + + @Test fun diff_type_same_name_no_match() { + assertFalse { + compatible(NamedScreen(Hey, "a"), NamedScreen(Whut, "a")) + } + } + + @Test fun recursion() { + assertTrue { + compatible( + NamedScreen(NamedScreen(Hey, "one"), "ho"), + NamedScreen(NamedScreen(Hey, "one"), "ho") + ) + } + + assertFalse { + compatible( + NamedScreen(NamedScreen(Hey, "one"), "ho"), + NamedScreen(NamedScreen(Hey, "two"), "ho") + ) + } + + assertFalse { + compatible( + NamedScreen(NamedScreen(Hey, "a"), "ho"), + NamedScreen(NamedScreen(Whut, "a"), "ho") + ) + } + } + + @Test fun key_recursion() { + assertEquals( + NamedScreen(NamedScreen(Hey, "one"), "ho").compatibilityKey, + NamedScreen(NamedScreen(Hey, "one"), "ho").compatibilityKey + ) + + assertNotEquals( + NamedScreen(NamedScreen(Hey, "two"), "ho").compatibilityKey, + NamedScreen(NamedScreen(Hey, "one"), "ho").compatibilityKey + ) + + assertEquals( + NamedScreen(NamedScreen(Whut, "a"), "ho").compatibilityKey, + NamedScreen(NamedScreen(Whut, "a"), "ho").compatibilityKey + ) + } + + @Test fun recursive_keys_are_legible() { + assertEquals( + "NamedScreen:ho(NamedScreen:one(${Hey::class}))", + NamedScreen(NamedScreen(Hey, "one"), "ho").compatibilityKey + ) + } + + private class Foo(override val compatibilityKey: String) : Compatible, Screen + + @Test fun the_test_Compatible_class_actually_works() { + assertTrue { compatible(Foo("bar"), Foo("bar")) } + assertFalse { compatible(Foo("bar"), Foo("baz")) } + } + + @Test fun wrapping_custom_Compatible_compatibility_works() { + assertTrue { + compatible(NamedScreen(Foo("bar"), "name"), NamedScreen(Foo("bar"), "name")) + } + assertFalse { + compatible(NamedScreen(Foo("bar"), "name"), NamedScreen(Foo("baz"), "name")) + } + } + + @Test fun wrapping_custom_Compatible_keys_work() { + assertEquals( + NamedScreen(Foo("bar"), "name").compatibilityKey, + NamedScreen(Foo("bar"), "name").compatibilityKey + ) + assertNotEquals( + NamedScreen(Foo("baz"), "name").compatibilityKey, + NamedScreen(Foo("bar"), "name").compatibilityKey + ) + } +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewEnvironmentTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewEnvironmentTest.kt new file mode 100644 index 000000000..ed157dac6 --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewEnvironmentTest.kt @@ -0,0 +1,122 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame + +@OptIn(WorkflowUiExperimentalApi::class) +internal class ViewEnvironmentTest { + private object StringHint : ViewEnvironmentKey() { + override val default = "" + } + + private object OtherStringHint : ViewEnvironmentKey() { + override val default = "" + } + + private data class DataHint( + val int: Int = -1, + val string: String = "" + ) { + companion object : ViewEnvironmentKey() { + override val default = DataHint() + } + } + + @Test fun defaults() { + assertEquals(DataHint(), EMPTY[DataHint]) + } + + @Test fun put() { + val environment = EMPTY + + (StringHint to "fnord") + + (DataHint to DataHint(42, "foo")) + + assertEquals("fnord", environment[StringHint]) + assertEquals(DataHint(42, "foo"), environment[DataHint]) + } + + @Test fun map_equality() { + val env1 = EMPTY + + (StringHint to "fnord") + + (DataHint to DataHint(42, "foo")) + + val env2 = EMPTY + + (StringHint to "fnord") + + (DataHint to DataHint(42, "foo")) + + assertEquals(env2, env1) + } + + @Test fun map_inequality() { + val env1 = EMPTY + + (StringHint to "fnord") + + (DataHint to DataHint(42, "foo")) + + val env2 = EMPTY + + (StringHint to "fnord") + + (DataHint to DataHint(43, "foo")) + + assertNotEquals(env2, env1) + } + + @Test fun key_equality() { + assertEquals(StringHint, StringHint) + } + + @Test fun key_inequality() { + assertNotEquals>(OtherStringHint, StringHint) + } + + @Test fun override() { + val environment = EMPTY + + (StringHint to "able") + + (StringHint to "baker") + + assertEquals("baker", environment[StringHint]) + } + + @Test fun keys_of_the_same_type() { + val environment = EMPTY + + (StringHint to "able") + + (OtherStringHint to "baker") + + assertEquals("able", environment[StringHint]) + assertEquals("baker", environment[OtherStringHint]) + } + + @Test fun preserve_this_when_merging_empty() { + val environment = EMPTY + (StringHint to "able") + assertSame(environment, environment + EMPTY) + } + + @Test fun preserve_other_when_merging_to_empty() { + val environment = EMPTY + (StringHint to "able") + assertSame(environment, EMPTY + environment) + } + + @Test fun self_plus_self_is_self() { + val environment = EMPTY + (StringHint to "able") + assertSame(environment, environment + environment) + } + + @Test fun honors_combine() { + val combiningHint = object : ViewEnvironmentKey() { + override val default: String + get() = error("") + + override fun combine( + left: String, + right: String + ): String { + return "$left-$right" + } + } + + val left = EMPTY + (combiningHint to "able") + val right = EMPTY + (combiningHint to "baker") + assertEquals("able-baker", (left + right)[combiningHint]) + } +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewRegistryTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewRegistryTest.kt new file mode 100644 index 000000000..aa6e8e872 --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewRegistryTest.kt @@ -0,0 +1,139 @@ +package com.squareup.workflow1.ui + +import com.squareup.workflow1.ui.ViewEnvironment.Companion.EMPTY +import com.squareup.workflow1.ui.ViewRegistry.Entry +import com.squareup.workflow1.ui.ViewRegistry.Key +import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(WorkflowUiExperimentalApi::class) +internal class ViewRegistryTest { + + @Test fun keys_from_bindings() { + val factory1 = TestEntry(FooRendering::class) + val factory2 = TestEntry(BarRendering::class) + val registry = ViewRegistry(factory1, factory2) + + assertEquals(setOf(factory1.key, factory2.key), registry.keys) + } + + @Test fun constructor_throws_on_duplicates() { + val factory1 = TestEntry(FooRendering::class) + val factory2 = TestEntry(FooRendering::class) + + val error = assertFailsWith { + ViewRegistry(factory1, factory2) + } + assertTrue { error.message!!.endsWith("must not have duplicate entries.") } + assertTrue { error.message!!.contains(FooRendering::class.toString()) } + } + + @Test fun getFactoryFor_works() { + val fooFactory = TestEntry(FooRendering::class) + val registry = ViewRegistry(fooFactory) + + val factory = registry[Key(FooRendering::class, TestEntry::class)] + assertSame(fooFactory, factory) + } + + @Test fun getFactoryFor_returns_null_on_missing_binding() { + val fooFactory = TestEntry(FooRendering::class) + val registry = ViewRegistry(fooFactory) + + assertNull(registry[Key(BarRendering::class, TestEntry::class)]) + } + + @Test fun viewRegistry_with_no_arguments_infers_type() { + val registry = ViewRegistry() + assertTrue(registry.keys.isEmpty()) + } + + @Test fun merge_prefers_right_side() { + val factory1 = TestEntry(FooRendering::class) + val factory2 = TestEntry(FooRendering::class) + val merged = ViewRegistry(factory1) merge ViewRegistry(factory2) + + assertSame(factory2, merged[Key(FooRendering::class, TestEntry::class)]) + } + + @Test fun viewEnvironment_plus_ViewRegistry_prefers_new_registry_values() { + val leftBar = TestEntry(BarRendering::class) + val rightBar = TestEntry(BarRendering::class) + + val env = EMPTY + ViewRegistry(leftBar) + val merged = env + ViewRegistry(rightBar, TestEntry(FooRendering::class)) + + assertSame(rightBar, merged[ViewRegistry][Key(BarRendering::class, TestEntry::class)]) + assertNotNull(merged[ViewRegistry][Key(FooRendering::class, TestEntry::class)]) + } + + @Test fun viewEnvironment_plus_ViewEnvironment_prefers_right_ViewRegistry() { + val leftBar = TestEntry(BarRendering::class) + val rightBar = TestEntry(BarRendering::class) + + val leftEnv = EMPTY + ViewRegistry(leftBar) + val rightEnv = EMPTY + ViewRegistry(rightBar, TestEntry(FooRendering::class)) + val merged = leftEnv + rightEnv + + assertSame(rightBar, merged[ViewRegistry][Key(BarRendering::class, TestEntry::class)]) + assertNotNull(merged[ViewRegistry][Key(FooRendering::class, TestEntry::class)]) + } + + @Test fun plus_of_empty_returns_this() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + assertSame(reg, reg + ViewRegistry()) + } + + @Test fun plus_to_empty_returns_other() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + assertSame(reg, ViewRegistry() + reg) + } + + @Test fun merge_of_empty_reg_returns_this() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + assertSame(reg, reg merge ViewRegistry()) + } + + @Test fun merge_to_empty_reg_returns_other() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + assertSame(reg, ViewRegistry() merge reg) + } + + @Test fun env_plus_empty_reg_returns_env() { + val env = EMPTY + ViewRegistry(TestEntry(FooRendering::class)) + assertSame(env, env + ViewRegistry()) + } + + @Test fun env_plus_same_reg_returns_self() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + val env = EMPTY + reg + assertSame(env, env + reg) + } + + @Test fun reg_plus_self_throws_dup_entries() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + assertFailsWith { + reg + reg + } + } + + @Test fun registry_merge_self_returns_self() { + val reg = ViewRegistry(TestEntry(FooRendering::class)) + assertSame(reg, reg merge reg) + } + + private class TestEntry( + type: KClass + ) : Entry { + override val key = Key(type, TestEntry::class) + } + + private object FooRendering + private object BarRendering +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BackStackScreenTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BackStackScreenTest.kt new file mode 100644 index 000000000..b70191552 --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BackStackScreenTest.kt @@ -0,0 +1,141 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +@OptIn(WorkflowUiExperimentalApi::class) +internal class BackStackScreenTest { + data class FooScreen(val value: T) : Screen + data class BarScreen(val value: T) : Screen + + @Test fun top_is_last() { + assertEquals( + FooScreen(4), + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3), FooScreen(4)).top + ) + } + + @Test fun backstack_is_all_but_top() { + assertEquals( + listOf(FooScreen(1), FooScreen(2), FooScreen(3)), + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3), FooScreen(4)).backStack + ) + } + + @Test fun get_works() { + assertEquals( + FooScreen("baker"), + BackStackScreen(FooScreen("able"), FooScreen("baker"), FooScreen("charlie"))[1] + ) + } + + @Test fun plus_another_stack() { + assertEquals( + BackStackScreen( + FooScreen(1), + FooScreen(2), + FooScreen(3), + FooScreen(8), + FooScreen(9), + FooScreen(0) + ), + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)) + BackStackScreen( + FooScreen(8), + FooScreen(9), + FooScreen(0) + ) + ) + } + + @Test fun unequal_by_order() { + assertNotEquals( + BackStackScreen(FooScreen(3), FooScreen(2), FooScreen(1)), + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)) + ) + } + + @Test fun equal_have_matching_hash() { + assertEquals( + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)).hashCode(), + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)).hashCode() + ) + } + + @Test fun unequal_have_mismatching_hash() { + assertNotEquals( + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)).hashCode(), + BackStackScreen(FooScreen(1), FooScreen(2)).hashCode() + ) + } + + @Test fun bottom_and_rest() { + assertEquals( + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3), FooScreen(4)), + BackStackScreen.fromList( + listOf(element = FooScreen(1)) + listOf(FooScreen(2), FooScreen(3), FooScreen(4)) + ) + ) + } + + @Test fun singleton() { + val stack = BackStackScreen(FooScreen("hi")) + assertEquals(FooScreen("hi"), stack.top) + assertEquals(listOf(FooScreen("hi")), stack.frames) + assertEquals(BackStackScreen(FooScreen("hi")), stack) + } + + @Test fun map() { + assertEquals( + BackStackScreen(FooScreen(2), FooScreen(4), FooScreen(6)), + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)).map { + FooScreen(it.value * 2) + } + ) + } + + @Test fun mapIndexed() { + val source = BackStackScreen(FooScreen("able"), FooScreen("baker"), FooScreen("charlie")) + assertEquals( + BackStackScreen(FooScreen("0: able"), FooScreen("1: baker"), FooScreen("2: charlie")), + source.mapIndexed { index, frame -> FooScreen("$index: ${frame.value}") } + ) + } + + @Test fun nullFromEmptyList() { + assertNull(emptyList>().toBackStackScreenOrNull()) + } + + @Test fun throwFromEmptyList() { + assertFailsWith { emptyList>().toBackStackScreen() } + } + + @Test fun fromList() { + assertEquals( + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)), + listOf(FooScreen(1), FooScreen(2), FooScreen(3)).toBackStackScreen() + ) + } + + @Test fun fromListOrNull() { + assertEquals( + BackStackScreen(FooScreen(1), FooScreen(2), FooScreen(3)), + listOf(FooScreen(1), FooScreen(2), FooScreen(3)).toBackStackScreenOrNull() + ) + } + + /** + * To reminds us why we want the `out` in `BackStackScreen`. + * Without this, using `BackStackScreen<*>` as `RenderingT` is not practical. + */ + @Test fun heterogenousPlusIsTolerable() { + val foo = BackStackScreen(FooScreen(1)) + val bar = BackStackScreen(BarScreen(1)) + val both = foo + bar + assertEquals(foo + bar, both) + } +} diff --git a/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BodyAndOverlaysScreenTest.kt b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BodyAndOverlaysScreenTest.kt new file mode 100644 index 000000000..ec247d092 --- /dev/null +++ b/workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BodyAndOverlaysScreenTest.kt @@ -0,0 +1,55 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compatible +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(WorkflowUiExperimentalApi::class) +internal class BodyAndOverlaysScreenTest { + data class S(val value: T) : Screen + data class O(val value: T) : Overlay + + @Test fun mapBody() { + val before = BodyAndOverlaysScreen(S("s-before"), listOf(O("o-before")), name = "fnord") + val after = before.mapBody { + assertEquals("s-before", it.value) + S(25) + } + + assertEquals(25, after.body.value) + assertEquals(1, after.overlays.size) + assertSame(before.overlays[0], after.overlays.first()) + assertEquals("fnord", after.name) + assertTrue { compatible(before, after) } + } + + @Test fun mapOverlays() { + val before = BodyAndOverlaysScreen(S("s-before"), listOf(O("o-before")), name = "bagel") + val after = before.mapOverlays { + assertEquals("o-before", it.value) + O(25) + } + + assertSame(before.body, after.body) + assertEquals(1, after.overlays.size) + assertEquals(25, after.overlays.first().value) + assertEquals("bagel", after.name) + assertTrue { compatible(before, after) } + } + + @Test fun nameAffectsCompatibility() { + val unnamed = BodyAndOverlaysScreen(S(1)) + val alsoUnnamed = BodyAndOverlaysScreen(S("string")) + val named = BodyAndOverlaysScreen(S(1), name = "name1") + val alsoNamed = BodyAndOverlaysScreen(S("string"), name = "name2") + + assertTrue { compatible(unnamed, alsoUnnamed) } + assertFalse { compatible(unnamed, named) } + assertFalse { compatible(named, alsoNamed) } + } +}