From f15445cc933c40a3ae3850f31fcdf7be87cd0583 Mon Sep 17 00:00:00 2001 From: Milan Jovic Date: Sat, 17 Feb 2024 19:41:30 +0100 Subject: [PATCH 01/15] Create workflow-ui/core module. --- artifacts.json | 54 ++++ settings.gradle.kts | 1 + workflow-ui/core/api/core.api | 277 ++++++++++++++++++ workflow-ui/core/build.gradle.kts | 27 ++ .../core/dependencies/jsRuntimeClasspath.txt | 9 + .../core/dependencies/jvmRuntimeClasspath.txt | 8 + .../core/dependencies/runtimeClasspath.txt | 9 + workflow-ui/core/gradle.properties | 3 + .../com/squareup/workflow1.ui/Compatible.kt | 57 ++++ .../workflow1.ui/CompositeViewRegistry.kt | 61 ++++ .../com/squareup/workflow1.ui/Container.kt | 74 +++++ .../workflow1.ui/EnvironmentScreen.kt | 64 ++++ .../com/squareup/workflow1.ui/NamedScreen.kt | 29 ++ .../com/squareup/workflow1.ui/Screen.kt | 7 + .../squareup/workflow1.ui/TextController.kt | 90 ++++++ .../workflow1.ui/TypedViewRegistry.kt | 43 +++ .../squareup/workflow1.ui/ViewEnvironment.kt | 86 ++++++ .../com/squareup/workflow1.ui/ViewRegistry.kt | 214 ++++++++++++++ .../workflow1.ui/WorkflowUiExperimentalApi.kt | 25 ++ .../workflow1.ui/navigation/AlertOverlay.kt | 50 ++++ .../navigation/BackStackConfig.kt | 39 +++ .../navigation/BackStackScreen.kt | 115 ++++++++ .../navigation/BodyAndOverlaysScreen.kt | 90 ++++++ .../navigation/FullScreenModal.kt | 17 ++ .../workflow1.ui/navigation/ModalOverlay.kt | 10 + .../workflow1.ui/navigation/Overlay.kt | 22 ++ .../workflow1.ui/navigation/ScreenOverlay.kt | 15 + .../squareup.workflow1.ui/CompatibleTest.kt | 35 +++ .../CompositeViewRegistryTest.kt | 90 ++++++ .../EnvironmentScreenTest.kt | 115 ++++++++ .../squareup.workflow1.ui/NamedScreenTest.kt | 105 +++++++ .../ViewEnvironmentTest.kt | 122 ++++++++ .../squareup.workflow1.ui/ViewRegistryTest.kt | 139 +++++++++ .../navigation/BackStackScreenTest.kt | 141 +++++++++ .../navigation/BodyAndOverlaysScreenTest.kt | 55 ++++ 35 files changed, 2298 insertions(+) create mode 100644 workflow-ui/core/api/core.api create mode 100644 workflow-ui/core/build.gradle.kts create mode 100644 workflow-ui/core/dependencies/jsRuntimeClasspath.txt create mode 100644 workflow-ui/core/dependencies/jvmRuntimeClasspath.txt create mode 100644 workflow-ui/core/dependencies/runtimeClasspath.txt create mode 100644 workflow-ui/core/gradle.properties create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Compatible.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/CompositeViewRegistry.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Container.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/EnvironmentScreen.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/NamedScreen.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Screen.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TextController.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TypedViewRegistry.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewEnvironment.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewRegistry.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/WorkflowUiExperimentalApi.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/AlertOverlay.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackConfig.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackScreen.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BodyAndOverlaysScreen.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/FullScreenModal.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ModalOverlay.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/Overlay.kt create mode 100644 workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ScreenOverlay.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompatibleTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/CompositeViewRegistryTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/EnvironmentScreenTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/NamedScreenTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewEnvironmentTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/ViewRegistryTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BackStackScreenTest.kt create mode 100644 workflow-ui/core/src/commonTest/kotlin/com/squareup.workflow1.ui/navigation/BodyAndOverlaysScreenTest.kt 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) } + } +} From 2a9a064e904a479b0c16a16958dd6e2115ba446b Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Tue, 25 Jun 2024 12:49:12 -0700 Subject: [PATCH 02/15] Create compose multiplatform module and start on UI tests --- build-logic/build.gradle.kts | 4 + build-logic/settings.gradle.kts | 2 +- .../buildsrc/AndroidUiTestsPlugin.kt | 8 +- .../ComposeMultiplatformUiTestsPlugin.kt | 20 + .../buildsrc/ComposeUiTestsPlugin.kt | 4 +- .../buildsrc/KotlinMultiplatformExtensions.kt | 14 +- dependencies/classpath.txt | 66 +- gradle.properties | 3 + gradle/libs.versions.toml | 18 +- .../dependencies/runtimeClasspath.txt | 10 +- .../compose-multiplatform-samples/.gitignore | 1 + .../build.gradle.kts | 49 ++ .../proguard-rules.pro | 21 + .../compose/multiplatform/ExampleTest.kt | 48 ++ .../src/androidMain/AndroidManifest.xml | 23 + .../compose/multiplatform/MainActivity.kt | 5 + .../drawable-v24/ic_launcher_foreground.xml | 30 + .../res/drawable/ic_launcher_background.xml | 170 +++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 3593 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 5339 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2636 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 3388 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 4926 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 7472 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 7909 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 11873 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 10652 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 16570 bytes .../src/androidMain/res/values/strings.xml | 3 + .../compose/multiplatform/ExampleUnitTest.kt | 17 + settings.gradle.kts | 2 + .../dependencies/runtimeClasspath.txt | 10 +- .../dependencies/releaseRuntimeClasspath.txt | 10 +- .../dependencies/runtimeClasspath.txt | 10 +- workflow-core/build.gradle.kts | 4 +- .../dependencies/jsRuntimeClasspath.txt | 8 +- .../dependencies/jvmRuntimeClasspath.txt | 8 +- .../dependencies/runtimeClasspath.txt | 10 +- workflow-runtime/build.gradle.kts | 4 +- .../dependencies/jsRuntimeClasspath.txt | 8 +- .../dependencies/jvmRuntimeClasspath.txt | 8 +- .../dependencies/runtimeClasspath.txt | 10 +- .../dependencies/runtimeClasspath.txt | 12 +- .../dependencies/runtimeClasspath.txt | 10 +- .../compose-multiplatform/build.gradle.kts | 78 +++ .../compose/ScreenComposableFactoryAndroid.kt | 144 ++++ .../ViewEnvironmentWithComposeSupport.kt | 54 ++ .../src/androidMain/res/values/ids.xml | 5 + .../compose/ComposeViewTreeIntegrationTest.kt | 644 ++++++++++++++++++ .../ui/compose/CompositionRootTest.kt | 121 ++++ .../compose/NoTransitionBackStackContainer.kt | 44 ++ .../workflow1/ui/compose/RenderAsStateTest.kt | 393 +++++++++++ .../ui/compose/ScreenComposableFactoryTest.kt | 148 ++++ .../ui/compose/WorkflowRenderingTest.kt | 609 +++++++++++++++++ .../workflow1/ui/compose/ComposeScreen.kt | 98 +++ .../workflow1/ui/compose/CompositionRoot.kt | 104 +++ .../workflow1/ui/compose/RenderAsState.kt | 251 +++++++ .../ui/compose/ScreenComposableFactory.kt | 99 +++ .../compose/ScreenComposableFactoryFinder.kt | 70 ++ .../compose/TextControllerAsMutableState.kt | 47 ++ .../workflow1/ui/compose/WorkflowRendering.kt | 126 ++++ .../dependencies/releaseRuntimeClasspath.txt | 10 +- .../dependencies/releaseRuntimeClasspath.txt | 10 +- .../dependencies/releaseRuntimeClasspath.txt | 10 +- .../dependencies/runtimeClasspath.txt | 10 +- workflow-ui/core/build.gradle.kts | 4 +- .../core/dependencies/jsRuntimeClasspath.txt | 8 +- .../core/dependencies/jvmRuntimeClasspath.txt | 8 +- .../core/dependencies/runtimeClasspath.txt | 10 +- .../dependencies/releaseRuntimeClasspath.txt | 10 +- 72 files changed, 3596 insertions(+), 159 deletions(-) create mode 100644 build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt create mode 100644 samples/compose-multiplatform-samples/.gitignore create mode 100644 samples/compose-multiplatform-samples/build.gradle.kts create mode 100644 samples/compose-multiplatform-samples/proguard-rules.pro create mode 100644 samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt create mode 100644 samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml create mode 100644 samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-mdpi/ic_launcher.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xhdpi/ic_launcher.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml create mode 100644 samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt create mode 100644 workflow-ui/compose-multiplatform/build.gradle.kts create mode 100644 workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidMain/res/values/ids.xml create mode 100644 workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt create mode 100644 workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt create mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 2d5697a74..b4fdb5d48 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -33,6 +33,10 @@ gradlePlugin { id = "compose-ui-tests" implementationClass = "com.squareup.workflow1.buildsrc.ComposeUiTestsPlugin" } + create("compose-multiplatform-ui-tests") { + id = "compose-multiplatform-ui-tests" + implementationClass = "com.squareup.workflow1.buildsrc.ComposeMultiplatformUiTestsPlugin" + } create("dependency-guard") { id = "dependency-guard" implementationClass = "com.squareup.workflow1.buildsrc.DependencyGuardConventionPlugin" diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts index 97f2d7155..11f864471 100644 --- a/build-logic/settings.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -1,6 +1,6 @@ plugins { // Hardcoded as this is upstream of the version catalog. Keep this in sync with that. - kotlin("jvm") version "1.9.10" apply false + kotlin("jvm") version "1.9.24" apply false } dependencyResolutionManagement { diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidUiTestsPlugin.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidUiTestsPlugin.kt index 9f7b996e1..4ce59d3e7 100644 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidUiTestsPlugin.kt +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidUiTestsPlugin.kt @@ -1,7 +1,7 @@ package com.squareup.workflow1.buildsrc import com.android.build.gradle.TestedExtension -import com.rickbusarow.kgx.dependency +import com.rickbusarow.kgx.library import com.rickbusarow.kgx.libsCatalog import com.squareup.workflow1.buildsrc.internal.androidTestImplementation import com.squareup.workflow1.buildsrc.internal.invoke @@ -28,9 +28,9 @@ class AndroidUiTestsPlugin : Plugin { target.dependencies { androidTestImplementation(target.project(":workflow-ui:internal-testing-android")) - androidTestImplementation(target.libsCatalog.dependency("androidx-test-espresso-core")) - androidTestImplementation(target.libsCatalog.dependency("androidx-test-junit")) - androidTestImplementation(target.libsCatalog.dependency("squareup-leakcanary-instrumentation")) + androidTestImplementation(target.libsCatalog.library("androidx-test-espresso-core")) + androidTestImplementation(target.libsCatalog.library("androidx-test-junit")) + androidTestImplementation(target.libsCatalog.library("squareup-leakcanary-instrumentation")) } } } diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt new file mode 100644 index 000000000..46c98b7c4 --- /dev/null +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt @@ -0,0 +1,20 @@ +package com.squareup.workflow1.buildsrc + +import com.rickbusarow.kgx.library +import com.rickbusarow.kgx.libsCatalog +import com.squareup.workflow1.buildsrc.internal.androidTestImplementation +import com.squareup.workflow1.buildsrc.internal.invoke +import org.gradle.api.Plugin +import org.gradle.api.Project + +class ComposeMultiplatformUiTestsPlugin : Plugin { + + override fun apply(target: Project) { + target.plugins.apply(AndroidDefaultsPlugin::class.java) + + target.dependencies { + androidTestImplementation(target.project(":workflow-ui:internal-testing-compose")) + androidTestImplementation(target.libsCatalog.library("androidx-compose-ui-test-junit4")) + } + } +} diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeUiTestsPlugin.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeUiTestsPlugin.kt index 113a34b47..08d8a2f42 100644 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeUiTestsPlugin.kt +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeUiTestsPlugin.kt @@ -1,6 +1,6 @@ package com.squareup.workflow1.buildsrc -import com.rickbusarow.kgx.dependency +import com.rickbusarow.kgx.library import com.rickbusarow.kgx.libsCatalog import com.squareup.workflow1.buildsrc.internal.androidTestImplementation import com.squareup.workflow1.buildsrc.internal.invoke @@ -15,7 +15,7 @@ class ComposeUiTestsPlugin : Plugin { target.dependencies { androidTestImplementation(target.project(":workflow-ui:internal-testing-compose")) - androidTestImplementation(target.libsCatalog.dependency("androidx-compose-ui-test-junit4")) + androidTestImplementation(target.libsCatalog.library("androidx-compose-ui-test-junit4")) } } } diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt index 1d1427803..faf7f93ef 100644 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt @@ -1,17 +1,9 @@ package com.squareup.workflow1.buildsrc -import org.gradle.api.Project import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTargetWithSimulatorTests -fun KotlinMultiplatformExtension.iosWithSimulatorArm64(target: Project) { - ios() +fun KotlinMultiplatformExtension.iosTargets() { + iosX64() + iosArm64() iosSimulatorArm64() - - sourceSets.getByName("iosSimulatorArm64Main") { - it.dependsOn(sourceSets.getByName("iosMain")) - } - sourceSets.getByName("iosSimulatorArm64Test") { - it.dependsOn(sourceSets.getByName("iosTest")) - } } diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt index 9b8cbf21f..178896e45 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -57,7 +57,7 @@ com.google.code.findbugs:jsr305:3.0.2 com.google.code.gson:gson:2.8.9 com.google.crypto.tink:tink:1.7.0 com.google.dagger:dagger:2.28.3 -com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.10-1.0.13 +com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.24-1.0.20 com.google.errorprone:error_prone_annotations:2.11.0 com.google.flatbuffers:flatbuffers-java:1.12.0 com.google.guava:failureaccess:1.0.1 @@ -132,39 +132,39 @@ org.codehaus.woodstox:stax2-api:4.2.1 org.glassfish.jaxb:jaxb-runtime:2.3.2 org.glassfish.jaxb:txw2:2.3.2 org.jdom:jdom2:2.0.6 -org.jetbrains.dokka:dokka-core:1.9.10 -org.jetbrains.dokka:dokka-gradle-plugin:1.9.10 +org.jetbrains.dokka:dokka-core:1.9.24 +org.jetbrains.dokka:dokka-gradle-plugin:1.9.24 org.jetbrains.intellij.deps:trove4j:1.0.20200330 -org.jetbrains.kotlin:kotlin-android-extensions:1.9.10 -org.jetbrains.kotlin:kotlin-bom:1.9.10 -org.jetbrains.kotlin:kotlin-build-tools-api:1.9.10 -org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.10 -org.jetbrains.kotlin:kotlin-compiler-runner:1.9.10 -org.jetbrains.kotlin:kotlin-daemon-client:1.9.10 -org.jetbrains.kotlin:kotlin-daemon-embeddable:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugin-idea:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugin-model:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10 -org.jetbrains.kotlin:kotlin-gradle-plugins-bom:1.9.10 -org.jetbrains.kotlin:kotlin-klib-commonizer-api:1.9.10 -org.jetbrains.kotlin:kotlin-native-utils:1.9.10 -org.jetbrains.kotlin:kotlin-project-model:1.9.10 -org.jetbrains.kotlin:kotlin-reflect:1.9.10 -org.jetbrains.kotlin:kotlin-scripting-common:1.9.10 -org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.9.10 -org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.9.10 -org.jetbrains.kotlin:kotlin-scripting-jvm:1.9.10 -org.jetbrains.kotlin:kotlin-serialization: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.kotlin:kotlin-tooling-core:1.9.10 -org.jetbrains.kotlin:kotlin-util-io:1.9.10 -org.jetbrains.kotlin:kotlin-util-klib:1.9.10 +org.jetbrains.kotlin:kotlin-android-extensions:1.9.24 +org.jetbrains.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-build-tools-api:1.9.24 +org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.24 +org.jetbrains.kotlin:kotlin-compiler-runner:1.9.24 +org.jetbrains.kotlin:kotlin-daemon-client:1.9.24 +org.jetbrains.kotlin:kotlin-daemon-embeddable:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugin-annotations:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugin-api:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugin-idea-proto:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugin-idea:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugin-model:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24 +org.jetbrains.kotlin:kotlin-gradle-plugins-bom:1.9.24 +org.jetbrains.kotlin:kotlin-klib-commonizer-api:1.9.24 +org.jetbrains.kotlin:kotlin-native-utils:1.9.24 +org.jetbrains.kotlin:kotlin-project-model:1.9.24 +org.jetbrains.kotlin:kotlin-reflect:1.9.24 +org.jetbrains.kotlin:kotlin-scripting-common:1.9.24 +org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.9.24 +org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.9.24 +org.jetbrains.kotlin:kotlin-scripting-jvm:1.9.24 +org.jetbrains.kotlin:kotlin-serialization:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 +org.jetbrains.kotlin:kotlin-tooling-core:1.9.24 +org.jetbrains.kotlin:kotlin-util-io:1.9.24 +org.jetbrains.kotlin:kotlin-util-klib:1.9.24 org.jetbrains.kotlinx:binary-compatibility-validator:0.13.2 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.3 diff --git a/gradle.properties b/gradle.properties index a896b2f4c..4e412c1d9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -30,3 +30,6 @@ SONATYPE_STAGING_PROFILE=com.squareup # we're only supporting IR for now. # For details see https://kotlinlang.org/docs/js-ir-compiler.html kotlin.js.compiler=ir + +# Compose Multiplatform +org.jetbrains.compose.experimental.jscanvas.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5ac906084..cbdecb03a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,14 +13,14 @@ androidx-activity = "1.6.1" androidx-appcompat = "1.6.1" androidx-benchmark = "1.2.3" androidx-cardview = "1.0.0" -androidx-compose-compiler = "1.5.3" +androidx-compose-compiler = "1.5.14" # see https://developer.android.com/jetpack/compose/bom/bom-mapping -androidx-compose-bom = "2023.01.00" +androidx-compose-bom = "2024.05.00" androidx-constraintlayout = "2.1.4" androidx-core = "1.12.0" androidx-fragment = "1.3.6" androidx-gridlayout = "1.0.0" -androidx-lifecycle = "2.6.1" +androidx-lifecycle = "2.8.0" androidx-navigation = "2.4.0-alpha09" androidx-paging = "3.0.1" androidx-profileinstaller = "1.2.0-alpha02" @@ -29,7 +29,7 @@ androidx-room = "2.4.0-alpha04" androidx-savedstate = "1.2.1" androidx-startup = "1.1.0" androidx-test = "1.5.0" -androidx-test-espresso = "3.5.1" +androidx-test-espresso = "3.5.0" androidx-test-junit-ext = "1.1.5" androidx-test-runner = "1.5.2" androidx-test-truth-ext = "1.5.0" @@ -37,23 +37,24 @@ androidx-tracing = "1.1.0" androidx-transition = "1.4.1" detekt = "1.19.0" -dokka = "1.9.10" +dokka = "1.9.20" dependencyGuard = "0.4.3" google-accompanist = "0.18.0" google-dagger = "2.40.5" -google-ksp = "1.9.10-1.0.13" +google-ksp = "1.9.24-1.0.20" google-material = "1.4.0" groovy = "3.0.9" jUnit = "4.13.2" java-diff-utils = "4.12" javaParser = "3.24.0" +jetbrains-compose = "1.6.11" kgx = "0.1.12" kotest = "5.1.0" # Keep this in sync with what is hard-coded in build-logic/settings.gradle.kts as that is upstream # of loading the library versions from this file but should be the same. -kotlin = "1.9.10" +kotlin = "1.9.24" kotlinx-binary-compatibility = "0.13.2" kotlinx-coroutines = "1.7.3" @@ -91,6 +92,7 @@ vanniktech-publish = "0.27.0" [plugins] +jetbrains-compose-plugin = { id = "org.jetbrains.compose", version.ref = "jetbrains-compose" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } @@ -132,6 +134,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-geometry = { module = "androidx.compose.ui:ui-geometry" } androidx-compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } +androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } @@ -189,6 +192,7 @@ hamcrest = "org.hamcrest:hamcrest-core:2.2" java-diff-utils = { module = "io.github.java-diff-utils:java-diff-utils", version.ref = "java-diff-utils" } jetbrains-annotations = "org.jetbrains:annotations:24.0.1" +jetbrains-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } junit = { module = "junit:junit", version.ref = "jUnit" } diff --git a/internal-testing-utils/dependencies/runtimeClasspath.txt b/internal-testing-utils/dependencies/runtimeClasspath.txt index a689dbdb0..3cb16cdb0 100644 --- a/internal-testing-utils/dependencies/runtimeClasspath.txt +++ b/internal-testing-utils/dependencies/runtimeClasspath.txt @@ -1,6 +1,6 @@ -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains:annotations:13.0 diff --git a/samples/compose-multiplatform-samples/.gitignore b/samples/compose-multiplatform-samples/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/samples/compose-multiplatform-samples/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/build.gradle.kts b/samples/compose-multiplatform-samples/build.gradle.kts new file mode 100644 index 000000000..f6bc63048 --- /dev/null +++ b/samples/compose-multiplatform-samples/build.gradle.kts @@ -0,0 +1,49 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree + +plugins { + alias(libs.plugins.jetbrains.compose.plugin) + id("kotlin-multiplatform") + id("com.android.application") + id("compose-multiplatform-ui-tests") +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant { + sourceSetTree.set(KotlinSourceSetTree.test) + + dependencies { + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.test.manifest) + } + } + } + + sourceSets { + commonMain.dependencies { + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.preview) + implementation(project(":workflow-ui:compose-multiplatform")) + implementation(libs.kotlin.test.core) + } + } +} + +android { + val name = "com.squareup.sample.compose.multiplatform" + defaultConfig { + applicationId = name + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } + namespace = name +} diff --git a/samples/compose-multiplatform-samples/proguard-rules.pro b/samples/compose-multiplatform-samples/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/samples/compose-multiplatform-samples/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt new file mode 100644 index 000000000..9192150c9 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt @@ -0,0 +1,48 @@ +package com.squareup.sample.compose.multiplatform + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import org.junit.Test + +class ExampleTest { + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() = runComposeUiTest { + // Declares a mock UI to demonstrate API calls + // + // Replace with your own declarations to test the code of your project + setContent { + Column { + var text by remember { mutableStateOf("Hello") } + Text( + text = text, + modifier = Modifier.testTag("text") + ) + Button( + onClick = { text = "Compose" }, + modifier = Modifier.testTag("button") + ) { + Text("Click me") + } + } + } + + // Tests the declared UI with assertions and actions of the Compose Multiplatform testing API + onNodeWithTag("text").assertTextEquals("Hello") + onNodeWithTag("button").performClick() + onNodeWithTag("text").assertTextEquals("Compose") + } +} diff --git a/samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml b/samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..e525e854a --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + diff --git a/samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt b/samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt new file mode 100644 index 000000000..9fa79a579 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt @@ -0,0 +1,5 @@ +package com.squareup.sample.compose.multiplatform + +import androidx.activity.ComponentActivity + +class MainActivity : ComponentActivity() diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml b/samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..e93e11ade --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..eca70cfe5 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a571e60098c92c2baca8a5df62f2929cbff01b52 GIT binary patch literal 3593 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 P00000NkvXXu0mjfP=x?Y literal 0 HcmV?d00001 diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..61da551c5594a1f9d26193983d2cd69189014603 GIT binary patch literal 5339 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 literal 0 HcmV?d00001 diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..b216f2d313cc673d8b8c4da591c174ebed52795c GIT binary patch literal 11873 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 literal 0 HcmV?d00001 diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml b/samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml new file mode 100644 index 000000000..c33e34045 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml @@ -0,0 +1,3 @@ + + Workflow Multiplatform + \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt b/samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt new file mode 100644 index 000000000..5f49ea7a9 --- /dev/null +++ b/samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.squareup.sample.compose.multiplatform + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f0d7719eb..76f0ab53f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,6 +38,7 @@ include( ":benchmarks:performance-poetry:complex-benchmark", ":benchmarks:performance-poetry:complex-poetry", ":internal-testing-utils", + ":samples:compose-multiplatform-samples", ":samples:compose-samples", ":samples:containers:app-poetry", ":samples:containers:app-raven", @@ -68,6 +69,7 @@ include( ":workflow-testing", ":workflow-tracing", ":workflow-ui:compose", + ":workflow-ui:compose-multiplatform", ":workflow-ui:compose-tooling", ":workflow-ui:core", ":workflow-ui:core-common", diff --git a/trace-encoder/dependencies/runtimeClasspath.txt b/trace-encoder/dependencies/runtimeClasspath.txt index 1562a659c..44200e3e2 100644 --- a/trace-encoder/dependencies/runtimeClasspath.txt +++ b/trace-encoder/dependencies/runtimeClasspath.txt @@ -2,11 +2,11 @@ com.squareup.moshi:moshi-adapters:1.15.0 com.squareup.moshi:moshi:1.15.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt index d8cb7353d..3d72a2ba1 100644 --- a/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-config/config-android/dependencies/releaseRuntimeClasspath.txt @@ -1,10 +1,10 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt index d8cb7353d..3d72a2ba1 100644 --- a/workflow-config/config-jvm/dependencies/runtimeClasspath.txt +++ b/workflow-config/config-jvm/dependencies/runtimeClasspath.txt @@ -1,10 +1,10 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index e03ba149f..128c87289 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -1,4 +1,4 @@ -import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 +import com.squareup.workflow1.buildsrc.iosTargets plugins { id("kotlin-multiplatform") @@ -8,7 +8,7 @@ plugins { kotlin { val targets = project.findProperty("workflow.targets") ?: "kmp" if (targets == "kmp" || targets == "ios") { - iosWithSimulatorArm64(project) + iosTargets() } if (targets == "kmp" || targets == "jvm") { jvm { withJava() } diff --git a/workflow-core/dependencies/jsRuntimeClasspath.txt b/workflow-core/dependencies/jsRuntimeClasspath.txt index 6dd90ac24..9edbfbcf1 100644 --- a/workflow-core/dependencies/jsRuntimeClasspath.txt +++ b/workflow-core/dependencies/jsRuntimeClasspath.txt @@ -1,9 +1,9 @@ com.squareup.okio:okio-js:3.3.0 com.squareup.okio:okio:3.3.0 -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:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-dom-api-compat:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-js:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-core/dependencies/jvmRuntimeClasspath.txt b/workflow-core/dependencies/jvmRuntimeClasspath.txt index 3ced6a669..9bb8a4666 100644 --- a/workflow-core/dependencies/jvmRuntimeClasspath.txt +++ b/workflow-core/dependencies/jvmRuntimeClasspath.txt @@ -1,9 +1,9 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-core/dependencies/runtimeClasspath.txt b/workflow-core/dependencies/runtimeClasspath.txt index d8cb7353d..3d72a2ba1 100644 --- a/workflow-core/dependencies/runtimeClasspath.txt +++ b/workflow-core/dependencies/runtimeClasspath.txt @@ -1,10 +1,10 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-runtime/build.gradle.kts b/workflow-runtime/build.gradle.kts index 6d4ea4339..fde010973 100644 --- a/workflow-runtime/build.gradle.kts +++ b/workflow-runtime/build.gradle.kts @@ -1,4 +1,4 @@ -import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 +import com.squareup.workflow1.buildsrc.iosTargets plugins { id("kotlin-multiplatform") @@ -8,7 +8,7 @@ plugins { kotlin { val targets = project.findProperty("workflow.targets") ?: "kmp" if (targets == "kmp" || targets == "ios") { - iosWithSimulatorArm64(project) + iosTargets() } if (targets == "kmp" || targets == "jvm") { jvm {} diff --git a/workflow-runtime/dependencies/jsRuntimeClasspath.txt b/workflow-runtime/dependencies/jsRuntimeClasspath.txt index 6dd90ac24..9edbfbcf1 100644 --- a/workflow-runtime/dependencies/jsRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jsRuntimeClasspath.txt @@ -1,9 +1,9 @@ com.squareup.okio:okio-js:3.3.0 com.squareup.okio:okio:3.3.0 -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:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-dom-api-compat:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-js:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt index 3ced6a669..9bb8a4666 100644 --- a/workflow-runtime/dependencies/jvmRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jvmRuntimeClasspath.txt @@ -1,9 +1,9 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-rx2/dependencies/runtimeClasspath.txt b/workflow-rx2/dependencies/runtimeClasspath.txt index 1b8022059..00f28d4a7 100644 --- a/workflow-rx2/dependencies/runtimeClasspath.txt +++ b/workflow-rx2/dependencies/runtimeClasspath.txt @@ -1,11 +1,11 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 io.reactivex.rxjava2:rxjava:2.2.21 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-testing/dependencies/runtimeClasspath.txt b/workflow-testing/dependencies/runtimeClasspath.txt index 1de4577fd..85ebe34a6 100644 --- a/workflow-testing/dependencies/runtimeClasspath.txt +++ b/workflow-testing/dependencies/runtimeClasspath.txt @@ -2,12 +2,12 @@ app.cash.turbine:turbine-jvm:1.0.0 app.cash.turbine:turbine:1.0.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -org.jetbrains.kotlin:kotlin-bom:1.9.10 -org.jetbrains.kotlin:kotlin-reflect: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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-reflect:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-tracing/dependencies/runtimeClasspath.txt b/workflow-tracing/dependencies/runtimeClasspath.txt index 1562a659c..44200e3e2 100644 --- a/workflow-tracing/dependencies/runtimeClasspath.txt +++ b/workflow-tracing/dependencies/runtimeClasspath.txt @@ -2,11 +2,11 @@ com.squareup.moshi:moshi-adapters:1.15.0 com.squareup.moshi:moshi:1.15.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-ui/compose-multiplatform/build.gradle.kts b/workflow-ui/compose-multiplatform/build.gradle.kts new file mode 100644 index 000000000..cdae5b234 --- /dev/null +++ b/workflow-ui/compose-multiplatform/build.gradle.kts @@ -0,0 +1,78 @@ +import com.squareup.workflow1.buildsrc.iosTargets +import org.jetbrains.compose.ExperimentalComposeLibrary + +plugins { + alias(libs.plugins.jetbrains.compose.plugin) + id("kotlin-multiplatform") + id("com.android.library") + id("android-defaults") + id("android-ui-tests") + // id("published") +} + +kotlin { + val targets = project.findProperty("workflow.targets") ?: "kmp" + if (targets == "kmp" || targets == "ios") { + iosTargets() + } + if (targets == "kmp" || targets == "jvm") { + jvm {} + } + if (targets == "kmp" || targets == "js") { + js(IR).browser() + } + if (targets == "kmp" || targets == "android") { + androidTarget() + } + + sourceSets { + commonMain.dependencies { + api(project(":workflow-ui:core")) + + implementation(compose.foundation) + implementation(compose.components.uiToolingPreview) + implementation(compose.runtime) + implementation(compose.ui) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.squareup.okio) + implementation(libs.jetbrains.lifecycle.runtime.compose) + + implementation(project(":workflow-core")) + implementation(project(":workflow-runtime")) + } + + commonTest.dependencies { + implementation(libs.kotlin.test.jdk) + implementation(compose.foundation) + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } + + androidMain.dependencies { + api(project(":workflow-ui:core-android")) + implementation(libs.androidx.activity.core) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.runtime.saveable) + implementation(libs.androidx.lifecycle.common) + implementation(libs.androidx.lifecycle.core) + } + } +} + +android { + defaultConfig { + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures.compose = true + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + } + namespace = "com.squareup.workflow1.ui.compose.multiplatform" + testNamespace = "$namespace.test" + + dependencies { + debugImplementation(compose.uiTooling) + } +} diff --git a/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt b/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt new file mode 100644 index 000000000..176af4047 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt @@ -0,0 +1,144 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.ViewGroup +import androidx.activity.OnBackPressedDispatcherOwner +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.setViewTreeLifecycleOwner +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey +import com.squareup.workflow1.ui.compose.multiplatform.R +import com.squareup.workflow1.ui.show +import com.squareup.workflow1.ui.startShowing +import kotlin.reflect.KClass + +/** + * Convert a [ScreenComposableFactory] into a [ScreenViewFactory] + * by using a [ComposeView] to host [ScreenComposableFactory.Content]. + * + * It is unusual to use this function directly, it is mainly an implementation detail + * of [ViewEnvironment.withComposeInteropSupport]. + */ +@WorkflowUiExperimentalApi +public fun ScreenComposableFactory.asViewFactory(): + ScreenViewFactory { + + return object : ScreenViewFactory { + override val type = this@asViewFactory.type + + override fun buildView( + initialRendering: ScreenT, + initialEnvironment: ViewEnvironment, + context: Context, + container: ViewGroup? + ): ScreenViewHolder { + val view = ComposeView(context) + return ScreenViewHolder(initialEnvironment, view) { newRendering, environment -> + // Update the state whenever a new rendering is emitted. + // This lambda will be executed synchronously before ScreenViewHolder.show returns. + view.setContent { Content(newRendering, environment) } + } + } + } +} + + +/** + * Convert a [ScreenViewFactory] to a [ScreenComposableFactory], + * using [AndroidView] to host the `View` it builds. + * + * It is unusual to use this function directly, it is mainly an implementation detail + * of [ViewEnvironment.withComposeInteropSupport]. + */ +@WorkflowUiExperimentalApi +public fun ScreenViewFactory.asComposableFactory(): + ScreenComposableFactory { + return object : ScreenComposableFactory { + private val viewFactory = this@asComposableFactory + + override val type: KClass get() = viewFactory.type + + /** + * This is effectively the logic of `WorkflowViewStub`, but translated into Compose idioms. + * This approach has a few advantages: + * + * - Avoids extra custom views required to host `WorkflowViewStub` inside a Composition. Its trick + * of replacing itself in its parent doesn't play nicely with Compose. + * - Allows us to pass the correct parent view for inflation (the root of the composition). + * - Avoids `WorkflowViewStub` having to do its own lookup to find the correct + * [ScreenViewFactory], since we already have the correct one. + * - Propagate the current `LifecycleOwner` from [LocalLifecycleOwner] by setting it as the + * [ViewTreeLifecycleOwner] on the view. + * - Propagate the current [OnBackPressedDispatcherOwner] from either + * [LocalOnBackPressedDispatcherOwner] or the [viewEnvironment], + * both on the [AndroidView] via [setViewTreeOnBackPressedDispatcherOwner], + * and in the [ViewEnvironment] for use by any nested [WorkflowViewStub] + * + * Like `WorkflowViewStub`, this function uses the [viewFactory] to create and memoize a + * `View` to display the [rendering], keeps it updated with the latest [rendering] and + * [environment], and adds it to the composition. + */ + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + val lifecycleOwner = LocalLifecycleOwner.current + + // Make sure any nested WorkflowViewStub will be able to propagate the + // OnBackPressedDispatcherOwner, if we found one. No need to fail fast here. + // It's only an issue if someone tries to use it, and the error message + // at those call sites should be clear enough. + val onBackOrNull = LocalOnBackPressedDispatcherOwner.current + ?: environment.map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner + + val envWithOnBack = onBackOrNull + ?.let { environment + (OnBackPressedDispatcherOwnerKey to it) } + ?: environment + + AndroidView( + factory = { context -> + + // We pass in a null container because the container isn't a View, it's a composable. The + // compose machinery will generate an intermediate view that it ends up adding this to but + // we don't have access to that. + viewFactory + .startShowing(rendering, envWithOnBack, context, container = null) + .let { viewHolder -> + // Put the viewHolder in a tag so that we can find it in the update lambda, below. + viewHolder.view.setTag(R.id.workflow_screen_view_holder, viewHolder) + + // Unfortunately AndroidView doesn't propagate these itself. + viewHolder.view.setViewTreeLifecycleOwner(lifecycleOwner) + onBackOrNull?.let { + viewHolder.view.setViewTreeOnBackPressedDispatcherOwner(it) + } + + // We don't propagate the (non-compose) SavedStateRegistryOwner, or the (compose) + // SaveableStateRegistry, because currently all our navigation is implemented as + // Android views, which ensures there is always an Android view between any state + // registry and any Android view shown as a child of it, even if there's a compose + // view in between. + viewHolder.view + } + }, + // This function will be invoked every time this composable is recomposed, which means that + // any time a new rendering or view environment are passed in we'll send them to the view. + update = { view -> + @Suppress("UNCHECKED_CAST") + val viewHolder = + view.getTag(R.id.workflow_screen_view_holder) as ScreenViewHolder + viewHolder.show(rendering, envWithOnBack) + } + ) + } + } +} diff --git a/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt b/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt new file mode 100644 index 000000000..04c809dbd --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt @@ -0,0 +1,54 @@ +package com.squareup.workflow1.ui.compose + +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewFactoryFinder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Replaces the [ScreenComposableFactoryFinder] and [ScreenViewFactoryFinder] + * found in the receiving [ViewEnvironment] with wrappers that are able to + * delegate from one platform to the other. Required to allow + * [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] + * to handle renderings bound to `@Composable` functions, and to allow + * [WorkflowRendering] to handle renderings bound to [ScreenViewFactory]. + * + * Note that the standard navigation related [Screen] types + * (e.g. [BackStackScreen][com.squareup.workflow1.ui.navigation.BackStackScreen]) + * are mainly bound to [View][android.view.View]-based implementations. + * Until that changes, effectively every Compose-based app must call this method. + * + * App-specific customizations of [ScreenComposableFactoryFinder] and [ScreenViewFactoryFinder] + * must be placed in the [ViewEnvironment] before calling this method. + */ +@WorkflowUiExperimentalApi +public fun ViewEnvironment.withComposeInteropSupport(): ViewEnvironment { + val rawViewFactoryFinder = get(ScreenViewFactoryFinder) + val rawComposableFactoryFinder = get(ScreenComposableFactoryFinder) + + val convertingViewFactoryFinder = object : ScreenViewFactoryFinder { + override fun getViewFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenViewFactory? { + return rawViewFactoryFinder.getViewFactoryForRendering(environment, rendering) + ?: rawComposableFactoryFinder.getComposableFactoryForRendering(environment, rendering) + ?.asViewFactory() + } + } + + val convertingComposableFactoryFinder = object : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + return rawComposableFactoryFinder.getComposableFactoryForRendering(environment, rendering) + ?: rawViewFactoryFinder.getViewFactoryForRendering(environment, rendering) + ?.asComposableFactory() + } + } + + return this + (ScreenViewFactoryFinder to convertingViewFactoryFinder) + + (ScreenComposableFactoryFinder to convertingComposableFactoryFinder) +} diff --git a/workflow-ui/compose-multiplatform/src/androidMain/res/values/ids.xml b/workflow-ui/compose-multiplatform/src/androidMain/res/values/ids.xml new file mode 100644 index 000000000..39544e2f0 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidMain/res/values/ids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt new file mode 100644 index 000000000..d4deac03b --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt @@ -0,0 +1,644 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentDialog +import androidx.compose.foundation.clickable +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnDetachedFromWindow +import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.internal.test.WorkflowUiTestActivity +import com.squareup.workflow1.ui.navigation.AndroidOverlay +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.BodyAndOverlaysScreen +import com.squareup.workflow1.ui.navigation.OverlayDialogFactory +import com.squareup.workflow1.ui.navigation.ScreenOverlay +import com.squareup.workflow1.ui.navigation.asDialogHolderWithContent +import com.squareup.workflow1.ui.plus +import leakcanary.DetectLeaksAfterTestSuccess +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +internal class ComposeViewTreeIntegrationTest { + + private val composeRule = createAndroidComposeRule() + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + private val scenario get() = composeRule.activityRule.scenario + + @Before fun setUp() { + scenario.onActivity { + it.viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(NoTransitionBackStackContainer) + } + } + + @Test fun compose_view_assertions_work() { + val firstScreen = TestComposeRendering("first") { + BasicText("First Screen") + } + val secondScreen = TestComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithText("First Screen").assertIsDisplayed() + + // Navigate away from the first screen. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.onNodeWithText("First Screen").assertDoesNotExist() + } + + @Test fun composition_is_disposed_when_navigated_away_dispose_on_detach_strategy() { + var composedCount = 0 + var disposedCount = 0 + val firstScreen = TestComposeRendering("first", disposeStrategy = DisposeOnDetachedFromWindow) { + DisposableEffect(Unit) { + composedCount++ + onDispose { + disposedCount++ + } + } + } + val secondScreen = TestComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(0) + } + + // Navigate away. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(1) + } + } + + @Test fun composition_is_disposed_when_navigated_away_dispose_on_destroy_strategy() { + var composedCount = 0 + var disposedCount = 0 + val firstScreen = + TestComposeRendering("first", disposeStrategy = DisposeOnViewTreeLifecycleDestroyed) { + DisposableEffect(Unit) { + composedCount++ + onDispose { + disposedCount++ + } + } + } + val secondScreen = TestComposeRendering("second") {} + + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(0) + } + + // Navigate away. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.runOnIdle { + assertThat(composedCount).isEqualTo(1) + assertThat(disposedCount).isEqualTo(1) + } + } + + @Test fun composition_state_is_restored_after_config_change() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + // Simulate config change. + scenario.recreate() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test fun composition_state_is_restored_after_navigating_back() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertDoesNotExist() + + // Navigate back. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test + fun composition_state_is_restored_after_config_change_then_navigating_back() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + scenario.recreate() + + composeRule.onNodeWithText("nothing to see here") + .assertIsDisplayed() + + // Navigate to the first screen again. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test fun composition_state_is_not_restored_after_screen_is_removed_from_backstack() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + // Remove the initial screen from the backstack – this should drop its state. + scenario.onActivity { + it.setBackstack(secondScreen) + } + + // Navigate to the first screen again. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + } + + @Test + fun composition_state_is_not_restored_after_screen_is_removed_and_replaced_from_backstack() { + val firstScreen = TestComposeRendering("first") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + val secondScreen = TestComposeRendering("second") { + BasicText("nothing to see here") + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setBackstack(firstScreen) + } + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + + // Add a screen to the backstack. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + // Remove the initial screen from the backstack – this should drop its state. + scenario.onActivity { + it.setBackstack(secondScreen) + } + + // Put the initial screen back – it should still not have saved state anymore. + scenario.onActivity { + it.setBackstack(firstScreen, secondScreen) + } + + // Navigate to the first screen again. + scenario.onActivity { + it.setBackstack(firstScreen) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + } + + @Test fun composition_is_restored_in_modal_after_config_change() { + val firstScreen: Screen = TestComposeRendering(compatibilityKey = "") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setRendering( + BodyAndOverlaysScreen( + EmptyRendering, + listOf(TestOverlay(BackStackScreen(EmptyRendering, firstScreen))) + ) + ) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + scenario.recreate() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + } + + @Test fun composition_is_restored_in_multiple_modals_after_config_change() { + val firstScreen: Screen = TestComposeRendering(compatibilityKey = "0") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag) + ) + } + + val secondScreen: Screen = TestComposeRendering(compatibilityKey = "1") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter2: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag2) + ) + } + + val thirdScreen: Screen = TestComposeRendering(compatibilityKey = "2") { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter3: $counter", + Modifier + .clickable { counter++ } + .testTag(CounterTag3) + ) + } + + // Show first screen to initialize state. + scenario.onActivity { + it.setRendering( + BodyAndOverlaysScreen( + EmptyRendering, + listOf( + TestOverlay(firstScreen), + TestOverlay(secondScreen), + TestOverlay(thirdScreen) + ) + ) + ) + } + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 0") + .performClick() + .assertTextEquals("Counter: 1") + + composeRule.onNodeWithTag(CounterTag2) + .assertTextEquals("Counter2: 0") + .performClick() + .assertTextEquals("Counter2: 1") + + composeRule.onNodeWithTag(CounterTag3) + .assertTextEquals("Counter3: 0") + .performClick() + .assertTextEquals("Counter3: 1") + + scenario.recreate() + + composeRule.onNodeWithTag(CounterTag) + .assertTextEquals("Counter: 1") + + composeRule.onNodeWithTag(CounterTag2) + .assertTextEquals("Counter2: 1") + + composeRule.onNodeWithTag(CounterTag3) + .assertTextEquals("Counter3: 1") + } + + @Test fun composition_is_restored_in_multiple_modals_backstacks_after_config_change() { + fun createRendering( + layer: Int, + screen: Int + ) = TestComposeRendering( + // Use the same compatibility key across layers – these screens are in different modals, so + // they won't conflict. + compatibilityKey = screen.toString() + ) { + var counter by rememberSaveable { mutableStateOf(0) } + BasicText( + "Counter[$layer][$screen]: $counter", + Modifier + .clickable { counter++ } + .testTag("L${layer}S$screen") + ) + } + + val layer0Screen0 = createRendering(0, 0) + val layer0Screen1 = createRendering(0, 1) + val layer1Screen0 = createRendering(1, 0) + val layer1Screen1 = createRendering(1, 1) + + // Show first screen to initialize state. + scenario.onActivity { + it.setRendering( + BodyAndOverlaysScreen( + EmptyRendering, + listOf( + TestOverlay(BackStackScreen(EmptyRendering, layer0Screen0)), + // A SavedStateRegistry is set up for each modal. Each registry needs a unique name, + // and these names default to their `Compatible.keyFor` value. When we show two + // of the same type at the same time, we need to give them unique names. + TestOverlay(NamedScreen(BackStackScreen(EmptyRendering, layer1Screen0), "another")) + ) + ) + ) + } + + composeRule.onNodeWithTag("L0S0") + .assertTextEquals("Counter[0][0]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[0][0]: 1") + + composeRule.onNodeWithTag("L1S0") + .assertTextEquals("Counter[1][0]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[1][0]: 1") + + // Push some screens onto the backstack. + scenario.onActivity { + it.setRendering( + BodyAndOverlaysScreen( + EmptyRendering, + listOf( + TestOverlay(BackStackScreen(EmptyRendering, layer0Screen0, layer0Screen1)), + // A SavedStateRegistry is set up for each modal. Each registry needs a unique name, + // and these names default to their `Compatible.keyFor` value. When we show two + // of the same type at the same time, we need to give them unique names. + TestOverlay( + NamedScreen(BackStackScreen(EmptyRendering, layer1Screen0, layer1Screen1), "another") + ) + ) + ) + ) + } + + composeRule.onNodeWithTag("L0S0") + .assertDoesNotExist() + composeRule.onNodeWithTag("L1S0") + .assertDoesNotExist() + + composeRule.onNodeWithTag("L0S1") + .assertTextEquals("Counter[0][1]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[0][1]: 1") + + composeRule.onNodeWithTag("L1S1") + .assertTextEquals("Counter[1][1]: 0") + .assertIsDisplayed() + .performClick() + .assertTextEquals("Counter[1][1]: 1") + + // Simulate config change. + scenario.recreate() + + // Check that the last-shown screens were restored. + composeRule.onNodeWithTag("L0S1") + .assertIsDisplayed() + composeRule.onNodeWithTag("L1S1") + .assertIsDisplayed() + + // Pop both backstacks and check that screens were restored. + scenario.onActivity { + it.setRendering( + BodyAndOverlaysScreen( + EmptyRendering, + listOf( + TestOverlay(BackStackScreen(EmptyRendering, layer0Screen0)), + // A SavedStateRegistry is set up for each modal. Each registry needs a unique name, + // and these names default to their `Compatible.keyFor` value. When we show two + // of the same type at the same time, we need to give them unique names. + TestOverlay(NamedScreen(BackStackScreen(EmptyRendering, layer1Screen0), "another")) + ) + ) + ) + } + + composeRule.onNodeWithText("Counter[0][0]: 1") + .assertIsDisplayed() + composeRule.onNodeWithText("Counter[1][0]: 1") + .assertIsDisplayed() + } + + private fun WorkflowUiTestActivity.setBackstack(vararg backstack: TestComposeRendering) { + setRendering( + BackStackScreen.fromList(listOf>(EmptyRendering) + backstack.asList()) + ) + } + + data class TestOverlay( + override val content: Screen + ) : ScreenOverlay, AndroidOverlay { + override fun map(transform: (Screen) -> U) = error("Not implemented") + + override val dialogFactory = + OverlayDialogFactory { initialRendering, initialEnvironment, context: Context -> + ComponentDialog(context).asDialogHolderWithContent(initialRendering, initialEnvironment) + } + } + + data class TestComposeRendering( + override val compatibilityKey: String, + val disposeStrategy: ViewCompositionStrategy? = null, + val content: @Composable () -> Unit + ) : Compatible, AndroidScreen, ScreenViewFactory { + override val type: KClass = TestComposeRendering::class + override val viewFactory: ScreenViewFactory get() = this + + override fun buildView( + initialRendering: TestComposeRendering, + initialEnvironment: ViewEnvironment, + context: Context, + container: ViewGroup? + ): ScreenViewHolder { + var lastCompositionStrategy = initialRendering.disposeStrategy + + return ComposeView(context).let { view -> + lastCompositionStrategy?.let(view::setViewCompositionStrategy) + + ScreenViewHolder(initialEnvironment, view) { rendering, _ -> + if (rendering.disposeStrategy != lastCompositionStrategy) { + lastCompositionStrategy = rendering.disposeStrategy + lastCompositionStrategy?.let { view.setViewCompositionStrategy(it) } + } + + view.setContent(rendering.content) + } + } + } + } + + object EmptyRendering : AndroidScreen { + override val viewFactory: ScreenViewFactory + get() = ScreenViewFactory.fromCode { _, e, c, _ -> + ScreenViewHolder(e, View(c)) { _, _ -> } + } + } + + companion object { + const val CounterTag = "counter" + const val CounterTag2 = "counter2" + const val CounterTag3 = "counter3" + } +} diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt new file mode 100644 index 000000000..bce16d6a7 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt @@ -0,0 +1,121 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import leakcanary.DetectLeaksAfterTestSuccess +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@OptIn(WorkflowUiExperimentalApi::class) +internal class CompositionRootTest { + + private val composeRule = createComposeRule() + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun wrappedWithRootIfNecessary_wrapsWhenNecessary() { + val root: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + WrappedWithRootIfNecessary(root) { + BasicText("two") + } + } + + // These semantics used to merge, but as of dev15, they don't, which seems to be a bug. + // https://issuetracker.google.com/issues/161979921 + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun wrappedWithRootIfNecessary_onlyWrapsOnce() { + val root: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + WrappedWithRootIfNecessary(root) { + BasicText("two") + WrappedWithRootIfNecessary(root) { + BasicText("three") + } + } + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + composeRule.onNodeWithText("three").assertIsDisplayed() + } + + @Test fun wrappedWithRootIfNecessary_seesUpdatesFromRootWrapper() { + val wrapperText = mutableStateOf("one") + val root: CompositionRoot = { content -> + Column { + BasicText(wrapperText.value) + content() + } + } + + composeRule.setContent { + WrappedWithRootIfNecessary(root) { + BasicText("two") + } + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + wrapperText.value = "ENO" + composeRule.onNodeWithText("ENO").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun wrappedWithRootIfNecessary_rewrapsWhenDifferentRoot() { + val root1: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + val root2: CompositionRoot = { content -> + Column { + BasicText("ENO") + content() + } + } + val viewEnvironment = mutableStateOf(root1) + + composeRule.setContent { + WrappedWithRootIfNecessary(viewEnvironment.value) { + BasicText("two") + } + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + viewEnvironment.value = root2 + composeRule.onNodeWithText("ENO").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } +} diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt new file mode 100644 index 000000000..f6bb3821e --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt @@ -0,0 +1,44 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.R +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.navigation.BackStackContainer +import com.squareup.workflow1.ui.navigation.BackStackScreen + +/** + * A subclass of [BackStackContainer] that disables transitions to make it simpler to test the + * actual backstack logic. Views are just swapped instantly. + */ +// TODO (https://github.com/square/workflow-kotlin/issues/306) Remove once BackStackContainer is +// transition-ignorant. +@OptIn(WorkflowUiExperimentalApi::class) +internal class NoTransitionBackStackContainer(context: Context) : BackStackContainer(context) { + + override fun performTransition( + oldHolderMaybe: ScreenViewHolder>?, + newHolder: ScreenViewHolder>, + popped: Boolean + ) { + oldHolderMaybe?.view?.let(::removeView) + addView(newHolder.view) + } + + companion object : ScreenViewFactory> + by ScreenViewFactory.fromCode( + buildView = { _, initialEnvironment, context, _ -> + val view = NoTransitionBackStackContainer(context) + .apply { + id = R.id.workflow_back_stack_container + layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) + } + ScreenViewHolder(initialEnvironment, view) { rendering, environment -> + view.update(rendering, environment) + } + } + ) +} diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt new file mode 100644 index 000000000..e8f562f72 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt @@ -0,0 +1,393 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.action +import com.squareup.workflow1.parse +import com.squareup.workflow1.readUtf8WithLength +import com.squareup.workflow1.rendering +import com.squareup.workflow1.stateless +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.RenderAsStateTest.SnapshottingWorkflow.SnapshottedRendering +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.writeUtf8WithLength +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.isActive +import kotlinx.coroutines.job +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import leakcanary.DetectLeaksAfterTestSuccess +import okio.ByteString +import okio.ByteString.Companion.decodeBase64 +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import kotlin.test.assertFailsWith + +@RunWith(AndroidJUnit4::class) +@OptIn(WorkflowUiExperimentalApi::class) +internal class RenderAsStateTest { + + private val composeRule = createComposeRule() + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun passesPropsThrough() { + val workflow = Workflow.stateless { it } + lateinit var initialRendering: String + + composeRule.setContent { + initialRendering = workflow.renderAsState(props = "foo", onOutput = {}).value + } + + composeRule.runOnIdle { + assertThat(initialRendering).isEqualTo("foo") + } + } + + @Test fun seesPropsAndRenderingUpdates() { + val workflow = Workflow.stateless { it } + val props = mutableStateOf("foo") + lateinit var rendering: String + + composeRule.setContent { + rendering = workflow.renderAsState(props.value, onOutput = {}).value + } + + composeRule.runOnIdle { + assertThat(rendering).isEqualTo("foo") + props.value = "bar" + } + composeRule.runOnIdle { + assertThat(rendering).isEqualTo("bar") + } + } + + @Test fun invokesOutputCallback() { + val workflow = Workflow.stateless Unit> { + { + string -> + actionSink.send(action { setOutput(string) }) + } + } + val receivedOutputs = mutableListOf() + lateinit var rendering: (String) -> Unit + + composeRule.setContent { + rendering = workflow.renderAsState(props = Unit, onOutput = { receivedOutputs += it }).value + } + + composeRule.runOnIdle { + assertThat(receivedOutputs).isEmpty() + rendering("one") + } + + composeRule.runOnIdle { + assertThat(receivedOutputs).isEqualTo(listOf("one")) + rendering("two") + } + + composeRule.runOnIdle { + assertThat(receivedOutputs).isEqualTo(listOf("one", "two")) + } + } + + @Test fun savesSnapshot() { + val workflow = SnapshottingWorkflow() + val savedStateRegistry = SaveableStateRegistry(emptyMap()) { true } + lateinit var rendering: SnapshottedRendering + val scope = TestScope() + + composeRule.setContent { + CompositionLocalProvider(LocalSaveableStateRegistry provides savedStateRegistry) { + rendering = renderAsState( + workflow = workflow, + scope = scope, + props = Unit, + interceptors = emptyList(), + onOutput = {}, + snapshotKey = SNAPSHOT_KEY + ).value + } + } + + composeRule.runOnIdle { + assertThat(rendering.string).isEmpty() + rendering.updateString("foo") + } + + // Move along the Workflow. + scope.advanceUntilIdle() + + composeRule.runOnIdle { + val savedValues = savedStateRegistry.performSave() + println("saved keys: ${savedValues.keys}") + // Relying on the int key across all runtimes is brittle, so use an explicit key. + @Suppress("UNCHECKED_CAST") + val snapshot = + ByteString.of(*((savedValues.getValue(SNAPSHOT_KEY).single() as State).value)) + println("snapshot: ${snapshot.base64()}") + assertThat(snapshot).isEqualTo(EXPECTED_SNAPSHOT) + } + } + + @Test fun restoresSnapshot() { + val workflow = SnapshottingWorkflow() + val restoreValues = + mapOf(SNAPSHOT_KEY to listOf(mutableStateOf(EXPECTED_SNAPSHOT.toByteArray()))) + val savedStateRegistry = SaveableStateRegistry(restoreValues) { true } + lateinit var rendering: SnapshottedRendering + + composeRule.setContent { + CompositionLocalProvider(LocalSaveableStateRegistry provides savedStateRegistry) { + rendering = renderAsState( + workflow = workflow, + scope = rememberCoroutineScope(), + props = Unit, + interceptors = emptyList(), + onOutput = {}, + snapshotKey = "workflow-snapshot" + ).value + } + } + + composeRule.runOnIdle { + assertThat(rendering.string).isEqualTo("foo") + } + } + + @Test fun savesAndRestoresSnapshotOnConfigChange() { + val stateRestorationTester = StateRestorationTester(composeRule) + val workflow = SnapshottingWorkflow() + lateinit var rendering: SnapshottedRendering + val scope = TestScope() + + stateRestorationTester.setContent { + rendering = workflow.renderAsState( + scope = scope, + props = Unit, + interceptors = emptyList(), + onOutput = {}, + ).value + } + + composeRule.runOnIdle { + assertThat(rendering.string).isEmpty() + rendering.updateString("foo") + } + + // Move along workflow before saving state! + scope.advanceUntilIdle() + + stateRestorationTester.emulateSavedInstanceStateRestore() + + composeRule.runOnIdle { + assertThat(rendering.string).isEqualTo("foo") + } + } + + @Test fun restoresFromSnapshotWhenWorkflowChanged() { + val workflow1 = SnapshottingWorkflow() + val workflow2 = SnapshottingWorkflow() + val currentWorkflow = mutableStateOf(workflow1) + lateinit var rendering: SnapshottedRendering + // Since we have frame timeouts we need to control the scope of the Workflow Runtime as + // well as the scope of the Recomposer. + val scope = TestScope() + + var compositionCount = 0 + var lastCompositionCount = 0 + fun assertWasRecomposed() { + assertThat(compositionCount).isGreaterThan(lastCompositionCount) + lastCompositionCount = compositionCount + } + + composeRule.setContent { + compositionCount++ + rendering = + currentWorkflow.value.renderAsState(props = Unit, onOutput = {}, scope = scope).value + } + + // Initialize the first workflow. + composeRule.runOnIdle { + assertThat(rendering.string).isEmpty() + assertWasRecomposed() + rendering.updateString("one") + } + + // Move along the workflow. + scope.advanceUntilIdle() + + composeRule.runOnIdle { + assertWasRecomposed() + assertThat(rendering.string).isEqualTo("one") + } + + // Change the workflow instance being rendered. This should restart the runtime, but restore + // it from the snapshot. + currentWorkflow.value = workflow2 + + scope.advanceUntilIdle() + + composeRule.runOnIdle { + assertWasRecomposed() + assertThat(rendering.string).isEqualTo("one") + } + } + + @Test fun renderingIsAvailableImmediatelyWhenWorkflowScopeUsesDifferentDispatcher() { + val workflow = Workflow.rendering("hello") + val scope = TestScope() + + composeRule.setContent { + val initialRendering = workflow.renderAsState( + props = Unit, + onOutput = {}, + scope = scope + ) + assertThat(initialRendering.value).isNotNull() + } + } + + @Test fun runtimeIsCancelledWhenCompositionFails() { + var innerJob: Job? = null + val workflow = Workflow.stateless { + runningSideEffect("test") { + innerJob = coroutineContext.job + awaitCancellation() + } + } + val scope = TestScope(UnconfinedTestDispatcher()) + + class CancelCompositionException : RuntimeException() + + scope.runTest { + assertFailsWith { + composeRule.setContent { + workflow.renderAsState(props = Unit, onOutput = {}, scope = scope) + throw CancelCompositionException() + } + } + + composeRule.runOnIdle { + assertThat(innerJob).isNotNull() + assertThat(innerJob!!.isCancelled).isTrue() + } + } + } + + @Test fun workflowScopeIsNotCancelledWhenRemovedFromComposition() { + val workflow = Workflow.stateless {} + val scope = TestScope() + var shouldRunWorkflow by mutableStateOf(true) + + scope.runTest { + composeRule.setContent { + if (shouldRunWorkflow) { + workflow.renderAsState(props = Unit, onOutput = {}, scope = scope) + } + } + + composeRule.runOnIdle { + assertThat(scope.isActive).isTrue() + } + + shouldRunWorkflow = false + + composeRule.runOnIdle { + scope.advanceUntilIdle() + assertThat(scope.isActive).isTrue() + } + } + } + + @Test fun runtimeIsCancelledWhenRemovedFromComposition() { + var innerJob: Job? = null + val workflow = Workflow.stateless { + runningSideEffect("test") { + innerJob = coroutineContext.job + awaitCancellation() + } + } + var shouldRunWorkflow by mutableStateOf(true) + + composeRule.setContent { + if (shouldRunWorkflow) { + workflow.renderAsState(props = Unit, onOutput = {}) + } + } + + composeRule.runOnIdle { + assertThat(innerJob).isNotNull() + assertThat(innerJob!!.isActive).isTrue() + } + + shouldRunWorkflow = false + + composeRule.runOnIdle { + assertThat(innerJob!!.isCancelled).isTrue() + } + } + + private companion object { + const val SNAPSHOT_KEY = "workflow-snapshot" + + /** Golden value from [savesSnapshot]. */ + val EXPECTED_SNAPSHOT = "AAAABwAAAANmb28AAAAA".decodeBase64()!! + } + + // Seems to be a problem accessing Workflow.stateful. + private class SnapshottingWorkflow : + StatefulWorkflow() { + + data class SnapshottedRendering( + val string: String, + val updateString: (String) -> Unit + ) + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): String = snapshot?.bytes?.parse { it.readUtf8WithLength() } ?: "" + + override fun render( + renderProps: Unit, + renderState: String, + context: RenderContext + ) = SnapshottedRendering( + string = renderState, + updateString = { newString -> context.actionSink.send(updateString(newString)) } + ) + + override fun snapshotState(state: String): Snapshot = + Snapshot.write { it.writeUtf8WithLength(state) } + + private fun updateString(newString: String) = action { + state = newString + } + } +} diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt new file mode 100644 index 000000000..23402346f --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt @@ -0,0 +1,148 @@ +package com.squareup.workflow1.ui.compose + +import android.content.Context +import android.widget.FrameLayout +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.WorkflowViewStub +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.plus +import leakcanary.DetectLeaksAfterTestSuccess +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith + +@OptIn(WorkflowUiExperimentalApi::class) +@RunWith(AndroidJUnit4::class) +internal class ScreenComposableFactoryTest { + + private val composeRule = createComposeRule() + + @get:Rule val rules: RuleChain = + RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun showsComposeContent() { + val viewFactory = ScreenComposableFactory { _, _ -> + BasicText("Hello, world!") + } + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(viewFactory)) + .withComposeInteropSupport() + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.show(TestRendering(), viewEnvironment) + } + } + + composeRule.onNodeWithText("Hello, world!").assertIsDisplayed() + } + + @Test fun getsRenderingUpdates() { + val viewFactory = ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text, Modifier.testTag("text")) + } + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(viewFactory)) + .withComposeInteropSupport() + var rendering by mutableStateOf(TestRendering("hello")) + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.show(rendering, viewEnvironment) + } + } + composeRule.onNodeWithTag("text").assertTextEquals("hello") + + rendering = TestRendering("world") + + composeRule.onNodeWithTag("text").assertTextEquals("world") + } + + @Test fun getsViewEnvironmentUpdates() { + val testEnvironmentKey = object : ViewEnvironmentKey() { + override val default: String get() = error("No default") + } + + val viewFactory = ScreenComposableFactory { _, environment -> + val text = environment[testEnvironmentKey] + BasicText(text, Modifier.testTag("text")) + } + val viewRegistry = ViewRegistry(viewFactory) + var viewEnvironment by mutableStateOf( + (ViewEnvironment.EMPTY + viewRegistry + (testEnvironmentKey to "hello")) + .withComposeInteropSupport() + ) + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.show(TestRendering(), viewEnvironment) + } + } + composeRule.onNodeWithTag("text").assertTextEquals("hello") + + viewEnvironment = viewEnvironment + (testEnvironmentKey to "world") + + composeRule.onNodeWithTag("text").assertTextEquals("world") + } + + @Test fun wrapsFactoryWithRoot() { + val wrapperText = mutableStateOf("one") + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(TestFactory)) + .withCompositionRoot { content -> + Column { + BasicText(wrapperText.value) + content() + } + } + .withComposeInteropSupport() + + composeRule.setContent { + AndroidView(::RootView) { + it.stub.show(TestRendering("two"), viewEnvironment) + } + } + + // Compose bug doesn't let us use assertIsDisplayed on older devices. + // See https://issuetracker.google.com/issues/157728188. + composeRule.onNodeWithText("one").assertExists() + composeRule.onNodeWithText("two").assertExists() + + wrapperText.value = "ENO" + + composeRule.onNodeWithText("ENO").assertExists() + composeRule.onNodeWithText("two").assertExists() + } + + private class RootView(context: Context) : FrameLayout(context) { + val stub = WorkflowViewStub(context).also(::addView) + } + + private data class TestRendering(val text: String = "") : Screen + + private companion object { + val TestFactory = ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text) + } + } +} diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt new file mode 100644 index 000000000..bd12f84db --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt @@ -0,0 +1,609 @@ +@file:Suppress("TestFunctionName") + +package com.squareup.workflow1.ui.compose + +import android.view.View +import android.widget.TextView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.plus +import com.squareup.workflow1.ui.withEnvironment +import leakcanary.DetectLeaksAfterTestSuccess +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +@RunWith(AndroidJUnit4::class) +internal class WorkflowRenderingTest { + + private val composeRule = createComposeRule() + + @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun doesNotRecompose_whenFactoryChanged() { + data class TestRendering( + val text: String + ) : Screen + + val registry1 = ViewRegistry( + ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text) + } + ) + val registry2 = ViewRegistry( + ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text.reversed()) + } + ) + val registry = mutableStateOf(registry1) + + composeRule.setContent { + WorkflowRendering(TestRendering("hello"), ViewEnvironment.EMPTY + registry.value) + } + + composeRule.onNodeWithText("hello").assertIsDisplayed() + registry.value = registry2 + composeRule.onNodeWithText("hello").assertIsDisplayed() + composeRule.onNodeWithText("olleh").assertDoesNotExist() + } + + @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() { + data class TestRendering(val text: String) : Screen + + val testFactory = ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text) + } + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(testFactory)) + .withCompositionRoot { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + WorkflowRendering(TestRendering("two"), viewEnvironment) + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun legacyAndroidViewRendersUpdates() { + val wrapperText = mutableStateOf("two") + + composeRule.setContent { + WorkflowRendering(LegacyViewRendering(wrapperText.value), env) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + // https://github.com/square/workflow-kotlin/issues/538 + @Test fun includesSupportForNamed() { + val wrapperText = mutableStateOf("two") + + composeRule.setContent { + val rendering = NamedScreen(LegacyViewRendering(wrapperText.value), "fnord") + WorkflowRendering(rendering, env) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + @Test fun namedScreenStaysInTheSameComposeView() { + composeRule.setContent { + val outer = LocalView.current + + WorkflowRendering( + viewEnvironment = env, + rendering = NamedScreen( + name = "fnord", + content = ComposeScreen { + val inner = LocalView.current + assertThat(inner).isSameInstanceAs(outer) + + BasicText("hello", Modifier.testTag("tag")) + } + ) + ) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("hello") + } + + @Test fun environmentScreenStaysInTheSameComposeView() { + val someKey = object : ViewEnvironmentKey() { + override val default = "default" + } + + composeRule.setContent { + val outer = LocalView.current + + WorkflowRendering( + viewEnvironment = env, + rendering = ComposeScreen { environment -> + val inner = LocalView.current + assertThat(inner).isSameInstanceAs(outer) + + BasicText(environment[someKey], Modifier.testTag("tag")) + }.withEnvironment((someKey to "fnord")) + ) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("fnord") + } + + @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() { + val lifecycleEvents = mutableListOf() + + class LifecycleRecorder : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + lifecycle.addObserver( + LifecycleEventObserver { _, event -> + lifecycleEvents += event + } + ) + onDispose { + // Yes, we're leaking the observer. That's intentional: we need to make sure we see any + // lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + class EmptyRendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) {} + } + + var rendering: Screen by mutableStateOf(LifecycleRecorder()) + composeRule.setContent { + WorkflowRendering(rendering, env) + } + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + lifecycleEvents.clear() + } + + rendering = EmptyRendering() + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + } + } + + @Test fun destroysChildLifecycle_fromLegacyView_whenIncompatibleRendering() { + val lifecycleEvents = mutableListOf() + + class LifecycleRecorder : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + val view = object : View(context) { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val lifecycle = this.findViewTreeLifecycleOwner()!!.lifecycle + lifecycle.addObserver( + LifecycleEventObserver { _, event -> lifecycleEvents += event } + ) + // Yes, we're leaking the observer. That's intentional: we need to make sure we see + // any lifecycle events that happen even after the composable is destroyed. + } + } + ScreenViewHolder(initialEnvironment, view) { _, _ -> } + } + } + + class EmptyRendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) {} + } + + var rendering: Screen by mutableStateOf(LifecycleRecorder()) + composeRule.setContent { + WorkflowRendering(rendering, env) + } + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + lifecycleEvents.clear() + } + + rendering = EmptyRendering() + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + } + } + + @Test fun followsParentLifecycle() { + val states = mutableListOf() + val parentOwner = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = registry + } + + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { + WorkflowRendering(LifecycleRecorder(states), env) + } + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(INITIALIZED).inOrder() + states.clear() + parentOwner.registry.currentState = STARTED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(CREATED, STARTED).inOrder() + states.clear() + parentOwner.registry.currentState = CREATED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(CREATED).inOrder() + states.clear() + parentOwner.registry.currentState = RESUMED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(STARTED, RESUMED).inOrder() + states.clear() + parentOwner.registry.currentState = DESTROYED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(STARTED, CREATED, DESTROYED).inOrder() + } + } + + @Test fun handlesParentInitiallyDestroyed() { + val states = mutableListOf() + val parentOwner = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = registry + } + composeRule.runOnIdle { + // Cannot go directly to DESTROYED + parentOwner.registry.currentState = CREATED + parentOwner.registry.currentState = DESTROYED + } + + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { + WorkflowRendering(LifecycleRecorder(states), env) + } + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(INITIALIZED).inOrder() + } + } + + @Test fun appliesModifierToComposableContent() { + class Rendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Box( + Modifier + .testTag("box") + .fillMaxSize() + ) + } + } + + composeRule.setContent { + WorkflowRendering( + Rendering(), + env, + Modifier.size(width = 42.dp, height = 43.dp) + ) + } + + composeRule.onNodeWithTag("box") + .assertWidthIsEqualTo(42.dp) + .assertHeightIsEqualTo(43.dp) + } + + @Test fun propagatesMinConstraints() { + class Rendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Box(Modifier.testTag("box")) + } + } + + composeRule.setContent { + WorkflowRendering( + Rendering(), + env, + Modifier.sizeIn(minWidth = 42.dp, minHeight = 43.dp) + ) + } + + composeRule.onNodeWithTag("box") + .assertWidthIsEqualTo(42.dp) + .assertHeightIsEqualTo(43.dp) + } + + @Test fun appliesModifierToViewContent() { + val viewId = View.generateViewId() + + class LegacyRendering(private val viewId: Int) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + val view = View(context) + ScreenViewHolder(initialEnvironment, view) { rendering, _ -> + view.id = rendering.viewId + } + } + } + + composeRule.setContent { + with(LocalDensity.current) { + WorkflowRendering( + LegacyRendering(viewId), + env, + Modifier.size(42.toDp(), 43.toDp()) + ) + } + } + + onView(withId(viewId)).check(matches(hasSize(42, 43))) + } + + @Test fun skipsPreviousContentWhenIncompatible() { + var disposeCount = 0 + + class Rendering( + override val compatibilityKey: String + ) : ComposableRendering, Compatible { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + var counter by rememberSaveable { mutableStateOf(0) } + Column { + BasicText( + "$compatibilityKey: $counter", + Modifier + .testTag("tag") + .clickable { counter++ } + ) + DisposableEffect(Unit) { + onDispose { + disposeCount++ + } + } + } + } + } + + var key by mutableStateOf("one") + composeRule.setContent { + WorkflowRendering(Rendering(key), env) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + .performClick() + .assertTextEquals("one: 1") + + key = "two" + + composeRule.onNodeWithTag("tag") + .assertTextEquals("two: 0") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(1) + } + + key = "one" + + // State should not be restored. + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(2) + } + } + + @Test fun doesNotSkipPreviousContentWhenCompatible() { + var disposeCount = 0 + + class Rendering(val text: String) : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + var counter by rememberSaveable { mutableStateOf(0) } + Column { + BasicText( + "$text: $counter", + Modifier + .testTag("tag") + .clickable { counter++ } + ) + DisposableEffect(Unit) { + onDispose { + disposeCount++ + } + } + } + } + } + + var text by mutableStateOf("one") + composeRule.setContent { + WorkflowRendering(Rendering(text), env) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + .performClick() + .assertTextEquals("one: 1") + + text = "two" + + // Counter state should be preserved. + composeRule.onNodeWithTag("tag") + .assertTextEquals("two: 1") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(0) + } + } + + @Suppress("SameParameterValue") + private fun hasSize( + width: Int, + height: Int + ) = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("has size ${width}x${height}px") + } + + override fun matchesSafely(item: View): Boolean { + return item.width == width && item.height == height + } + } + + private class LifecycleRecorder( + // For some reason, if we just capture the states val, it is null in the composable. + private val states: MutableList + ) : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + this@LifecycleRecorder.states += lifecycle.currentState + lifecycle.addObserver( + LifecycleEventObserver { _, _ -> + this@LifecycleRecorder.states += lifecycle.currentState + } + ) + onDispose { + // Yes, we're leaking the observer. That's intentional: we need to make sure we see any + // lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + /** + * It is significant that this returns a new instance on every call, since we can't rely on real + * implementations in the wild to reuse the same factory instance across rendering instances. + */ + private object InefficientComposableFinder : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + return if (rendering is ComposableRendering) { + object : ScreenComposableFactory { + override val type: KClass get() = error("whatever") + + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + (rendering as ComposableRendering).Content(environment) + } + } + } else { + super.getComposableFactoryForRendering( + environment, + rendering + ) + } + } + } + + private val env = + (ViewEnvironment.EMPTY + (ScreenComposableFactoryFinder to InefficientComposableFinder)) + .withComposeInteropSupport() + + private interface ComposableRendering : Screen { + @Composable fun Content(viewEnvironment: ViewEnvironment) + } + + private data class LegacyViewRendering(val text: String) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + val view = TextView(context) + ScreenViewHolder(initialEnvironment, view) { rendering, _ -> + view.text = rendering.text + } + } + } +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt new file mode 100644 index 000000000..06a00f645 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt @@ -0,0 +1,98 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Interface implemented by a rendering class to allow it to drive a composable UI via an + * appropriate [ScreenComposableFactory] implementation, by simply overriding the [Content] method. + * + * Note that it is generally an error for a [Workflow][com.squareup.workflow1.Workflow] + * to declare [ComposeScreen] as its `RenderingT` type -- prefer [Screen] for that. + * [ComposeScreen], like [AndroidScreen][com.squareup.workflow1.ui.AndroidScreen], + * is strictly a possible implementation detail of [Screen]. It is a convenience to + * minimize the boilerplate required to set up a [ScreenComposableFactory]. + * That interface is the fundamental unit of Compose tooling for Workflow UI. + * But in day to day use, most developer will work with [ComposeScreen] and be only + * vaguely aware of the existence of [ScreenComposableFactory], + * so the bulk of our description of working with Compose is here. + * + * **NB**: A Workflow app that relies on Compose must call [withComposeInteropSupport] + * on its top-level [ViewEnvironment]. See that function for details. + * + * Note that unlike most workflow view functions, [Content] does not take the rendering as a + * parameter. Instead, the rendering is the receiver, i.e. the current value of `this`. + * + * Example: + * + * @OptIn(WorkflowUiExperimentalApi::class) + * data class HelloScreen( + * val message: String, + * val onClick: () -> Unit + * ) : ComposeScreen { + * + * @Composable override fun Content(viewEnvironment: ViewEnvironment) { + * Button(onClick) { + * Text(message) + * } + * } + * } + * + * This is the simplest way to bridge the gap between your workflows and the UI, but using it + * requires your workflows code to reside in Android modules and depend upon the Compose runtime, + * instead of being pure Kotlin. If this is a problem, or you need more flexibility for any other + * reason, you can use [ViewRegistry] to bind your renderings to [ScreenComposableFactory] + * implementations at runtime. + * + * ## Nesting child renderings + * + * Workflows can render other workflows, and renderings from one workflow can contain renderings + * from other workflows. These renderings may all be bound to their own UI factories. + * A classic [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory] can + * use [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] to recursively show nested + * renderings. + * + * Compose-based UI may also show nested renderings. Doing so is as simple + * as calling [WorkflowRendering] and passing in the nested rendering. + * See the kdoc on that function for an example. + * + * Nested renderings will have access to any + * [composition locals][androidx.compose.runtime.CompositionLocal] defined in outer composable, even + * if there are legacy views in between them, as long as the [ViewEnvironment] is propagated + * continuously between the two factories. + * + * ## Initializing Compose context (Theming) + * + * Often all the [ScreenComposableFactory] factories in an app need to share some context – + * for example, certain composition locals need to be provided, such as `MaterialTheme`. + * To configure this shared context, call [withCompositionRoot] on your top-level [ViewEnvironment]. + * The first time a [ScreenComposableFactory] is used to show a rendering, its [Content] function + * will be wrapped with the [CompositionRoot]. See the documentation on [CompositionRoot] for + * more information. + */ +@WorkflowUiExperimentalApi +public interface ComposeScreen : Screen { + + /** + * The composable content of this rendering. This method will be called with the current rendering + * instance as the receiver, any time a new rendering is emitted, or the [viewEnvironment] + * changes. + */ + @Composable public fun Content(viewEnvironment: ViewEnvironment) +} + +/** + * Convenience function for creating anonymous [ComposeScreen]s since composable fun interfaces + * aren't supported. See the [ComposeScreen] class for more information. + */ +@WorkflowUiExperimentalApi +public inline fun ComposeScreen( + crossinline content: @Composable (ViewEnvironment) -> Unit +): ComposeScreen = object : ComposeScreen { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + content(viewEnvironment) + } +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt new file mode 100644 index 000000000..80d6df960 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt @@ -0,0 +1,104 @@ +@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") + +package com.squareup.workflow1.ui.compose + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.Companion.PRIVATE +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Used by [WrappedWithRootIfNecessary] to ensure the [CompositionRoot] is only applied once. + */ +private val LocalHasViewFactoryRootBeenApplied = staticCompositionLocalOf { false } + +/** + * A composable function that will be used to wrap the first (highest-level) + * [ScreenComposableFactory] view factory in a composition. This can be used to setup any + * [composition locals][androidx.compose.runtime.CompositionLocal] that all + * [ScreenComposableFactory] factories need access to, such as UI themes. + * + * This function will called once, to wrap the _highest-level_ [ScreenComposableFactory] + * in the tree. However, composition locals are propagated down to child [ScreenComposableFactory] + * compositions, so any locals provided here will be available in _all_ [ScreenComposableFactory] + * compositions. + */ +public typealias CompositionRoot = @Composable (content: @Composable () -> Unit) -> Unit + +/** + * Convenience function for applying a [CompositionRoot] to this [ViewEnvironment]'s + * [ScreenComposableFactoryFinder]. See [ScreenComposableFactoryFinder.withCompositionRoot]. + */ +@WorkflowUiExperimentalApi +public fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment { + return this + + (ScreenComposableFactoryFinder to this[ScreenComposableFactoryFinder].withCompositionRoot(root)) +} + +/** + * Returns a [ScreenComposableFactoryFinder] that ensures that any [ScreenComposableFactory] + * factories registered in this registry will be wrapped exactly once with a [CompositionRoot] + * wrapper. See [CompositionRoot] for more information. + */ +@WorkflowUiExperimentalApi +public fun ScreenComposableFactoryFinder.withCompositionRoot( + root: CompositionRoot +): ScreenComposableFactoryFinder { + return mapFactories { factory -> + @Suppress("UNCHECKED_CAST") + (factory as? ScreenComposableFactory)?.let { composeFactory -> + ScreenComposableFactory(composeFactory.type) { rendering, environment -> + WrappedWithRootIfNecessary(root) { composeFactory.Content(rendering, environment) } + } + } ?: factory + } +} + +/** + * Adds [content] to the composition, ensuring that [CompositionRoot] has been applied. Will only + * wrap the content at the highest occurrence of this function in the composition subtree. + */ +@VisibleForTesting(otherwise = PRIVATE) +@Composable +internal fun WrappedWithRootIfNecessary( + root: CompositionRoot, + content: @Composable () -> Unit +) { + if (LocalHasViewFactoryRootBeenApplied.current) { + // The only way this local can have the value true is if, somewhere above this point in the + // composition, the else case below was hit and wrapped us in the local. Since the root + // wrapper will have already been applied, we can just compose content directly. + content() + } else { + // If the local is false, this is the first time this function has appeared in the composition + // so far. We provide a true value for the local for everything below us, so any recursive + // calls to this function will hit the if case above and not re-apply the wrapper. + CompositionLocalProvider(LocalHasViewFactoryRootBeenApplied provides true) { + root(content) + } + } +} + +@WorkflowUiExperimentalApi +private fun ScreenComposableFactoryFinder.mapFactories( + transform: (ScreenComposableFactory<*>) -> ScreenComposableFactory<*> +): ScreenComposableFactoryFinder = object : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + val factoryFor = this@mapFactories.getComposableFactoryForRendering(environment, rendering) + ?: return null + val transformedFactory = transform(factoryFor) + check(transformedFactory.type == rendering::class) { + "Expected transform to return a ScreenComposableFactory that is compatible " + + "with ${rendering::class}, but got one with type ${transformedFactory.type}" + } + @Suppress("UNCHECKED_CAST") + return transformedFactory as ScreenComposableFactory + } +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt new file mode 100644 index 000000000..d91019f11 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt @@ -0,0 +1,251 @@ +package com.squareup.workflow1.ui.compose + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.RememberObserver +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.TreeSnapshot +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowInterceptor +import com.squareup.workflow1.renderWorkflowIn +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart.UNDISPATCHED +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import okio.ByteString + +/** + * Runs this [Workflow] as long as this composable is part of the composition, and returns a + * [State] object that will be updated whenever the runtime emits a new [RenderingT]. Note that + * here, and in the rest of the documentation for this class, the "`State`" type refers to Compose's + * snapshot [State] type, _not_ the concept of the `StateT` type in a particular workflow. + * + * The workflow runtime will be started when this function is first added to the composition, and + * cancelled when it is removed or if the composition fails. The first rendering will be available + * immediately as soon as this function returns as [State.value]. Composables that read this value + * will automatically recompose whenever the runtime emits a new rendering. If you are driving UI + * from the Workflow tree managed by [renderAsState] then you will probably want to pass the + * returned [State]'s value (which is the Workflow rendering) to the [WorkflowRendering] composable. + * + * [Snapshot]s from the runtime will automatically be saved and restored using Compose's + * [rememberSaveable]. + * + * ## Example + * + * ``` + * private val appViewRegistry = ViewRegistry(…) + * + * @Composable fun App(workflow: Workflow<…>, props: Props) { + * val scaffoldState = … + * + * // Run the workflow in the current composition's coroutine scope. + * val rendering by workflow.renderAsState(props, onOutput = { output -> + * // Note that onOutput is a suspend function, so you can run animations + * // and call other suspend functions. + * scaffoldState.snackbarHostState + * .showSnackbar(output.toString()) + * }) + * val viewEnvironment = remember { + * ViewEnvironment(mapOf(ViewRegistry to appViewRegistry)) + * } + * + * Scaffold(…) { padding -> + * // Display the root rendering using the view environment's ViewRegistry. + * WorkflowRendering(rendering, viewEnvironment, Modifier.padding(padding)) + * } + * } + * ``` + * + * ## Caveat on threading and composition + * + * Note that the initial render pass will occur on whatever thread this function is called from. + * That may be a background thread, as Compose supports performing composition on background + * threads. Well-behaved workflows should have pure `initialState` and `render` functions, so this + * should not be a problem. Any side effects performed by workflows using the `runningSideEffect` + * method or Workers will be executed in [scope] as usual. + * + * Also note that composition is an operation that may fail, or be cancelled, and the "result" + * of a given composition pass may be thrown away and never used to update UI. When this happens, + * the composition is said to have failed to commit. If the composition that initializes a workflow + * runtime using this function fails to commit, the runtime will be started and then immediately + * cancelled. Since the workflow runtime may perform side effects, this may cause effects that look + * like they spontaneously occur, or happen more often than they should. + * + * @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow] + * while this function is in the composition, the runtime will be restarted with the new workflow. + * @param props The [PropsT] for the root [Workflow]. Changes to this value across different + * compositions will cause the root workflow to re-render with the new props. + * @param interceptors + * An optional list of [WorkflowInterceptor]s that will wrap every workflow rendered by the runtime. + * Interceptors will be invoked in 0-to-`length` order: the interceptor at index 0 will process the + * workflow first, then the interceptor at index 1, etc. + * @param scope + * The [CoroutineScope] in which to launch the workflow runtime. If not specified, the value of + * [rememberCoroutineScope] will be used. Any exceptions thrown in any workflows, after the initial + * render pass, will be handled by this scope, and cancelling this scope will cancel the workflow + * runtime and any running workers. Note that any dispatcher in this scope will _not_ be used to + * execute the very first render pass. + * @param runtimeConfig + * The [RuntimeConfig] for the Workflow runtime started to power this state. + * @param onOutput A function that will be executed whenever the root [Workflow] emits an output. + */ +@Composable +public fun Workflow.renderAsState( + props: PropsT, + interceptors: List = emptyList(), + scope: CoroutineScope = rememberCoroutineScope(), + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + onOutput: suspend (OutputT) -> Unit +): State = renderAsState(this, scope, props, interceptors, runtimeConfig, onOutput) + +/** + * @param snapshotKey Allows tests to pass in a custom key to use to save/restore the snapshot from + * the [LocalSaveableStateRegistry]. If null, will use the default key based on source location. + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +@Composable +internal fun renderAsState( + workflow: Workflow, + scope: CoroutineScope, + props: PropsT, + interceptors: List, + runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + onOutput: suspend (OutputT) -> Unit, + snapshotKey: String? = null +): State { + val snapshotState = rememberSaveable(key = snapshotKey, stateSaver = TreeSnapshotSaver) { + mutableStateOf(null) + } + val updatedOnOutput by rememberUpdatedState(onOutput) + + // We can't use DisposableEffect because it won't run until the composition is successfully + // committed, which will be after this function returns, and we need to run this immediately so we + // get the rendering synchronously. The thread running this composition might also not be the + // main thread or whatever thread the workflow context is configured to run on, but that should + // be fine as long as the workflows are correctly performing side effects in effects and not their + // render or related methods. + // The WorkflowState object remembered here is a RememberObserver – it will automatically cancel + // the workflow runtime when it leaves the composition or if the composition doesn't commit. + // The remember is keyed on any values that we can't update the runtime with dynamically, and + // therefore require completely restarting the runtime to take effect. + val state = remember(workflow, scope, interceptors) { + WorkflowRuntimeState( + workflowScope = scope, + initialProps = props, + snapshotState = snapshotState, + runtimeConfig = runtimeConfig, + onOutput = { updatedOnOutput(it) } + ).apply { + start(workflow, interceptors) + } + } + + // Use a side effect to update props so that it waits for the composition to commit. + SideEffect { + state.setProps(props) + } + + return state.rendering +} + +/** + * State hoisted out of [renderAsState]. + */ +private class WorkflowRuntimeState( + workflowScope: CoroutineScope, + initialProps: PropsT, + private val snapshotState: MutableState, + private val runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, + private val onOutput: suspend (OutputT) -> Unit, +) : RememberObserver { + + private val renderingState = mutableStateOf(null) + private val propsFlow = MutableStateFlow(initialProps) + + /** + * The actual scope used to run the workflow. It has a child [Job] of the incoming scope so + * we can cancel the runtime without cancelling the incoming scope. + */ + private val workflowScope = workflowScope + Job(parent = workflowScope.coroutineContext[Job]) + + // The value is guaranteed to be set before returning, so this cast is fine. + @Suppress("UNCHECKED_CAST") + val rendering: State + get() = renderingState as State + + fun start( + workflow: Workflow, + interceptors: List + ) { + val renderings = renderWorkflowIn( + workflow = workflow, + scope = workflowScope, + props = propsFlow, + initialSnapshot = snapshotState.value, + interceptors = interceptors, + runtimeConfig = runtimeConfig, + onOutput = onOutput + ) + + workflowScope.launch( + start = UNDISPATCHED, + context = Dispatchers.Unconfined + ) { + // We collect the renderings in the workflowScope to participate in structured concurrency, + // however we don't need to use its dispatcher – this collector is simply setting snapshot + // state values, which is thread safe. + // Also, if the scope uses a non-immediate dispatcher, the initial states won't get set until + // the dispatcher dispatches the collection coroutine, but our contract requires them to be + // set by the time this function returns and using the Unconfined dispatcher along with + // launching this coroutine as CoroutineStart.UNDISPATCHED guarantees that. + + renderings.collect { + renderingState.value = it.rendering + snapshotState.value = it.snapshot + } + } + } + + fun setProps(props: PropsT) { + propsFlow.value = props + } + + override fun onAbandoned() { + workflowScope.cancel() + } + + override fun onRemembered() {} + + override fun onForgotten() { + workflowScope.cancel() + } +} + +private object TreeSnapshotSaver : Saver { + override fun SaverScope.save(value: TreeSnapshot?): ByteArray { + return value?.toByteString()?.toByteArray() ?: ByteArray(0) + } + + override fun restore(value: ByteArray): TreeSnapshot? { + return value.takeUnless { it.isEmpty() } + ?.let { bytes -> TreeSnapshot.parse(ByteString.of(*bytes)) } + } +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt new file mode 100644 index 000000000..f5e6b55d3 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt @@ -0,0 +1,99 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.Composable +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.ViewRegistry.Key +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import kotlin.reflect.KClass + +@WorkflowUiExperimentalApi +public inline fun ScreenComposableFactory( + noinline content: @Composable ( + rendering: ScreenT, + environment: ViewEnvironment + ) -> Unit +): ScreenComposableFactory = ScreenComposableFactory(ScreenT::class, content) + +@PublishedApi +@WorkflowUiExperimentalApi +internal fun ScreenComposableFactory( + type: KClass, + content: @Composable ( + rendering: ScreenT, + environment: ViewEnvironment + ) -> Unit +): ScreenComposableFactory = object : ScreenComposableFactory { + override val type: KClass = type + + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + content(rendering, environment) + } +} + +/** + * A [ViewRegistry.Entry] that uses a [Composable] function to display [ScreenT]. + * This is the fundamental unit of Compose tooling in Workflow UI, the Compose analogue of + * [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory]. + * + * [ScreenComposableFactory] is also a bit cumbersome to use directly, + * so [ComposeScreen] is provided as a convenience. Most developers will + * have no reason to work with [ScreenComposableFactory] directly, or even + * be aware of it. + * + * - See [ComposeScreen] for a more complete description of using Compose to + * build a Workflow-based UI. + * + * - See [WorkflowRendering] to display a nested [Screen] from [ComposeScreen.Content] + * or from [ScreenComposableFactory.Content] + * + * Use [ScreenComposableFactory] directly if you need to prevent your + * [Screen] rendering classes from depending on Compose at compile time. + * + * Example: + * + * val fooComposableFactory = ScreenComposableFactory { screen, _ -> + * Text(screen.message) + * } + * + * val viewRegistry = ViewRegistry(fooComposableFactory, …) + * val viewEnvironment = ViewEnvironment.EMPTY + viewRegistry + * + * renderWorkflowIn( + * workflow = MyWorkflow.mapRendering { it.withEnvironment(viewEnvironment) } + * ) + */ +@WorkflowUiExperimentalApi +public interface ScreenComposableFactory : ViewRegistry.Entry { + public val type: KClass + + override val key: Key> + get() = Key(type, ScreenComposableFactory::class) + + /** + * The composable content of this [ScreenComposableFactory]. This method will be called + * any time [rendering] or [environment] change. It is the Compose-based analogue of + * [ScreenViewRunner.showRendering][com.squareup.workflow1.ui.ScreenViewRunner.showRendering]. + */ + @Composable public fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) +} + +/** + * It is rare to call this method directly. Instead the most common path is to pass [Screen] + * instances to [WorkflowRendering], which will apply the [ScreenComposableFactory] + * and [ScreenComposableFactoryFinder] machinery for you. + */ +@WorkflowUiExperimentalApi +public fun ScreenT.toComposableFactory( + environment: ViewEnvironment +): ScreenComposableFactory { + return environment[ScreenComposableFactoryFinder] + .requireComposableFactoryForRendering(environment, this) +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt new file mode 100644 index 000000000..3466c48bb --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt @@ -0,0 +1,70 @@ +package com.squareup.workflow1.ui.compose + +import com.squareup.workflow1.ui.EnvironmentScreen +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.ViewRegistry.Key +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.getFactoryFor + +@WorkflowUiExperimentalApi +public interface ScreenComposableFactoryFinder { + public fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + val factoryOrNull: ScreenComposableFactory? = + environment[ViewRegistry].getFactoryFor(rendering) + + @Suppress("UNCHECKED_CAST") + return factoryOrNull + ?: (rendering as? ComposeScreen)?.let { + ScreenComposableFactory { rendering, environment -> + rendering.Content(environment) + } as ScreenComposableFactory + } + + // Support for Compose BackStackScreen, BodyAndOverlaysScreen treatments would go here, + // if it were planned. See similar blocks in ScreenViewFactoryFinder + + ?: (rendering as? NamedScreen<*>)?.let { + ScreenComposableFactory> { rendering, environment -> + val innerFactory = rendering.content.toComposableFactory(environment) + innerFactory.Content(rendering.content, environment) + // WorkflowRendering(rendering.content, environment) + } as ScreenComposableFactory + } + ?: (rendering as? EnvironmentScreen<*>)?.let { + ScreenComposableFactory> { rendering, environment -> + val comboEnv = environment + rendering.environment + val innerFactory = rendering.content.toComposableFactory(comboEnv) + innerFactory.Content(rendering.content, comboEnv) + // WorkflowRendering(rendering.content, comboEnv) + } as ScreenComposableFactory + } + } + + public companion object : ViewEnvironmentKey() { + override val default: ScreenComposableFactoryFinder + get() = object : ScreenComposableFactoryFinder {} + } +} + +@WorkflowUiExperimentalApi +public fun ScreenComposableFactoryFinder.requireComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT +): ScreenComposableFactory { + return getComposableFactoryForRendering(environment, rendering) + ?: throw IllegalArgumentException( + "A ScreenComposableFactory should have been registered to display $rendering, " + + "or that class should implement ComposeScreen. Instead found " + + "${ + environment[ViewRegistry] + .getEntryFor(Key(rendering::class, ScreenComposableFactory::class)) + }." + ) +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt new file mode 100644 index 000000000..f78073649 --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt @@ -0,0 +1,47 @@ +@file:OptIn(WorkflowUiExperimentalApi::class) + +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import com.squareup.workflow1.ui.TextController +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import kotlinx.coroutines.launch + +/** + * Exposes the [textValue][TextController.textValue] of a [TextController] + * as a remembered [MutableState], suitable for use from `@Composable` + * functions. + * + * Usage: + * + * var text by rendering.textController.asMutableState() + * + * OutlinedTextField( + * label = {}, + * placeholder = { Text("Enter some text") }, + * value = text, + * onValueChange = { text = it } + * ) + */ +@Composable public fun TextController.asMutableState(): MutableState { + // keys are set to `this` to reset the state if a different controller is passed in… + return remember(this) { mutableStateOf(textValue) }.also { state -> + // …and to restart the effect. + LaunchedEffect(this) { + // Push changes from the workflow to the state. + launch { + onTextChanged.collect { state.value = it } + } + // And the other way – push changes to the state to the workflow. + // This won't cause an infinite loop because both MutableState and + // MutableSnapshotFlow ignore duplicate values. + snapshotFlow { state.value } + .collect { textValue = it } + } + } +} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt new file mode 100644 index 000000000..8a80be0ca --- /dev/null +++ b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt @@ -0,0 +1,126 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +/** + * Renders [rendering] into the composition using this [ViewEnvironment]'s + * [ScreenComposeFactoryFinder] to generate the view. + * + * ## Example + * + * ``` + * data class FramedRendering( + * val borderColor: Color, + * val child: R + * ) : ComposeRendering { + * + * @Composable override fun Content(viewEnvironment: ViewEnvironment) { + * Surface(border = Border(borderColor, 8.dp)) { + * WorkflowRendering(child, viewEnvironment) + * } + * } + * } + * ``` + * + * @param rendering The workflow rendering to display. + * @param modifier A [Modifier] that will be applied to composable used to show [rendering]. + * + * @throws IllegalArgumentException if no factory can be found for [rendering]'s type. + */ +@WorkflowUiExperimentalApi +@Composable +public fun WorkflowRendering( + rendering: Screen, + viewEnvironment: ViewEnvironment, + modifier: Modifier = Modifier +) { + // This will fetch a new view factory any time the new rendering is incompatible with the previous + // one, as determined by Compatible. This corresponds to WorkflowViewStub's canShowRendering + // check. + val renderingCompatibilityKey = Compatible.keyFor(rendering) + + // By surrounding the below code with this key function, any time the new rendering is not + // compatible with the previous rendering we'll tear down the previous subtree of the composition, + // including its lifecycle, which destroys the lifecycle and any remembered state. If the view + // factory created an Android view, this will also remove the old one from the view hierarchy + // before replacing it with the new one. + key(renderingCompatibilityKey) { + val composableFactory = remember { + // The view registry may return a new factory instance for a rendering every time we ask it, for + // example if an AndroidScreen doesn't share its factory between rendering instances. We + // intentionally don't ask it for a new instance every time to match the behavior of + // WorkflowViewStub and other containers, which only ask for a new factory when the rendering is + // incompatible. + rendering.toComposableFactory(viewEnvironment) + } + + // Just like WorkflowViewStub, we need to manage a Lifecycle for the child view. We just provide + // a local here – ViewFactoryAndroidView will handle setting the appropriate view tree owners + // on the child view when necessary. Because this function is surrounded by a key() call, when + // the rendering is incompatible, the lifecycle for the old view will be destroyed. + val lifecycleOwner = rememberChildLifecycleOwner() + + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + // We need to propagate min constraints because one of the likely uses for the modifier passed + // into this function is to directly control the layout of the child view – which means + // minimum constraints are likely to be significant. + Box(modifier, propagateMinConstraints = true) { + composableFactory.Content(rendering, viewEnvironment) + } + } + } +} + +/** + * Returns a [LifecycleOwner] that is a mirror of the current [LocalLifecycleOwner] until this + * function leaves the composition. More details can be found [here](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-lifecycle.html) + * for the lifecycle of a composable function depending what platform is used + */ +@Composable private fun rememberChildLifecycleOwner(): LifecycleOwner { + val lifecycleOwner = remember { + object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = registry + } + } + val parentLifecycle = LocalLifecycleOwner.current.lifecycle + + DisposableEffect(parentLifecycle) { + val parentObserver = LifecycleEventObserver { _, event -> + // Any time the parent lifecycle changes state, perform the same change on our lifecycle. + lifecycleOwner.registry.handleLifecycleEvent(event) + } + + parentLifecycle.addObserver(parentObserver) + onDispose { + parentLifecycle.removeObserver(parentObserver) + + // If we're leaving the composition it means the WorkflowRendering is either going away itself + // or about to switch to an incompatible rendering – either way, this lifecycle is dead. Note + // that we can't transition from INITIALIZED to DESTROYED – the LifecycleRegistry will throw. + if (lifecycleOwner.registry.currentState != INITIALIZED) { + lifecycleOwner.registry.currentState = DESTROYED + } + } + } + + return lifecycleOwner +} diff --git a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt index 6f7c4c2c0..a3ea2fb39 100644 --- a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt @@ -45,11 +45,11 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 diff --git a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt index 20dbb06a8..5c87d8040 100644 --- a/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose/dependencies/releaseRuntimeClasspath.txt @@ -42,11 +42,11 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index 21c26739e..e997cccb6 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -24,11 +24,11 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 diff --git a/workflow-ui/core-common/dependencies/runtimeClasspath.txt b/workflow-ui/core-common/dependencies/runtimeClasspath.txt index d8cb7353d..3d72a2ba1 100644 --- a/workflow-ui/core-common/dependencies/runtimeClasspath.txt +++ b/workflow-ui/core-common/dependencies/runtimeClasspath.txt @@ -1,10 +1,10 @@ com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-ui/core/build.gradle.kts b/workflow-ui/core/build.gradle.kts index e8b653e87..ffe9069a1 100644 --- a/workflow-ui/core/build.gradle.kts +++ b/workflow-ui/core/build.gradle.kts @@ -1,4 +1,4 @@ -import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64 +import com.squareup.workflow1.buildsrc.iosTargets plugins { id("kotlin-multiplatform") @@ -8,7 +8,7 @@ plugins { kotlin { val targets = project.findProperty("workflow.targets") ?: "kmp" if (targets == "kmp" || targets == "ios") { - iosWithSimulatorArm64(project) + iosTargets() } if (targets == "kmp" || targets == "jvm") { jvm { withJava() } diff --git a/workflow-ui/core/dependencies/jsRuntimeClasspath.txt b/workflow-ui/core/dependencies/jsRuntimeClasspath.txt index 5494404ea..a34d1c638 100644 --- a/workflow-ui/core/dependencies/jsRuntimeClasspath.txt +++ b/workflow-ui/core/dependencies/jsRuntimeClasspath.txt @@ -1,7 +1,7 @@ -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:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-dom-api-compat:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-js:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt b/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt index fe39ce5b6..9d47f54c9 100644 --- a/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt +++ b/workflow-ui/core/dependencies/jvmRuntimeClasspath.txt @@ -1,7 +1,7 @@ -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-ui/core/dependencies/runtimeClasspath.txt b/workflow-ui/core/dependencies/runtimeClasspath.txt index 1adc1c1b1..b70b1727c 100644 --- a/workflow-ui/core/dependencies/runtimeClasspath.txt +++ b/workflow-ui/core/dependencies/runtimeClasspath.txt @@ -1,8 +1,8 @@ -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 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 diff --git a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index 0c08955f8..8160b295c 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -26,11 +26,11 @@ com.squareup.curtains:curtains:1.2.2 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 com.squareup.radiography:radiography:2.4.1 -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.kotlin:kotlin-bom:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.24 org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3 org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3 From bb61f8a4cbd36d1f7fc321fa7298048f14ac5dde Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Tue, 25 Jun 2024 14:06:49 -0700 Subject: [PATCH 03/15] Get multiplatform tests working --- build.gradle.kts | 1 + gradle.properties | 1 + .../build.gradle.kts | 5 + .../compose/multiplatform/ExampleTest.kt | 16 +- .../multiplatform/WorkflowRenderingTest.kt | 611 ++++++++++++++++++ workflow-core/build.gradle.kts | 10 + .../compose-multiplatform/build.gradle.kts | 57 +- .../compose/ComposeViewTreeIntegrationTest.kt | 0 .../ui/compose/CompositionRootTest.kt | 0 .../compose/NoTransitionBackStackContainer.kt | 0 .../workflow1/ui/compose/RenderAsStateTest.kt | 0 .../ui/compose/ScreenComposableFactoryTest.kt | 0 .../ui/compose/WorkflowRenderingTest.kt | 16 +- 13 files changed, 685 insertions(+), 32 deletions(-) create mode 100644 samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt rename workflow-ui/compose-multiplatform/src/{androidTest => androidInstrumentedTest}/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt (100%) rename workflow-ui/compose-multiplatform/src/{androidTest => androidInstrumentedTest}/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt (100%) rename workflow-ui/compose-multiplatform/src/{androidTest => androidInstrumentedTest}/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt (100%) rename workflow-ui/compose-multiplatform/src/{androidTest => androidInstrumentedTest}/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt (100%) rename workflow-ui/compose-multiplatform/src/{androidTest => androidInstrumentedTest}/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt (100%) rename workflow-ui/compose-multiplatform/src/{androidTest => androidInstrumentedTest}/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt (97%) diff --git a/build.gradle.kts b/build.gradle.kts index ada8fa044..f7f0a65b5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,6 +27,7 @@ plugins { id("artifacts-check") id("dependency-guard") alias(libs.plugins.ktlint) + id("com.autonomousapps.dependency-analysis") version "1.32.0" } shardConnectedCheckTasks(project) diff --git a/gradle.properties b/gradle.properties index 4e412c1d9..536e03907 100644 --- a/gradle.properties +++ b/gradle.properties @@ -33,3 +33,4 @@ kotlin.js.compiler=ir # Compose Multiplatform org.jetbrains.compose.experimental.jscanvas.enabled=true +dependency.analysis.print.build.health=true diff --git a/samples/compose-multiplatform-samples/build.gradle.kts b/samples/compose-multiplatform-samples/build.gradle.kts index f6bc63048..974f16755 100644 --- a/samples/compose-multiplatform-samples/build.gradle.kts +++ b/samples/compose-multiplatform-samples/build.gradle.kts @@ -17,6 +17,8 @@ kotlin { dependencies { implementation(project.dependencies.platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.squareup.leakcanary.instrumentation) + implementation(project(":workflow-ui:internal-testing-android")) debugImplementation(libs.androidx.compose.ui.test.manifest) } } @@ -45,5 +47,8 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() } + testOptions { + animationsDisabled = true + } namespace = name } diff --git a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt index 9192150c9..285e36e03 100644 --- a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt +++ b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt @@ -11,20 +11,24 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.runComposeUiTest +import org.junit.Rule import org.junit.Test class ExampleTest { + @get:Rule + val rule = createComposeRule() + @OptIn(ExperimentalTestApi::class) @Test - fun myTest() = runComposeUiTest { + fun myTest() { // Declares a mock UI to demonstrate API calls // // Replace with your own declarations to test the code of your project - setContent { + rule.setContent { Column { var text by remember { mutableStateOf("Hello") } Text( @@ -41,8 +45,8 @@ class ExampleTest { } // Tests the declared UI with assertions and actions of the Compose Multiplatform testing API - onNodeWithTag("text").assertTextEquals("Hello") - onNodeWithTag("button").performClick() - onNodeWithTag("text").assertTextEquals("Compose") + rule.onNodeWithTag("text").assertTextEquals("Hello") + rule.onNodeWithTag("button").performClick() + rule.onNodeWithTag("text").assertTextEquals("Compose") } } diff --git a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt new file mode 100644 index 000000000..b61ebd61e --- /dev/null +++ b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt @@ -0,0 +1,611 @@ +package com.squareup.sample.compose.multiplatform + +import android.view.View +import android.widget.TextView +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event +import androidx.lifecycle.Lifecycle.Event.ON_CREATE +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.Lifecycle.State.STARTED +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.findViewTreeLifecycleOwner +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.google.common.truth.Truth.assertThat +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.NamedScreen +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewHolder +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.ViewEnvironmentKey +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ComposeScreen +import com.squareup.workflow1.ui.compose.ScreenComposableFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactoryFinder +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.withComposeInteropSupport +import com.squareup.workflow1.ui.compose.withCompositionRoot +import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule +import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.plus +import com.squareup.workflow1.ui.withEnvironment +import leakcanary.DetectLeaksAfterTestSuccess +import org.hamcrest.Description +import org.hamcrest.TypeSafeMatcher +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import kotlin.reflect.KClass + +@OptIn(WorkflowUiExperimentalApi::class) +class WorkflowRenderingTest { + + private val composeRule = createComposeRule() + + @get:Rule + val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + .around(IdleAfterTestRule) + .around(composeRule) + .around(IdlingDispatcherRule) + + @Test fun doesNotRecompose_whenFactoryChanged() { + data class TestRendering( + val text: String + ) : Screen + + val registry1 = ViewRegistry( + ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text) + } + ) + val registry2 = ViewRegistry( + ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text.reversed()) + } + ) + val registry = mutableStateOf(registry1) + + composeRule.setContent { + WorkflowRendering(TestRendering("hello"), ViewEnvironment.EMPTY + registry.value) + } + + composeRule.onNodeWithText("hello").assertIsDisplayed() + registry.value = registry2 + composeRule.onNodeWithText("hello").assertIsDisplayed() + composeRule.onNodeWithText("olleh").assertDoesNotExist() + } + + @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() { + data class TestRendering(val text: String) : Screen + + val testFactory = ScreenComposableFactory { rendering, _ -> + BasicText(rendering.text) + } + val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(testFactory)) + .withCompositionRoot { content -> + Column { + BasicText("one") + content() + } + } + + composeRule.setContent { + WorkflowRendering(TestRendering("two"), viewEnvironment) + } + + composeRule.onNodeWithText("one").assertIsDisplayed() + composeRule.onNodeWithText("two").assertIsDisplayed() + } + + @Test fun legacyAndroidViewRendersUpdates() { + val wrapperText = mutableStateOf("two") + + composeRule.setContent { + WorkflowRendering(LegacyViewRendering(wrapperText.value), env) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + // https://github.com/square/workflow-kotlin/issues/538 + @Test fun includesSupportForNamed() { + val wrapperText = mutableStateOf("two") + + composeRule.setContent { + val rendering = NamedScreen(LegacyViewRendering(wrapperText.value), "fnord") + WorkflowRendering(rendering, env) + } + + onView(withText("two")).check(matches(isDisplayed())) + wrapperText.value = "OWT" + onView(withText("OWT")).check(matches(isDisplayed())) + } + + @Test fun namedScreenStaysInTheSameComposeView() { + composeRule.setContent { + val outer = LocalView.current + + WorkflowRendering( + viewEnvironment = env, + rendering = NamedScreen( + name = "fnord", + content = ComposeScreen { + val inner = LocalView.current + assertThat(inner).isSameInstanceAs(outer) + + BasicText("hello", Modifier.testTag("tag")) + } + ) + ) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("hello") + } + + @Test fun environmentScreenStaysInTheSameComposeView() { + val someKey = object : ViewEnvironmentKey() { + override val default = "default" + } + + composeRule.setContent { + val outer = LocalView.current + + WorkflowRendering( + viewEnvironment = env, + rendering = ComposeScreen { environment -> + val inner = LocalView.current + assertThat(inner).isSameInstanceAs(outer) + + BasicText(environment[someKey], Modifier.testTag("tag")) + }.withEnvironment((someKey to "fnord")) + ) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("fnord") + } + + @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() { + val lifecycleEvents = mutableListOf() + + class LifecycleRecorder : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + lifecycle.addObserver( + LifecycleEventObserver { _, event -> + lifecycleEvents += event + } + ) + onDispose { + // Yes, we're leaking the observer. That's intentional: we need to make sure we see any + // lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + class EmptyRendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) {} + } + + var rendering: Screen by mutableStateOf(LifecycleRecorder()) + composeRule.setContent { + WorkflowRendering(rendering, env) + } + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + lifecycleEvents.clear() + } + + rendering = EmptyRendering() + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + } + } + + @Test fun destroysChildLifecycle_fromLegacyView_whenIncompatibleRendering() { + val lifecycleEvents = mutableListOf() + + class LifecycleRecorder : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + val view = object : View(context) { + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val lifecycle = this.findViewTreeLifecycleOwner()!!.lifecycle + lifecycle.addObserver( + LifecycleEventObserver { _, event -> lifecycleEvents += event } + ) + // Yes, we're leaking the observer. That's intentional: we need to make sure we see + // any lifecycle events that happen even after the composable is destroyed. + } + } + ScreenViewHolder(initialEnvironment, view) { _, _ -> } + } + } + + class EmptyRendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) {} + } + + var rendering: Screen by mutableStateOf(LifecycleRecorder()) + composeRule.setContent { + WorkflowRendering(rendering, env) + } + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + lifecycleEvents.clear() + } + + rendering = EmptyRendering() + + composeRule.runOnIdle { + assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + } + } + + @Test fun followsParentLifecycle() { + val states = mutableListOf() + val parentOwner = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = registry + } + + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { + WorkflowRendering(LifecycleRecorder(states), env) + } + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(INITIALIZED).inOrder() + states.clear() + parentOwner.registry.currentState = STARTED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(CREATED, STARTED).inOrder() + states.clear() + parentOwner.registry.currentState = CREATED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(CREATED).inOrder() + states.clear() + parentOwner.registry.currentState = RESUMED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(STARTED, RESUMED).inOrder() + states.clear() + parentOwner.registry.currentState = DESTROYED + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(STARTED, CREATED, DESTROYED).inOrder() + } + } + + @Test fun handlesParentInitiallyDestroyed() { + val states = mutableListOf() + val parentOwner = object : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = registry + } + composeRule.runOnIdle { + // Cannot go directly to DESTROYED + parentOwner.registry.currentState = CREATED + parentOwner.registry.currentState = DESTROYED + } + + composeRule.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { + WorkflowRendering(LifecycleRecorder(states), env) + } + } + + composeRule.runOnIdle { + assertThat(states).containsExactly(INITIALIZED).inOrder() + } + } + + @Test fun appliesModifierToComposableContent() { + class Rendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Box( + Modifier + .testTag("box") + .fillMaxSize() + ) + } + } + + composeRule.setContent { + WorkflowRendering( + Rendering(), + env, + Modifier.size(width = 42.dp, height = 43.dp) + ) + } + + composeRule.onNodeWithTag("box") + .assertWidthIsEqualTo(42.dp) + .assertHeightIsEqualTo(43.dp) + } + + @Test fun propagatesMinConstraints() { + class Rendering : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + Box(Modifier.testTag("box")) + } + } + + composeRule.setContent { + WorkflowRendering( + Rendering(), + env, + Modifier.sizeIn(minWidth = 42.dp, minHeight = 43.dp) + ) + } + + composeRule.onNodeWithTag("box") + .assertWidthIsEqualTo(42.dp) + .assertHeightIsEqualTo(43.dp) + } + + @Test fun appliesModifierToViewContent() { + val viewId = View.generateViewId() + + class LegacyRendering(private val viewId: Int) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + val view = View(context) + ScreenViewHolder(initialEnvironment, view) { rendering, _ -> + view.id = rendering.viewId + } + } + } + + composeRule.setContent { + with(LocalDensity.current) { + WorkflowRendering( + LegacyRendering(viewId), + env, + Modifier.size(42.toDp(), 43.toDp()) + ) + } + } + + onView(withId(viewId)).check(matches(hasSize(42, 43))) + } + + @Test fun skipsPreviousContentWhenIncompatible() { + var disposeCount = 0 + + class Rendering( + override val compatibilityKey: String + ) : ComposableRendering, Compatible { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + var counter by rememberSaveable { mutableStateOf(0) } + Column { + BasicText( + "$compatibilityKey: $counter", + Modifier + .testTag("tag") + .clickable { counter++ } + ) + DisposableEffect(Unit) { + onDispose { + disposeCount++ + } + } + } + } + } + + var key by mutableStateOf("one") + composeRule.setContent { + WorkflowRendering(Rendering(key), env) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + .performClick() + .assertTextEquals("one: 1") + + key = "two" + + composeRule.onNodeWithTag("tag") + .assertTextEquals("two: 0") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(1) + } + + key = "one" + + // State should not be restored. + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(2) + } + } + + @Test fun doesNotSkipPreviousContentWhenCompatible() { + var disposeCount = 0 + + class Rendering(val text: String) : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + var counter by rememberSaveable { mutableStateOf(0) } + Column { + BasicText( + "$text: $counter", + Modifier + .testTag("tag") + .clickable { counter++ } + ) + DisposableEffect(Unit) { + onDispose { + disposeCount++ + } + } + } + } + } + + var text by mutableStateOf("one") + composeRule.setContent { + WorkflowRendering(Rendering(text), env) + } + + composeRule.onNodeWithTag("tag") + .assertTextEquals("one: 0") + .performClick() + .assertTextEquals("one: 1") + + text = "two" + + // Counter state should be preserved. + composeRule.onNodeWithTag("tag") + .assertTextEquals("two: 1") + composeRule.runOnIdle { + assertThat(disposeCount).isEqualTo(0) + } + } + + @Suppress("SameParameterValue") + private fun hasSize( + width: Int, + height: Int + ) = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("has size ${width}x${height}px") + } + + override fun matchesSafely(item: View): Boolean { + return item.width == width && item.height == height + } + } + + private class LifecycleRecorder( + // For some reason, if we just capture the states val, it is null in the composable. + private val states: MutableList + ) : ComposableRendering { + @Composable override fun Content(viewEnvironment: ViewEnvironment) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + DisposableEffect(lifecycle) { + this@LifecycleRecorder.states += lifecycle.currentState + lifecycle.addObserver( + LifecycleEventObserver { _, _ -> + this@LifecycleRecorder.states += lifecycle.currentState + } + ) + onDispose { + // Yes, we're leaking the observer. That's intentional: we need to make sure we see any + // lifecycle events that happen even after the composable is destroyed. + } + } + } + } + + /** + * It is significant that this returns a new instance on every call, since we can't rely on real + * implementations in the wild to reuse the same factory instance across rendering instances. + */ + private object InefficientComposableFinder : ScreenComposableFactoryFinder { + override fun getComposableFactoryForRendering( + environment: ViewEnvironment, + rendering: ScreenT + ): ScreenComposableFactory? { + return if (rendering is ComposableRendering) { + object : ScreenComposableFactory { + override val type: KClass get() = error("whatever") + + @Composable override fun Content( + rendering: ScreenT, + environment: ViewEnvironment + ) { + (rendering as ComposableRendering).Content(environment) + } + } + } else { + super.getComposableFactoryForRendering( + environment, + rendering + ) + } + } + } + + private val env = + (ViewEnvironment.EMPTY + (ScreenComposableFactoryFinder to InefficientComposableFinder)) + .withComposeInteropSupport() + + private interface ComposableRendering : Screen { + @Composable fun Content(viewEnvironment: ViewEnvironment) + } + + private data class LegacyViewRendering(val text: String) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> + val view = TextView(context) + ScreenViewHolder(initialEnvironment, view) { rendering, _ -> + view.text = rendering.text + } + } + } +} diff --git a/workflow-core/build.gradle.kts b/workflow-core/build.gradle.kts index 128c87289..ffc274f23 100644 --- a/workflow-core/build.gradle.kts +++ b/workflow-core/build.gradle.kts @@ -6,6 +6,16 @@ plugins { } kotlin { + targets.all { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + } + val targets = project.findProperty("workflow.targets") ?: "kmp" if (targets == "kmp" || targets == "ios") { iosTargets() diff --git a/workflow-ui/compose-multiplatform/build.gradle.kts b/workflow-ui/compose-multiplatform/build.gradle.kts index cdae5b234..345e80fb2 100644 --- a/workflow-ui/compose-multiplatform/build.gradle.kts +++ b/workflow-ui/compose-multiplatform/build.gradle.kts @@ -1,5 +1,8 @@ import com.squareup.workflow1.buildsrc.iosTargets import org.jetbrains.compose.ExperimentalComposeLibrary +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree plugins { alias(libs.plugins.jetbrains.compose.plugin) @@ -10,19 +13,35 @@ plugins { // id("published") } +fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant { + sourceSetTree.set(KotlinSourceSetTree.test) + + dependencies { + debugImplementation(libs.androidx.compose.ui.test.manifest) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.squareup.leakcanary.instrumentation) + implementation(project(":workflow-ui:internal-testing-android")) + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + } + } + } +} + kotlin { val targets = project.findProperty("workflow.targets") ?: "kmp" - if (targets == "kmp" || targets == "ios") { - iosTargets() - } - if (targets == "kmp" || targets == "jvm") { - jvm {} - } - if (targets == "kmp" || targets == "js") { - js(IR).browser() - } - if (targets == "kmp" || targets == "android") { - androidTarget() + + listOf( + "ios" to { iosTargets() }, + "jvm" to { jvm {} }, + "js" to { js(IR).browser() }, + "android" to { androidTargetWithTesting() } + ).forEach { (target, action) -> + if (targets == "kmp" || targets == target) { + action() + } } sourceSets { @@ -34,7 +53,6 @@ kotlin { implementation(compose.runtime) implementation(compose.ui) implementation(libs.kotlinx.coroutines.core) - implementation(libs.squareup.okio) implementation(libs.jetbrains.lifecycle.runtime.compose) implementation(project(":workflow-core")) @@ -54,23 +72,24 @@ kotlin { implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.runtime.saveable) - implementation(libs.androidx.lifecycle.common) - implementation(libs.androidx.lifecycle.core) } } } android { + val name = "com.squareup.workflow1.ui.compose.multiplatform" + val testName = "$name.test" + defaultConfig { + testApplicationId = testName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildFeatures.compose = true - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() - } - namespace = "com.squareup.workflow1.ui.compose.multiplatform" - testNamespace = "$namespace.test" + composeOptions.kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + namespace = name + testNamespace = testName + testOptions.animationsDisabled = true dependencies { debugImplementation(compose.uiTooling) diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt rename to workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt rename to workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt rename to workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt rename to workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt rename to workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt similarity index 97% rename from workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt rename to workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt index bd12f84db..8895a3bb7 100644 --- a/workflow-ui/compose-multiplatform/src/androidTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt +++ b/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt @@ -1,5 +1,3 @@ -@file:Suppress("TestFunctionName") - package com.squareup.workflow1.ui.compose import android.view.View @@ -55,7 +53,6 @@ import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.Compatible @@ -67,6 +64,12 @@ import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewEnvironmentKey import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ComposeScreen +import com.squareup.workflow1.ui.compose.ScreenComposableFactory +import com.squareup.workflow1.ui.compose.ScreenComposableFactoryFinder +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.withComposeInteropSupport +import com.squareup.workflow1.ui.compose.withCompositionRoot import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule import com.squareup.workflow1.ui.plus @@ -77,16 +80,15 @@ import org.hamcrest.TypeSafeMatcher import org.junit.Rule import org.junit.Test import org.junit.rules.RuleChain -import org.junit.runner.RunWith import kotlin.reflect.KClass @OptIn(WorkflowUiExperimentalApi::class) -@RunWith(AndroidJUnit4::class) -internal class WorkflowRenderingTest { +class WorkflowRenderingTest { private val composeRule = createComposeRule() - @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) + @get:Rule + val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) .around(IdleAfterTestRule) .around(composeRule) .around(IdlingDispatcherRule) From 8ce8c93c7dddd9fcfe594b5f2c7c1f60f98b097c Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Wed, 26 Jun 2024 13:56:32 -0700 Subject: [PATCH 04/15] Delete compose multiplatform module and instead move everything to the regular compose package * Add UI tests for iOS --- .../compose-multiplatform-samples/.gitignore | 1 - .../build.gradle.kts | 54 -- .../proguard-rules.pro | 21 - .../compose/multiplatform/ExampleTest.kt | 52 -- .../multiplatform/WorkflowRenderingTest.kt | 611 ----------------- .../src/androidMain/AndroidManifest.xml | 23 - .../compose/multiplatform/MainActivity.kt | 5 - .../drawable-v24/ic_launcher_foreground.xml | 30 - .../res/drawable/ic_launcher_background.xml | 170 ----- .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 - .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 - .../res/mipmap-hdpi/ic_launcher.png | Bin 3593 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 5339 -> 0 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 2636 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 3388 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 4926 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 7472 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 7909 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 11873 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 10652 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 16570 -> 0 bytes .../src/androidMain/res/values/strings.xml | 3 - .../compose/multiplatform/ExampleUnitTest.kt | 17 - settings.gradle.kts | 2 - .../compose-multiplatform/build.gradle.kts | 97 --- .../workflow1/ui/compose/CompositionRoot.kt | 104 --- .../workflow1/ui/compose/WorkflowRendering.kt | 126 ---- workflow-ui/compose/build.gradle.kts | 124 ++-- .../AndroidManifest.xml | 0 .../compose/ComposeViewTreeIntegrationTest.kt | 0 .../ui/compose/CompositionRootTest.kt | 0 .../compose/NoTransitionBackStackContainer.kt | 0 .../workflow1/ui/compose/RenderAsStateTest.kt | 0 .../ui/compose/ScreenComposableFactoryTest.kt | 0 .../ui/compose/WorkflowRenderingTest.kt | 0 .../compose/ScreenComposableFactoryAndroid.kt | 1 - .../ViewEnvironmentWithComposeSupport.kt | 0 .../src/androidMain/res/values/ids.xml | 0 .../compose/ComposeViewTreeIntegrationTest.kt | 644 ------------------ .../ui/compose/CompositionRootTest.kt | 121 ---- .../compose/NoTransitionBackStackContainer.kt | 44 -- .../ui/compose/ScreenComposableFactoryTest.kt | 148 ---- .../workflow1/ui/compose/ComposeScreen.kt | 0 .../workflow1/ui/compose/CompositionRoot.kt | 1 - .../workflow1/ui/compose/RenderAsState.kt | 0 .../ui/compose/ScreenComposableFactory.kt | 0 .../compose/ScreenComposableFactoryFinder.kt | 0 .../compose/TextControllerAsMutableState.kt | 0 .../workflow1/ui/compose/WorkflowRendering.kt | 4 - .../ui/compose/CompositionRootTestIos.kt | 107 +++ .../workflow1/ui/compose/IosTestUtils.kt | 46 ++ .../ui/compose/RenderAsStateTestIos.kt} | 199 +++--- .../ui/compose/WorkflowRenderingTestIos.kt} | 329 ++------- .../workflow1/ui/compose/ComposeScreen.kt | 98 --- .../workflow1/ui/compose/RenderAsState.kt | 251 ------- .../ui/compose/ScreenComposableFactory.kt | 234 ------- .../compose/ScreenComposableFactoryFinder.kt | 70 -- .../compose/TextControllerAsMutableState.kt | 47 -- .../ViewEnvironmentWithComposeSupport.kt | 54 -- .../compose/src/main/res/values/ids.xml | 5 - 60 files changed, 392 insertions(+), 3461 deletions(-) delete mode 100644 samples/compose-multiplatform-samples/.gitignore delete mode 100644 samples/compose-multiplatform-samples/build.gradle.kts delete mode 100644 samples/compose-multiplatform-samples/proguard-rules.pro delete mode 100644 samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt delete mode 100644 samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-mdpi/ic_launcher.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xhdpi/ic_launcher.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png delete mode 100644 samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml delete mode 100644 samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt delete mode 100644 workflow-ui/compose-multiplatform/build.gradle.kts delete mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt delete mode 100644 workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt rename workflow-ui/compose/src/{androidTest => androidInstrumentedTest}/AndroidManifest.xml (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt (100%) rename workflow-ui/compose/src/{androidTest/java => androidInstrumentedTest/kotlin}/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt (99%) rename workflow-ui/{compose-multiplatform => compose}/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/androidMain/res/values/ids.xml (100%) delete mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt delete mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/CompositionRootTest.kt delete mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt delete mode 100644 workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt rename workflow-ui/{compose-multiplatform => compose}/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt (100%) rename workflow-ui/compose/src/{main/java => commonMain/kotlin}/com/squareup/workflow1/ui/compose/CompositionRoot.kt (98%) rename workflow-ui/{compose-multiplatform => compose}/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt (100%) rename workflow-ui/{compose-multiplatform => compose}/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt (100%) rename workflow-ui/compose/src/{main/java => commonMain/kotlin}/com/squareup/workflow1/ui/compose/WorkflowRendering.kt (96%) create mode 100644 workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTestIos.kt create mode 100644 workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/IosTestUtils.kt rename workflow-ui/compose/src/{androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt => iosTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTestIos.kt} (66%) rename workflow-ui/{compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt => compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTestIos.kt} (50%) delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt delete mode 100644 workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt delete mode 100644 workflow-ui/compose/src/main/res/values/ids.xml diff --git a/samples/compose-multiplatform-samples/.gitignore b/samples/compose-multiplatform-samples/.gitignore deleted file mode 100644 index 42afabfd2..000000000 --- a/samples/compose-multiplatform-samples/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/build \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/build.gradle.kts b/samples/compose-multiplatform-samples/build.gradle.kts deleted file mode 100644 index 974f16755..000000000 --- a/samples/compose-multiplatform-samples/build.gradle.kts +++ /dev/null @@ -1,54 +0,0 @@ -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree - -plugins { - alias(libs.plugins.jetbrains.compose.plugin) - id("kotlin-multiplatform") - id("com.android.application") - id("compose-multiplatform-ui-tests") -} - -kotlin { - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - instrumentedTestVariant { - sourceSetTree.set(KotlinSourceSetTree.test) - - dependencies { - implementation(project.dependencies.platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.squareup.leakcanary.instrumentation) - implementation(project(":workflow-ui:internal-testing-android")) - debugImplementation(libs.androidx.compose.ui.test.manifest) - } - } - } - - sourceSets { - commonMain.dependencies { - implementation(compose.foundation) - implementation(compose.material) - implementation(compose.preview) - implementation(project(":workflow-ui:compose-multiplatform")) - implementation(libs.kotlin.test.core) - } - } -} - -android { - val name = "com.squareup.sample.compose.multiplatform" - defaultConfig { - applicationId = name - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() - } - testOptions { - animationsDisabled = true - } - namespace = name -} diff --git a/samples/compose-multiplatform-samples/proguard-rules.pro b/samples/compose-multiplatform-samples/proguard-rules.pro deleted file mode 100644 index 481bb4348..000000000 --- a/samples/compose-multiplatform-samples/proguard-rules.pro +++ /dev/null @@ -1,21 +0,0 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt deleted file mode 100644 index 285e36e03..000000000 --- a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/ExampleTest.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.squareup.sample.compose.multiplatform - -import androidx.compose.foundation.layout.Column -import androidx.compose.material.Button -import androidx.compose.material.Text -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test - -class ExampleTest { - - @get:Rule - val rule = createComposeRule() - - @OptIn(ExperimentalTestApi::class) - @Test - fun myTest() { - // Declares a mock UI to demonstrate API calls - // - // Replace with your own declarations to test the code of your project - rule.setContent { - Column { - var text by remember { mutableStateOf("Hello") } - Text( - text = text, - modifier = Modifier.testTag("text") - ) - Button( - onClick = { text = "Compose" }, - modifier = Modifier.testTag("button") - ) { - Text("Click me") - } - } - } - - // Tests the declared UI with assertions and actions of the Compose Multiplatform testing API - rule.onNodeWithTag("text").assertTextEquals("Hello") - rule.onNodeWithTag("button").performClick() - rule.onNodeWithTag("text").assertTextEquals("Compose") - } -} diff --git a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt b/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt deleted file mode 100644 index b61ebd61e..000000000 --- a/samples/compose-multiplatform-samples/src/androidInstrumentedTest/kotlin/com/squareup/sample/compose/multiplatform/WorkflowRenderingTest.kt +++ /dev/null @@ -1,611 +0,0 @@ -package com.squareup.sample.compose.multiplatform - -import android.view.View -import android.widget.TextView -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertHeightIsEqualTo -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.assertWidthIsEqualTo -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.Event -import androidx.lifecycle.Lifecycle.Event.ON_CREATE -import androidx.lifecycle.Lifecycle.Event.ON_DESTROY -import androidx.lifecycle.Lifecycle.Event.ON_PAUSE -import androidx.lifecycle.Lifecycle.Event.ON_RESUME -import androidx.lifecycle.Lifecycle.Event.ON_START -import androidx.lifecycle.Lifecycle.Event.ON_STOP -import androidx.lifecycle.Lifecycle.State -import androidx.lifecycle.Lifecycle.State.CREATED -import androidx.lifecycle.Lifecycle.State.DESTROYED -import androidx.lifecycle.Lifecycle.State.INITIALIZED -import androidx.lifecycle.Lifecycle.State.RESUMED -import androidx.lifecycle.Lifecycle.State.STARTED -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import com.google.common.truth.Truth.assertThat -import com.squareup.workflow1.ui.AndroidScreen -import com.squareup.workflow1.ui.Compatible -import com.squareup.workflow1.ui.NamedScreen -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewHolder -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewEnvironmentKey -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.ComposeScreen -import com.squareup.workflow1.ui.compose.ScreenComposableFactory -import com.squareup.workflow1.ui.compose.ScreenComposableFactoryFinder -import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.compose.withCompositionRoot -import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule -import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule -import com.squareup.workflow1.ui.plus -import com.squareup.workflow1.ui.withEnvironment -import leakcanary.DetectLeaksAfterTestSuccess -import org.hamcrest.Description -import org.hamcrest.TypeSafeMatcher -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import kotlin.reflect.KClass - -@OptIn(WorkflowUiExperimentalApi::class) -class WorkflowRenderingTest { - - private val composeRule = createComposeRule() - - @get:Rule - val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) - .around(IdleAfterTestRule) - .around(composeRule) - .around(IdlingDispatcherRule) - - @Test fun doesNotRecompose_whenFactoryChanged() { - data class TestRendering( - val text: String - ) : Screen - - val registry1 = ViewRegistry( - ScreenComposableFactory { rendering, _ -> - BasicText(rendering.text) - } - ) - val registry2 = ViewRegistry( - ScreenComposableFactory { rendering, _ -> - BasicText(rendering.text.reversed()) - } - ) - val registry = mutableStateOf(registry1) - - composeRule.setContent { - WorkflowRendering(TestRendering("hello"), ViewEnvironment.EMPTY + registry.value) - } - - composeRule.onNodeWithText("hello").assertIsDisplayed() - registry.value = registry2 - composeRule.onNodeWithText("hello").assertIsDisplayed() - composeRule.onNodeWithText("olleh").assertDoesNotExist() - } - - @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() { - data class TestRendering(val text: String) : Screen - - val testFactory = ScreenComposableFactory { rendering, _ -> - BasicText(rendering.text) - } - val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(testFactory)) - .withCompositionRoot { content -> - Column { - BasicText("one") - content() - } - } - - composeRule.setContent { - WorkflowRendering(TestRendering("two"), viewEnvironment) - } - - composeRule.onNodeWithText("one").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - } - - @Test fun legacyAndroidViewRendersUpdates() { - val wrapperText = mutableStateOf("two") - - composeRule.setContent { - WorkflowRendering(LegacyViewRendering(wrapperText.value), env) - } - - onView(withText("two")).check(matches(isDisplayed())) - wrapperText.value = "OWT" - onView(withText("OWT")).check(matches(isDisplayed())) - } - - // https://github.com/square/workflow-kotlin/issues/538 - @Test fun includesSupportForNamed() { - val wrapperText = mutableStateOf("two") - - composeRule.setContent { - val rendering = NamedScreen(LegacyViewRendering(wrapperText.value), "fnord") - WorkflowRendering(rendering, env) - } - - onView(withText("two")).check(matches(isDisplayed())) - wrapperText.value = "OWT" - onView(withText("OWT")).check(matches(isDisplayed())) - } - - @Test fun namedScreenStaysInTheSameComposeView() { - composeRule.setContent { - val outer = LocalView.current - - WorkflowRendering( - viewEnvironment = env, - rendering = NamedScreen( - name = "fnord", - content = ComposeScreen { - val inner = LocalView.current - assertThat(inner).isSameInstanceAs(outer) - - BasicText("hello", Modifier.testTag("tag")) - } - ) - ) - } - - composeRule.onNodeWithTag("tag") - .assertTextEquals("hello") - } - - @Test fun environmentScreenStaysInTheSameComposeView() { - val someKey = object : ViewEnvironmentKey() { - override val default = "default" - } - - composeRule.setContent { - val outer = LocalView.current - - WorkflowRendering( - viewEnvironment = env, - rendering = ComposeScreen { environment -> - val inner = LocalView.current - assertThat(inner).isSameInstanceAs(outer) - - BasicText(environment[someKey], Modifier.testTag("tag")) - }.withEnvironment((someKey to "fnord")) - ) - } - - composeRule.onNodeWithTag("tag") - .assertTextEquals("fnord") - } - - @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() { - val lifecycleEvents = mutableListOf() - - class LifecycleRecorder : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - lifecycle.addObserver( - LifecycleEventObserver { _, event -> - lifecycleEvents += event - } - ) - onDispose { - // Yes, we're leaking the observer. That's intentional: we need to make sure we see any - // lifecycle events that happen even after the composable is destroyed. - } - } - } - } - - class EmptyRendering : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) {} - } - - var rendering: Screen by mutableStateOf(LifecycleRecorder()) - composeRule.setContent { - WorkflowRendering(rendering, env) - } - - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() - lifecycleEvents.clear() - } - - rendering = EmptyRendering() - - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() - } - } - - @Test fun destroysChildLifecycle_fromLegacyView_whenIncompatibleRendering() { - val lifecycleEvents = mutableListOf() - - class LifecycleRecorder : AndroidScreen { - override val viewFactory = - ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> - val view = object : View(context) { - override fun onAttachedToWindow() { - super.onAttachedToWindow() - val lifecycle = this.findViewTreeLifecycleOwner()!!.lifecycle - lifecycle.addObserver( - LifecycleEventObserver { _, event -> lifecycleEvents += event } - ) - // Yes, we're leaking the observer. That's intentional: we need to make sure we see - // any lifecycle events that happen even after the composable is destroyed. - } - } - ScreenViewHolder(initialEnvironment, view) { _, _ -> } - } - } - - class EmptyRendering : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) {} - } - - var rendering: Screen by mutableStateOf(LifecycleRecorder()) - composeRule.setContent { - WorkflowRendering(rendering, env) - } - - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() - lifecycleEvents.clear() - } - - rendering = EmptyRendering() - - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() - } - } - - @Test fun followsParentLifecycle() { - val states = mutableListOf() - val parentOwner = object : LifecycleOwner { - val registry = LifecycleRegistry(this) - override val lifecycle: Lifecycle - get() = registry - } - - composeRule.setContent { - CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { - WorkflowRendering(LifecycleRecorder(states), env) - } - } - - composeRule.runOnIdle { - assertThat(states).containsExactly(INITIALIZED).inOrder() - states.clear() - parentOwner.registry.currentState = STARTED - } - - composeRule.runOnIdle { - assertThat(states).containsExactly(CREATED, STARTED).inOrder() - states.clear() - parentOwner.registry.currentState = CREATED - } - - composeRule.runOnIdle { - assertThat(states).containsExactly(CREATED).inOrder() - states.clear() - parentOwner.registry.currentState = RESUMED - } - - composeRule.runOnIdle { - assertThat(states).containsExactly(STARTED, RESUMED).inOrder() - states.clear() - parentOwner.registry.currentState = DESTROYED - } - - composeRule.runOnIdle { - assertThat(states).containsExactly(STARTED, CREATED, DESTROYED).inOrder() - } - } - - @Test fun handlesParentInitiallyDestroyed() { - val states = mutableListOf() - val parentOwner = object : LifecycleOwner { - val registry = LifecycleRegistry(this) - override val lifecycle: Lifecycle - get() = registry - } - composeRule.runOnIdle { - // Cannot go directly to DESTROYED - parentOwner.registry.currentState = CREATED - parentOwner.registry.currentState = DESTROYED - } - - composeRule.setContent { - CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { - WorkflowRendering(LifecycleRecorder(states), env) - } - } - - composeRule.runOnIdle { - assertThat(states).containsExactly(INITIALIZED).inOrder() - } - } - - @Test fun appliesModifierToComposableContent() { - class Rendering : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - Box( - Modifier - .testTag("box") - .fillMaxSize() - ) - } - } - - composeRule.setContent { - WorkflowRendering( - Rendering(), - env, - Modifier.size(width = 42.dp, height = 43.dp) - ) - } - - composeRule.onNodeWithTag("box") - .assertWidthIsEqualTo(42.dp) - .assertHeightIsEqualTo(43.dp) - } - - @Test fun propagatesMinConstraints() { - class Rendering : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - Box(Modifier.testTag("box")) - } - } - - composeRule.setContent { - WorkflowRendering( - Rendering(), - env, - Modifier.sizeIn(minWidth = 42.dp, minHeight = 43.dp) - ) - } - - composeRule.onNodeWithTag("box") - .assertWidthIsEqualTo(42.dp) - .assertHeightIsEqualTo(43.dp) - } - - @Test fun appliesModifierToViewContent() { - val viewId = View.generateViewId() - - class LegacyRendering(private val viewId: Int) : AndroidScreen { - override val viewFactory = - ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> - val view = View(context) - ScreenViewHolder(initialEnvironment, view) { rendering, _ -> - view.id = rendering.viewId - } - } - } - - composeRule.setContent { - with(LocalDensity.current) { - WorkflowRendering( - LegacyRendering(viewId), - env, - Modifier.size(42.toDp(), 43.toDp()) - ) - } - } - - onView(withId(viewId)).check(matches(hasSize(42, 43))) - } - - @Test fun skipsPreviousContentWhenIncompatible() { - var disposeCount = 0 - - class Rendering( - override val compatibilityKey: String - ) : ComposableRendering, Compatible { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - var counter by rememberSaveable { mutableStateOf(0) } - Column { - BasicText( - "$compatibilityKey: $counter", - Modifier - .testTag("tag") - .clickable { counter++ } - ) - DisposableEffect(Unit) { - onDispose { - disposeCount++ - } - } - } - } - } - - var key by mutableStateOf("one") - composeRule.setContent { - WorkflowRendering(Rendering(key), env) - } - - composeRule.onNodeWithTag("tag") - .assertTextEquals("one: 0") - .performClick() - .assertTextEquals("one: 1") - - key = "two" - - composeRule.onNodeWithTag("tag") - .assertTextEquals("two: 0") - composeRule.runOnIdle { - assertThat(disposeCount).isEqualTo(1) - } - - key = "one" - - // State should not be restored. - composeRule.onNodeWithTag("tag") - .assertTextEquals("one: 0") - composeRule.runOnIdle { - assertThat(disposeCount).isEqualTo(2) - } - } - - @Test fun doesNotSkipPreviousContentWhenCompatible() { - var disposeCount = 0 - - class Rendering(val text: String) : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - var counter by rememberSaveable { mutableStateOf(0) } - Column { - BasicText( - "$text: $counter", - Modifier - .testTag("tag") - .clickable { counter++ } - ) - DisposableEffect(Unit) { - onDispose { - disposeCount++ - } - } - } - } - } - - var text by mutableStateOf("one") - composeRule.setContent { - WorkflowRendering(Rendering(text), env) - } - - composeRule.onNodeWithTag("tag") - .assertTextEquals("one: 0") - .performClick() - .assertTextEquals("one: 1") - - text = "two" - - // Counter state should be preserved. - composeRule.onNodeWithTag("tag") - .assertTextEquals("two: 1") - composeRule.runOnIdle { - assertThat(disposeCount).isEqualTo(0) - } - } - - @Suppress("SameParameterValue") - private fun hasSize( - width: Int, - height: Int - ) = object : TypeSafeMatcher() { - override fun describeTo(description: Description) { - description.appendText("has size ${width}x${height}px") - } - - override fun matchesSafely(item: View): Boolean { - return item.width == width && item.height == height - } - } - - private class LifecycleRecorder( - // For some reason, if we just capture the states val, it is null in the composable. - private val states: MutableList - ) : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - val lifecycle = LocalLifecycleOwner.current.lifecycle - DisposableEffect(lifecycle) { - this@LifecycleRecorder.states += lifecycle.currentState - lifecycle.addObserver( - LifecycleEventObserver { _, _ -> - this@LifecycleRecorder.states += lifecycle.currentState - } - ) - onDispose { - // Yes, we're leaking the observer. That's intentional: we need to make sure we see any - // lifecycle events that happen even after the composable is destroyed. - } - } - } - } - - /** - * It is significant that this returns a new instance on every call, since we can't rely on real - * implementations in the wild to reuse the same factory instance across rendering instances. - */ - private object InefficientComposableFinder : ScreenComposableFactoryFinder { - override fun getComposableFactoryForRendering( - environment: ViewEnvironment, - rendering: ScreenT - ): ScreenComposableFactory? { - return if (rendering is ComposableRendering) { - object : ScreenComposableFactory { - override val type: KClass get() = error("whatever") - - @Composable override fun Content( - rendering: ScreenT, - environment: ViewEnvironment - ) { - (rendering as ComposableRendering).Content(environment) - } - } - } else { - super.getComposableFactoryForRendering( - environment, - rendering - ) - } - } - } - - private val env = - (ViewEnvironment.EMPTY + (ScreenComposableFactoryFinder to InefficientComposableFinder)) - .withComposeInteropSupport() - - private interface ComposableRendering : Screen { - @Composable fun Content(viewEnvironment: ViewEnvironment) - } - - private data class LegacyViewRendering(val text: String) : AndroidScreen { - override val viewFactory = - ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> - val view = TextView(context) - ScreenViewHolder(initialEnvironment, view) { rendering, _ -> - view.text = rendering.text - } - } - } -} diff --git a/samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml b/samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml deleted file mode 100644 index e525e854a..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/AndroidManifest.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - diff --git a/samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt b/samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt deleted file mode 100644 index 9fa79a579..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/kotlin/com/squareup/sample/compose/multiplatform/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.squareup.sample.compose.multiplatform - -import androidx.activity.ComponentActivity - -class MainActivity : ComponentActivity() diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml deleted file mode 100644 index 2b068d114..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml b/samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml deleted file mode 100644 index e93e11ade..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,170 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index eca70cfe5..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index eca70cfe5..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index a571e60098c92c2baca8a5df62f2929cbff01b52..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3593 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 P00000NkvXXu0mjfP=x?Y diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 61da551c5594a1f9d26193983d2cd69189014603..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5339 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 diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/samples/compose-multiplatform-samples/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index b216f2d313cc673d8b8c4da591c174ebed52795c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11873 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 diff --git a/samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml b/samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml deleted file mode 100644 index c33e34045..000000000 --- a/samples/compose-multiplatform-samples/src/androidMain/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - Workflow Multiplatform - \ No newline at end of file diff --git a/samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt b/samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt deleted file mode 100644 index 5f49ea7a9..000000000 --- a/samples/compose-multiplatform-samples/src/test/java/com/squareup/sample/compose/multiplatform/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.squareup.sample.compose.multiplatform - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 76f0ab53f..f0d7719eb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,7 +38,6 @@ include( ":benchmarks:performance-poetry:complex-benchmark", ":benchmarks:performance-poetry:complex-poetry", ":internal-testing-utils", - ":samples:compose-multiplatform-samples", ":samples:compose-samples", ":samples:containers:app-poetry", ":samples:containers:app-raven", @@ -69,7 +68,6 @@ include( ":workflow-testing", ":workflow-tracing", ":workflow-ui:compose", - ":workflow-ui:compose-multiplatform", ":workflow-ui:compose-tooling", ":workflow-ui:core", ":workflow-ui:core-common", diff --git a/workflow-ui/compose-multiplatform/build.gradle.kts b/workflow-ui/compose-multiplatform/build.gradle.kts deleted file mode 100644 index 345e80fb2..000000000 --- a/workflow-ui/compose-multiplatform/build.gradle.kts +++ /dev/null @@ -1,97 +0,0 @@ -import com.squareup.workflow1.buildsrc.iosTargets -import org.jetbrains.compose.ExperimentalComposeLibrary -import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions -import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree - -plugins { - alias(libs.plugins.jetbrains.compose.plugin) - id("kotlin-multiplatform") - id("com.android.library") - id("android-defaults") - id("android-ui-tests") - // id("published") -} - -fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { - androidTarget { - @OptIn(ExperimentalKotlinGradlePluginApi::class) - instrumentedTestVariant { - sourceSetTree.set(KotlinSourceSetTree.test) - - dependencies { - debugImplementation(libs.androidx.compose.ui.test.manifest) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.squareup.leakcanary.instrumentation) - implementation(project(":workflow-ui:internal-testing-android")) - implementation(project.dependencies.platform(libs.androidx.compose.bom)) - } - } - } -} - -kotlin { - val targets = project.findProperty("workflow.targets") ?: "kmp" - - listOf( - "ios" to { iosTargets() }, - "jvm" to { jvm {} }, - "js" to { js(IR).browser() }, - "android" to { androidTargetWithTesting() } - ).forEach { (target, action) -> - if (targets == "kmp" || targets == target) { - action() - } - } - - sourceSets { - commonMain.dependencies { - api(project(":workflow-ui:core")) - - implementation(compose.foundation) - implementation(compose.components.uiToolingPreview) - implementation(compose.runtime) - implementation(compose.ui) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.jetbrains.lifecycle.runtime.compose) - - implementation(project(":workflow-core")) - implementation(project(":workflow-runtime")) - } - - commonTest.dependencies { - implementation(libs.kotlin.test.jdk) - implementation(compose.foundation) - @OptIn(ExperimentalComposeLibrary::class) - implementation(compose.uiTest) - } - - androidMain.dependencies { - api(project(":workflow-ui:core-android")) - implementation(libs.androidx.activity.core) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.compose.foundation.layout) - implementation(libs.androidx.compose.runtime.saveable) - } - } -} - -android { - val name = "com.squareup.workflow1.ui.compose.multiplatform" - val testName = "$name.test" - - defaultConfig { - testApplicationId = testName - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildFeatures.compose = true - composeOptions.kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() - namespace = name - testNamespace = testName - testOptions.animationsDisabled = true - - dependencies { - debugImplementation(compose.uiTooling) - } -} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt deleted file mode 100644 index 80d6df960..000000000 --- a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt +++ /dev/null @@ -1,104 +0,0 @@ -@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry") - -package com.squareup.workflow1.ui.compose - -import androidx.annotation.VisibleForTesting -import androidx.annotation.VisibleForTesting.Companion.PRIVATE -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi - -/** - * Used by [WrappedWithRootIfNecessary] to ensure the [CompositionRoot] is only applied once. - */ -private val LocalHasViewFactoryRootBeenApplied = staticCompositionLocalOf { false } - -/** - * A composable function that will be used to wrap the first (highest-level) - * [ScreenComposableFactory] view factory in a composition. This can be used to setup any - * [composition locals][androidx.compose.runtime.CompositionLocal] that all - * [ScreenComposableFactory] factories need access to, such as UI themes. - * - * This function will called once, to wrap the _highest-level_ [ScreenComposableFactory] - * in the tree. However, composition locals are propagated down to child [ScreenComposableFactory] - * compositions, so any locals provided here will be available in _all_ [ScreenComposableFactory] - * compositions. - */ -public typealias CompositionRoot = @Composable (content: @Composable () -> Unit) -> Unit - -/** - * Convenience function for applying a [CompositionRoot] to this [ViewEnvironment]'s - * [ScreenComposableFactoryFinder]. See [ScreenComposableFactoryFinder.withCompositionRoot]. - */ -@WorkflowUiExperimentalApi -public fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment { - return this + - (ScreenComposableFactoryFinder to this[ScreenComposableFactoryFinder].withCompositionRoot(root)) -} - -/** - * Returns a [ScreenComposableFactoryFinder] that ensures that any [ScreenComposableFactory] - * factories registered in this registry will be wrapped exactly once with a [CompositionRoot] - * wrapper. See [CompositionRoot] for more information. - */ -@WorkflowUiExperimentalApi -public fun ScreenComposableFactoryFinder.withCompositionRoot( - root: CompositionRoot -): ScreenComposableFactoryFinder { - return mapFactories { factory -> - @Suppress("UNCHECKED_CAST") - (factory as? ScreenComposableFactory)?.let { composeFactory -> - ScreenComposableFactory(composeFactory.type) { rendering, environment -> - WrappedWithRootIfNecessary(root) { composeFactory.Content(rendering, environment) } - } - } ?: factory - } -} - -/** - * Adds [content] to the composition, ensuring that [CompositionRoot] has been applied. Will only - * wrap the content at the highest occurrence of this function in the composition subtree. - */ -@VisibleForTesting(otherwise = PRIVATE) -@Composable -internal fun WrappedWithRootIfNecessary( - root: CompositionRoot, - content: @Composable () -> Unit -) { - if (LocalHasViewFactoryRootBeenApplied.current) { - // The only way this local can have the value true is if, somewhere above this point in the - // composition, the else case below was hit and wrapped us in the local. Since the root - // wrapper will have already been applied, we can just compose content directly. - content() - } else { - // If the local is false, this is the first time this function has appeared in the composition - // so far. We provide a true value for the local for everything below us, so any recursive - // calls to this function will hit the if case above and not re-apply the wrapper. - CompositionLocalProvider(LocalHasViewFactoryRootBeenApplied provides true) { - root(content) - } - } -} - -@WorkflowUiExperimentalApi -private fun ScreenComposableFactoryFinder.mapFactories( - transform: (ScreenComposableFactory<*>) -> ScreenComposableFactory<*> -): ScreenComposableFactoryFinder = object : ScreenComposableFactoryFinder { - override fun getComposableFactoryForRendering( - environment: ViewEnvironment, - rendering: ScreenT - ): ScreenComposableFactory? { - val factoryFor = this@mapFactories.getComposableFactoryForRendering(environment, rendering) - ?: return null - val transformedFactory = transform(factoryFor) - check(transformedFactory.type == rendering::class) { - "Expected transform to return a ScreenComposableFactory that is compatible " + - "with ${rendering::class}, but got one with type ${transformedFactory.type}" - } - @Suppress("UNCHECKED_CAST") - return transformedFactory as ScreenComposableFactory - } -} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt b/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt deleted file mode 100644 index 8a80be0ca..000000000 --- a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.key -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.Lifecycle.State.DESTROYED -import androidx.lifecycle.Lifecycle.State.INITIALIZED -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import com.squareup.workflow1.ui.Compatible -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi - -/** - * Renders [rendering] into the composition using this [ViewEnvironment]'s - * [ScreenComposeFactoryFinder] to generate the view. - * - * ## Example - * - * ``` - * data class FramedRendering( - * val borderColor: Color, - * val child: R - * ) : ComposeRendering { - * - * @Composable override fun Content(viewEnvironment: ViewEnvironment) { - * Surface(border = Border(borderColor, 8.dp)) { - * WorkflowRendering(child, viewEnvironment) - * } - * } - * } - * ``` - * - * @param rendering The workflow rendering to display. - * @param modifier A [Modifier] that will be applied to composable used to show [rendering]. - * - * @throws IllegalArgumentException if no factory can be found for [rendering]'s type. - */ -@WorkflowUiExperimentalApi -@Composable -public fun WorkflowRendering( - rendering: Screen, - viewEnvironment: ViewEnvironment, - modifier: Modifier = Modifier -) { - // This will fetch a new view factory any time the new rendering is incompatible with the previous - // one, as determined by Compatible. This corresponds to WorkflowViewStub's canShowRendering - // check. - val renderingCompatibilityKey = Compatible.keyFor(rendering) - - // By surrounding the below code with this key function, any time the new rendering is not - // compatible with the previous rendering we'll tear down the previous subtree of the composition, - // including its lifecycle, which destroys the lifecycle and any remembered state. If the view - // factory created an Android view, this will also remove the old one from the view hierarchy - // before replacing it with the new one. - key(renderingCompatibilityKey) { - val composableFactory = remember { - // The view registry may return a new factory instance for a rendering every time we ask it, for - // example if an AndroidScreen doesn't share its factory between rendering instances. We - // intentionally don't ask it for a new instance every time to match the behavior of - // WorkflowViewStub and other containers, which only ask for a new factory when the rendering is - // incompatible. - rendering.toComposableFactory(viewEnvironment) - } - - // Just like WorkflowViewStub, we need to manage a Lifecycle for the child view. We just provide - // a local here – ViewFactoryAndroidView will handle setting the appropriate view tree owners - // on the child view when necessary. Because this function is surrounded by a key() call, when - // the rendering is incompatible, the lifecycle for the old view will be destroyed. - val lifecycleOwner = rememberChildLifecycleOwner() - - CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { - // We need to propagate min constraints because one of the likely uses for the modifier passed - // into this function is to directly control the layout of the child view – which means - // minimum constraints are likely to be significant. - Box(modifier, propagateMinConstraints = true) { - composableFactory.Content(rendering, viewEnvironment) - } - } - } -} - -/** - * Returns a [LifecycleOwner] that is a mirror of the current [LocalLifecycleOwner] until this - * function leaves the composition. More details can be found [here](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-lifecycle.html) - * for the lifecycle of a composable function depending what platform is used - */ -@Composable private fun rememberChildLifecycleOwner(): LifecycleOwner { - val lifecycleOwner = remember { - object : LifecycleOwner { - val registry = LifecycleRegistry(this) - override val lifecycle: Lifecycle - get() = registry - } - } - val parentLifecycle = LocalLifecycleOwner.current.lifecycle - - DisposableEffect(parentLifecycle) { - val parentObserver = LifecycleEventObserver { _, event -> - // Any time the parent lifecycle changes state, perform the same change on our lifecycle. - lifecycleOwner.registry.handleLifecycleEvent(event) - } - - parentLifecycle.addObserver(parentObserver) - onDispose { - parentLifecycle.removeObserver(parentObserver) - - // If we're leaving the composition it means the WorkflowRendering is either going away itself - // or about to switch to an incompatible rendering – either way, this lifecycle is dead. Note - // that we can't transition from INITIALIZED to DESTROYED – the LifecycleRegistry will throw. - if (lifecycleOwner.registry.currentState != INITIALIZED) { - lifecycleOwner.registry.currentState = DESTROYED - } - } - } - - return lifecycleOwner -} diff --git a/workflow-ui/compose/build.gradle.kts b/workflow-ui/compose/build.gradle.kts index ab5ef8990..aa7eb72b0 100644 --- a/workflow-ui/compose/build.gradle.kts +++ b/workflow-ui/compose/build.gradle.kts @@ -1,60 +1,94 @@ +import com.squareup.workflow1.buildsrc.iosTargets +import org.jetbrains.compose.ExperimentalComposeLibrary +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree + import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { + alias(libs.plugins.jetbrains.compose.plugin) + id("kotlin-multiplatform") id("com.android.library") - id("kotlin-android") id("android-defaults") id("android-ui-tests") - id("published") + // id("published") } -android { - buildFeatures.compose = true - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() +fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant { + sourceSetTree.set(KotlinSourceSetTree.test) + + dependencies { + debugImplementation(libs.androidx.compose.ui.test.manifest) + implementation(libs.androidx.compose.ui.test.junit4) + implementation(libs.squareup.leakcanary.instrumentation) + implementation(project(":workflow-ui:internal-testing-android")) + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + } + } } - namespace = "com.squareup.workflow1.ui.compose" - testNamespace = "$namespace.test" } -tasks.withType { - kotlinOptions { - @Suppress("SuspiciousCollectionReassignment") - freeCompilerArgs += listOf( - "-opt-in=kotlin.RequiresOptIn" - ) +kotlin { + targets.all { + compilations.all { + kotlinOptions { + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" + } + } + } + + val targets = project.findProperty("workflow.targets") ?: "kmp" + + listOf( + "ios" to { iosTargets() }, + "jvm" to { jvm() }, + "js" to { js(IR).browser() }, + "android" to { androidTargetWithTesting() }, + ).forEach { (target, action) -> + if (targets == "kmp" || targets == target) { + action() + } + } + + sourceSets { + commonMain.dependencies { + api(project(":workflow-ui:core")) + + implementation(compose.foundation) + implementation(compose.components.uiToolingPreview) + implementation(compose.runtime) + implementation(compose.ui) + implementation(libs.jetbrains.lifecycle.runtime.compose) + + implementation(project(":workflow-core")) + implementation(project(":workflow-runtime")) + } + + commonTest.dependencies { + implementation(libs.kotlin.test.jdk) + + @OptIn(ExperimentalComposeLibrary::class) + implementation(compose.uiTest) + } + + androidMain.dependencies { + api(project(":workflow-ui:core-android")) + implementation(libs.androidx.activity.compose) + } } } -dependencies { - val composeBom = platform(libs.androidx.compose.bom) - - androidTestImplementation(libs.androidx.activity.core) - androidTestImplementation(composeBom) - androidTestImplementation(libs.androidx.compose.foundation) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - androidTestImplementation(libs.androidx.test.core) - androidTestImplementation(libs.androidx.test.truth) - androidTestImplementation(libs.kotlin.test.jdk) - - androidTestImplementation(project(":workflow-ui:internal-testing-compose")) - - api(libs.androidx.compose.runtime) - api(libs.kotlin.common) - - api(project(":workflow-ui:core-android")) - api(project(":workflow-ui:core-common")) - - implementation(composeBom) - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.compose.foundation.layout) - implementation(libs.androidx.compose.runtime.saveable) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.lifecycle.common) - implementation(libs.androidx.lifecycle.core) - implementation(libs.kotlinx.coroutines.core) - implementation(libs.squareup.okio) - - implementation(project(":workflow-core")) - implementation(project(":workflow-runtime")) +android { + buildFeatures.compose = true + composeOptions.kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + namespace = "com.squareup.workflow1.ui.compose" + testNamespace = "$namespace.test" + + dependencies { + debugImplementation(compose.uiTooling) + } } diff --git a/workflow-ui/compose/src/androidTest/AndroidManifest.xml b/workflow-ui/compose/src/androidInstrumentedTest/AndroidManifest.xml similarity index 100% rename from workflow-ui/compose/src/androidTest/AndroidManifest.xml rename to workflow-ui/compose/src/androidInstrumentedTest/AndroidManifest.xml diff --git a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt b/workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt rename to workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt b/workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt rename to workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt b/workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt rename to workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt diff --git a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt b/workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt rename to workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt b/workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt rename to workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt b/workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt similarity index 100% rename from workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt rename to workflow-ui/compose/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt diff --git a/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt b/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt similarity index 99% rename from workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt rename to workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt index 176af4047..67610006c 100644 --- a/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt +++ b/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt @@ -16,7 +16,6 @@ import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey -import com.squareup.workflow1.ui.compose.multiplatform.R import com.squareup.workflow1.ui.show import com.squareup.workflow1.ui.startShowing import kotlin.reflect.KClass diff --git a/workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt b/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt rename to workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt diff --git a/workflow-ui/compose-multiplatform/src/androidMain/res/values/ids.xml b/workflow-ui/compose/src/androidMain/res/values/ids.xml similarity index 100% rename from workflow-ui/compose-multiplatform/src/androidMain/res/values/ids.xml rename to workflow-ui/compose/src/androidMain/res/values/ids.xml diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt deleted file mode 100644 index d4deac03b..000000000 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ComposeViewTreeIntegrationTest.kt +++ /dev/null @@ -1,644 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import android.content.Context -import android.view.View -import android.view.ViewGroup -import androidx.activity.ComponentDialog -import androidx.compose.foundation.clickable -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnDetachedFromWindow -import androidx.compose.ui.platform.ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import com.google.common.truth.Truth.assertThat -import com.squareup.workflow1.ui.AndroidScreen -import com.squareup.workflow1.ui.Compatible -import com.squareup.workflow1.ui.NamedScreen -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewHolder -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule -import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule -import com.squareup.workflow1.ui.internal.test.WorkflowUiTestActivity -import com.squareup.workflow1.ui.navigation.AndroidOverlay -import com.squareup.workflow1.ui.navigation.BackStackScreen -import com.squareup.workflow1.ui.navigation.BodyAndOverlaysScreen -import com.squareup.workflow1.ui.navigation.OverlayDialogFactory -import com.squareup.workflow1.ui.navigation.ScreenOverlay -import com.squareup.workflow1.ui.navigation.asDialogHolderWithContent -import com.squareup.workflow1.ui.plus -import leakcanary.DetectLeaksAfterTestSuccess -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import kotlin.reflect.KClass - -@OptIn(WorkflowUiExperimentalApi::class) -internal class ComposeViewTreeIntegrationTest { - - private val composeRule = createAndroidComposeRule() - - @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) - .around(IdleAfterTestRule) - .around(composeRule) - .around(IdlingDispatcherRule) - - private val scenario get() = composeRule.activityRule.scenario - - @Before fun setUp() { - scenario.onActivity { - it.viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(NoTransitionBackStackContainer) - } - } - - @Test fun compose_view_assertions_work() { - val firstScreen = TestComposeRendering("first") { - BasicText("First Screen") - } - val secondScreen = TestComposeRendering("second") {} - - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.onNodeWithText("First Screen").assertIsDisplayed() - - // Navigate away from the first screen. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - composeRule.onNodeWithText("First Screen").assertDoesNotExist() - } - - @Test fun composition_is_disposed_when_navigated_away_dispose_on_detach_strategy() { - var composedCount = 0 - var disposedCount = 0 - val firstScreen = TestComposeRendering("first", disposeStrategy = DisposeOnDetachedFromWindow) { - DisposableEffect(Unit) { - composedCount++ - onDispose { - disposedCount++ - } - } - } - val secondScreen = TestComposeRendering("second") {} - - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.runOnIdle { - assertThat(composedCount).isEqualTo(1) - assertThat(disposedCount).isEqualTo(0) - } - - // Navigate away. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - composeRule.runOnIdle { - assertThat(composedCount).isEqualTo(1) - assertThat(disposedCount).isEqualTo(1) - } - } - - @Test fun composition_is_disposed_when_navigated_away_dispose_on_destroy_strategy() { - var composedCount = 0 - var disposedCount = 0 - val firstScreen = - TestComposeRendering("first", disposeStrategy = DisposeOnViewTreeLifecycleDestroyed) { - DisposableEffect(Unit) { - composedCount++ - onDispose { - disposedCount++ - } - } - } - val secondScreen = TestComposeRendering("second") {} - - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.runOnIdle { - assertThat(composedCount).isEqualTo(1) - assertThat(disposedCount).isEqualTo(0) - } - - // Navigate away. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - composeRule.runOnIdle { - assertThat(composedCount).isEqualTo(1) - assertThat(disposedCount).isEqualTo(1) - } - } - - @Test fun composition_state_is_restored_after_config_change() { - val firstScreen = TestComposeRendering("first") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setBackstack(firstScreen) - } - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - .assertTextEquals("Counter: 1") - - // Simulate config change. - scenario.recreate() - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - } - - @Test fun composition_state_is_restored_after_navigating_back() { - val firstScreen = TestComposeRendering("first") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - val secondScreen = TestComposeRendering("second") { - BasicText("nothing to see here") - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setBackstack(firstScreen) - } - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - .assertTextEquals("Counter: 1") - - // Add a screen to the backstack. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - composeRule.onNodeWithTag(CounterTag) - .assertDoesNotExist() - - // Navigate back. - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - } - - @Test - fun composition_state_is_restored_after_config_change_then_navigating_back() { - val firstScreen = TestComposeRendering("first") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - val secondScreen = TestComposeRendering("second") { - BasicText("nothing to see here") - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setBackstack(firstScreen) - } - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - .assertTextEquals("Counter: 1") - - // Add a screen to the backstack. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - scenario.recreate() - - composeRule.onNodeWithText("nothing to see here") - .assertIsDisplayed() - - // Navigate to the first screen again. - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - } - - @Test fun composition_state_is_not_restored_after_screen_is_removed_from_backstack() { - val firstScreen = TestComposeRendering("first") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - val secondScreen = TestComposeRendering("second") { - BasicText("nothing to see here") - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setBackstack(firstScreen) - } - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - - // Add a screen to the backstack. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - // Remove the initial screen from the backstack – this should drop its state. - scenario.onActivity { - it.setBackstack(secondScreen) - } - - // Navigate to the first screen again. - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - } - - @Test - fun composition_state_is_not_restored_after_screen_is_removed_and_replaced_from_backstack() { - val firstScreen = TestComposeRendering("first") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - val secondScreen = TestComposeRendering("second") { - BasicText("nothing to see here") - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setBackstack(firstScreen) - } - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - - // Add a screen to the backstack. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - // Remove the initial screen from the backstack – this should drop its state. - scenario.onActivity { - it.setBackstack(secondScreen) - } - - // Put the initial screen back – it should still not have saved state anymore. - scenario.onActivity { - it.setBackstack(firstScreen, secondScreen) - } - - // Navigate to the first screen again. - scenario.onActivity { - it.setBackstack(firstScreen) - } - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - } - - @Test fun composition_is_restored_in_modal_after_config_change() { - val firstScreen: Screen = TestComposeRendering(compatibilityKey = "") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setRendering( - BodyAndOverlaysScreen( - EmptyRendering, - listOf(TestOverlay(BackStackScreen(EmptyRendering, firstScreen))) - ) - ) - } - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - .assertTextEquals("Counter: 1") - - scenario.recreate() - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - } - - @Test fun composition_is_restored_in_multiple_modals_after_config_change() { - val firstScreen: Screen = TestComposeRendering(compatibilityKey = "0") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag) - ) - } - - val secondScreen: Screen = TestComposeRendering(compatibilityKey = "1") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter2: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag2) - ) - } - - val thirdScreen: Screen = TestComposeRendering(compatibilityKey = "2") { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter3: $counter", - Modifier - .clickable { counter++ } - .testTag(CounterTag3) - ) - } - - // Show first screen to initialize state. - scenario.onActivity { - it.setRendering( - BodyAndOverlaysScreen( - EmptyRendering, - listOf( - TestOverlay(firstScreen), - TestOverlay(secondScreen), - TestOverlay(thirdScreen) - ) - ) - ) - } - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 0") - .performClick() - .assertTextEquals("Counter: 1") - - composeRule.onNodeWithTag(CounterTag2) - .assertTextEquals("Counter2: 0") - .performClick() - .assertTextEquals("Counter2: 1") - - composeRule.onNodeWithTag(CounterTag3) - .assertTextEquals("Counter3: 0") - .performClick() - .assertTextEquals("Counter3: 1") - - scenario.recreate() - - composeRule.onNodeWithTag(CounterTag) - .assertTextEquals("Counter: 1") - - composeRule.onNodeWithTag(CounterTag2) - .assertTextEquals("Counter2: 1") - - composeRule.onNodeWithTag(CounterTag3) - .assertTextEquals("Counter3: 1") - } - - @Test fun composition_is_restored_in_multiple_modals_backstacks_after_config_change() { - fun createRendering( - layer: Int, - screen: Int - ) = TestComposeRendering( - // Use the same compatibility key across layers – these screens are in different modals, so - // they won't conflict. - compatibilityKey = screen.toString() - ) { - var counter by rememberSaveable { mutableStateOf(0) } - BasicText( - "Counter[$layer][$screen]: $counter", - Modifier - .clickable { counter++ } - .testTag("L${layer}S$screen") - ) - } - - val layer0Screen0 = createRendering(0, 0) - val layer0Screen1 = createRendering(0, 1) - val layer1Screen0 = createRendering(1, 0) - val layer1Screen1 = createRendering(1, 1) - - // Show first screen to initialize state. - scenario.onActivity { - it.setRendering( - BodyAndOverlaysScreen( - EmptyRendering, - listOf( - TestOverlay(BackStackScreen(EmptyRendering, layer0Screen0)), - // A SavedStateRegistry is set up for each modal. Each registry needs a unique name, - // and these names default to their `Compatible.keyFor` value. When we show two - // of the same type at the same time, we need to give them unique names. - TestOverlay(NamedScreen(BackStackScreen(EmptyRendering, layer1Screen0), "another")) - ) - ) - ) - } - - composeRule.onNodeWithTag("L0S0") - .assertTextEquals("Counter[0][0]: 0") - .assertIsDisplayed() - .performClick() - .assertTextEquals("Counter[0][0]: 1") - - composeRule.onNodeWithTag("L1S0") - .assertTextEquals("Counter[1][0]: 0") - .assertIsDisplayed() - .performClick() - .assertTextEquals("Counter[1][0]: 1") - - // Push some screens onto the backstack. - scenario.onActivity { - it.setRendering( - BodyAndOverlaysScreen( - EmptyRendering, - listOf( - TestOverlay(BackStackScreen(EmptyRendering, layer0Screen0, layer0Screen1)), - // A SavedStateRegistry is set up for each modal. Each registry needs a unique name, - // and these names default to their `Compatible.keyFor` value. When we show two - // of the same type at the same time, we need to give them unique names. - TestOverlay( - NamedScreen(BackStackScreen(EmptyRendering, layer1Screen0, layer1Screen1), "another") - ) - ) - ) - ) - } - - composeRule.onNodeWithTag("L0S0") - .assertDoesNotExist() - composeRule.onNodeWithTag("L1S0") - .assertDoesNotExist() - - composeRule.onNodeWithTag("L0S1") - .assertTextEquals("Counter[0][1]: 0") - .assertIsDisplayed() - .performClick() - .assertTextEquals("Counter[0][1]: 1") - - composeRule.onNodeWithTag("L1S1") - .assertTextEquals("Counter[1][1]: 0") - .assertIsDisplayed() - .performClick() - .assertTextEquals("Counter[1][1]: 1") - - // Simulate config change. - scenario.recreate() - - // Check that the last-shown screens were restored. - composeRule.onNodeWithTag("L0S1") - .assertIsDisplayed() - composeRule.onNodeWithTag("L1S1") - .assertIsDisplayed() - - // Pop both backstacks and check that screens were restored. - scenario.onActivity { - it.setRendering( - BodyAndOverlaysScreen( - EmptyRendering, - listOf( - TestOverlay(BackStackScreen(EmptyRendering, layer0Screen0)), - // A SavedStateRegistry is set up for each modal. Each registry needs a unique name, - // and these names default to their `Compatible.keyFor` value. When we show two - // of the same type at the same time, we need to give them unique names. - TestOverlay(NamedScreen(BackStackScreen(EmptyRendering, layer1Screen0), "another")) - ) - ) - ) - } - - composeRule.onNodeWithText("Counter[0][0]: 1") - .assertIsDisplayed() - composeRule.onNodeWithText("Counter[1][0]: 1") - .assertIsDisplayed() - } - - private fun WorkflowUiTestActivity.setBackstack(vararg backstack: TestComposeRendering) { - setRendering( - BackStackScreen.fromList(listOf>(EmptyRendering) + backstack.asList()) - ) - } - - data class TestOverlay( - override val content: Screen - ) : ScreenOverlay, AndroidOverlay { - override fun map(transform: (Screen) -> U) = error("Not implemented") - - override val dialogFactory = - OverlayDialogFactory { initialRendering, initialEnvironment, context: Context -> - ComponentDialog(context).asDialogHolderWithContent(initialRendering, initialEnvironment) - } - } - - data class TestComposeRendering( - override val compatibilityKey: String, - val disposeStrategy: ViewCompositionStrategy? = null, - val content: @Composable () -> Unit - ) : Compatible, AndroidScreen, ScreenViewFactory { - override val type: KClass = TestComposeRendering::class - override val viewFactory: ScreenViewFactory get() = this - - override fun buildView( - initialRendering: TestComposeRendering, - initialEnvironment: ViewEnvironment, - context: Context, - container: ViewGroup? - ): ScreenViewHolder { - var lastCompositionStrategy = initialRendering.disposeStrategy - - return ComposeView(context).let { view -> - lastCompositionStrategy?.let(view::setViewCompositionStrategy) - - ScreenViewHolder(initialEnvironment, view) { rendering, _ -> - if (rendering.disposeStrategy != lastCompositionStrategy) { - lastCompositionStrategy = rendering.disposeStrategy - lastCompositionStrategy?.let { view.setViewCompositionStrategy(it) } - } - - view.setContent(rendering.content) - } - } - } - } - - object EmptyRendering : AndroidScreen { - override val viewFactory: ScreenViewFactory - get() = ScreenViewFactory.fromCode { _, e, c, _ -> - ScreenViewHolder(e, View(c)) { _, _ -> } - } - } - - companion object { - const val CounterTag = "counter" - const val CounterTag2 = "counter2" - const val CounterTag3 = "counter3" - } -} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/CompositionRootTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/CompositionRootTest.kt deleted file mode 100644 index bce16d6a7..000000000 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/CompositionRootTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithText -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule -import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule -import leakcanary.DetectLeaksAfterTestSuccess -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -@OptIn(WorkflowUiExperimentalApi::class) -internal class CompositionRootTest { - - private val composeRule = createComposeRule() - - @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) - .around(IdleAfterTestRule) - .around(composeRule) - .around(IdlingDispatcherRule) - - @Test fun wrappedWithRootIfNecessary_wrapsWhenNecessary() { - val root: CompositionRoot = { content -> - Column { - BasicText("one") - content() - } - } - - composeRule.setContent { - WrappedWithRootIfNecessary(root) { - BasicText("two") - } - } - - // These semantics used to merge, but as of dev15, they don't, which seems to be a bug. - // https://issuetracker.google.com/issues/161979921 - composeRule.onNodeWithText("one").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - } - - @Test fun wrappedWithRootIfNecessary_onlyWrapsOnce() { - val root: CompositionRoot = { content -> - Column { - BasicText("one") - content() - } - } - - composeRule.setContent { - WrappedWithRootIfNecessary(root) { - BasicText("two") - WrappedWithRootIfNecessary(root) { - BasicText("three") - } - } - } - - composeRule.onNodeWithText("one").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - composeRule.onNodeWithText("three").assertIsDisplayed() - } - - @Test fun wrappedWithRootIfNecessary_seesUpdatesFromRootWrapper() { - val wrapperText = mutableStateOf("one") - val root: CompositionRoot = { content -> - Column { - BasicText(wrapperText.value) - content() - } - } - - composeRule.setContent { - WrappedWithRootIfNecessary(root) { - BasicText("two") - } - } - - composeRule.onNodeWithText("one").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - wrapperText.value = "ENO" - composeRule.onNodeWithText("ENO").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - } - - @Test fun wrappedWithRootIfNecessary_rewrapsWhenDifferentRoot() { - val root1: CompositionRoot = { content -> - Column { - BasicText("one") - content() - } - } - val root2: CompositionRoot = { content -> - Column { - BasicText("ENO") - content() - } - } - val viewEnvironment = mutableStateOf(root1) - - composeRule.setContent { - WrappedWithRootIfNecessary(viewEnvironment.value) { - BasicText("two") - } - } - - composeRule.onNodeWithText("one").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - viewEnvironment.value = root2 - composeRule.onNodeWithText("ENO").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - } -} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt deleted file mode 100644 index f6bb3821e..000000000 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/NoTransitionBackStackContainer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import android.content.Context -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import com.squareup.workflow1.ui.NamedScreen -import com.squareup.workflow1.ui.R -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewHolder -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.navigation.BackStackContainer -import com.squareup.workflow1.ui.navigation.BackStackScreen - -/** - * A subclass of [BackStackContainer] that disables transitions to make it simpler to test the - * actual backstack logic. Views are just swapped instantly. - */ -// TODO (https://github.com/square/workflow-kotlin/issues/306) Remove once BackStackContainer is -// transition-ignorant. -@OptIn(WorkflowUiExperimentalApi::class) -internal class NoTransitionBackStackContainer(context: Context) : BackStackContainer(context) { - - override fun performTransition( - oldHolderMaybe: ScreenViewHolder>?, - newHolder: ScreenViewHolder>, - popped: Boolean - ) { - oldHolderMaybe?.view?.let(::removeView) - addView(newHolder.view) - } - - companion object : ScreenViewFactory> - by ScreenViewFactory.fromCode( - buildView = { _, initialEnvironment, context, _ -> - val view = NoTransitionBackStackContainer(context) - .apply { - id = R.id.workflow_back_stack_container - layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT) - } - ScreenViewHolder(initialEnvironment, view) { rendering, environment -> - view.update(rendering, environment) - } - } - ) -} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt b/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt deleted file mode 100644 index 23402346f..000000000 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryTest.kt +++ /dev/null @@ -1,148 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import android.content.Context -import android.widget.FrameLayout -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.viewinterop.AndroidView -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewEnvironmentKey -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.WorkflowViewStub -import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule -import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule -import com.squareup.workflow1.ui.plus -import leakcanary.DetectLeaksAfterTestSuccess -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith - -@OptIn(WorkflowUiExperimentalApi::class) -@RunWith(AndroidJUnit4::class) -internal class ScreenComposableFactoryTest { - - private val composeRule = createComposeRule() - - @get:Rule val rules: RuleChain = - RuleChain.outerRule(DetectLeaksAfterTestSuccess()) - .around(IdleAfterTestRule) - .around(composeRule) - .around(IdlingDispatcherRule) - - @Test fun showsComposeContent() { - val viewFactory = ScreenComposableFactory { _, _ -> - BasicText("Hello, world!") - } - val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(viewFactory)) - .withComposeInteropSupport() - - composeRule.setContent { - AndroidView(::RootView) { - it.stub.show(TestRendering(), viewEnvironment) - } - } - - composeRule.onNodeWithText("Hello, world!").assertIsDisplayed() - } - - @Test fun getsRenderingUpdates() { - val viewFactory = ScreenComposableFactory { rendering, _ -> - BasicText(rendering.text, Modifier.testTag("text")) - } - val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(viewFactory)) - .withComposeInteropSupport() - var rendering by mutableStateOf(TestRendering("hello")) - - composeRule.setContent { - AndroidView(::RootView) { - it.stub.show(rendering, viewEnvironment) - } - } - composeRule.onNodeWithTag("text").assertTextEquals("hello") - - rendering = TestRendering("world") - - composeRule.onNodeWithTag("text").assertTextEquals("world") - } - - @Test fun getsViewEnvironmentUpdates() { - val testEnvironmentKey = object : ViewEnvironmentKey() { - override val default: String get() = error("No default") - } - - val viewFactory = ScreenComposableFactory { _, environment -> - val text = environment[testEnvironmentKey] - BasicText(text, Modifier.testTag("text")) - } - val viewRegistry = ViewRegistry(viewFactory) - var viewEnvironment by mutableStateOf( - (ViewEnvironment.EMPTY + viewRegistry + (testEnvironmentKey to "hello")) - .withComposeInteropSupport() - ) - - composeRule.setContent { - AndroidView(::RootView) { - it.stub.show(TestRendering(), viewEnvironment) - } - } - composeRule.onNodeWithTag("text").assertTextEquals("hello") - - viewEnvironment = viewEnvironment + (testEnvironmentKey to "world") - - composeRule.onNodeWithTag("text").assertTextEquals("world") - } - - @Test fun wrapsFactoryWithRoot() { - val wrapperText = mutableStateOf("one") - val viewEnvironment = (ViewEnvironment.EMPTY + ViewRegistry(TestFactory)) - .withCompositionRoot { content -> - Column { - BasicText(wrapperText.value) - content() - } - } - .withComposeInteropSupport() - - composeRule.setContent { - AndroidView(::RootView) { - it.stub.show(TestRendering("two"), viewEnvironment) - } - } - - // Compose bug doesn't let us use assertIsDisplayed on older devices. - // See https://issuetracker.google.com/issues/157728188. - composeRule.onNodeWithText("one").assertExists() - composeRule.onNodeWithText("two").assertExists() - - wrapperText.value = "ENO" - - composeRule.onNodeWithText("ENO").assertExists() - composeRule.onNodeWithText("two").assertExists() - } - - private class RootView(context: Context) : FrameLayout(context) { - val stub = WorkflowViewStub(context).also(::addView) - } - - private data class TestRendering(val text: String = "") : Screen - - private companion object { - val TestFactory = ScreenComposableFactory { rendering, _ -> - BasicText(rendering.text) - } - } -} diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ComposeScreen.kt diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt similarity index 98% rename from workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt index 1902c7f9f..6db3be614 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/CompositionRoot.kt +++ b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/CompositionRoot.kt @@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactoryFinder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/RenderAsState.kt diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt diff --git a/workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt similarity index 100% rename from workflow-ui/compose-multiplatform/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt similarity index 96% rename from workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt rename to workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt index e8a4f9b24..e2d9c1ec0 100644 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/WorkflowRendering.kt +++ b/workflow-ui/compose/src/commonMain/kotlin/com/squareup/workflow1/ui/compose/WorkflowRendering.kt @@ -16,12 +16,8 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.squareup.workflow1.ui.Compatible import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactoryFinder -import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.WorkflowViewStub -import com.squareup.workflow1.ui.androidx.WorkflowLifecycleOwner /** * Renders [rendering] into the composition using this [ViewEnvironment]'s diff --git a/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTestIos.kt b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTestIos.kt new file mode 100644 index 000000000..41ab074f7 --- /dev/null +++ b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/CompositionRootTestIos.kt @@ -0,0 +1,107 @@ +@file:OptIn(ExperimentalTestApi::class, ExperimentalTestApi::class) + +package com.squareup.workflow1.ui.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.Test + +internal class CompositionRootTestIos { + + @Test fun wrappedWithRootIfNecessary_wrapsWhenNecessary() = runComposeUiTest { + val root: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + + setContent { + WrappedWithRootIfNecessary(root) { + BasicText("two") + } + } + + // These semantics used to merge, but as of dev15, they don't, which seems to be a bug. + // https://issuetracker.google.com/issues/161979921 + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + } + + @Test fun wrappedWithRootIfNecessary_onlyWrapsOnce() = runComposeUiTest { + val root: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + + setContentWithLifecycle { + WrappedWithRootIfNecessary(root) { + BasicText("two") + WrappedWithRootIfNecessary(root) { + BasicText("three") + } + } + } + + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + onNodeWithText("three").assertIsDisplayed() + } + + @Test fun wrappedWithRootIfNecessary_seesUpdatesFromRootWrapper() = runComposeUiTest { + val wrapperText = mutableStateOf("one") + val root: CompositionRoot = { content -> + Column { + BasicText(wrapperText.value) + content() + } + } + + setContentWithLifecycle { + WrappedWithRootIfNecessary(root) { + BasicText("two") + } + } + + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + wrapperText.value = "ENO" + onNodeWithText("ENO").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + } + + @Test fun wrappedWithRootIfNecessary_rewrapsWhenDifferentRoot() = runComposeUiTest { + val root1: CompositionRoot = { content -> + Column { + BasicText("one") + content() + } + } + val root2: CompositionRoot = { content -> + Column { + BasicText("ENO") + content() + } + } + val viewEnvironment = mutableStateOf(root1) + + setContentWithLifecycle { + WrappedWithRootIfNecessary(viewEnvironment.value) { + BasicText("two") + } + } + + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + viewEnvironment.value = root2 + onNodeWithText("ENO").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() + } +} diff --git a/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/IosTestUtils.kt b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/IosTestUtils.kt new file mode 100644 index 000000000..460ddb58f --- /dev/null +++ b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/IosTestUtils.kt @@ -0,0 +1,46 @@ +package com.squareup.workflow1.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State.DESTROYED +import androidx.lifecycle.Lifecycle.State.RESUMED +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry + +@OptIn(ExperimentalTestApi::class) +fun ComposeUiTest.setContentWithLifecycle( + lifecycleOwner: LifecycleOwner = IosLifecycleOwner(), + content: @Composable () -> Unit +) { + setContent { + (lifecycleOwner as? IosLifecycleOwner)?.let { + DisposableEffect(Unit) { + with(lifecycleOwner.registry) { + currentState = RESUMED + + onDispose { + currentState = DESTROYED + } + } + } + } + + CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) { + content() + } + } +} + + +class IosLifecycleOwner : LifecycleOwner { + val registry = LifecycleRegistry(this) + override val lifecycle: Lifecycle + get() = registry +} diff --git a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTestIos.kt similarity index 66% rename from workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt rename to workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTestIos.kt index e8f562f72..f964bdbbd 100644 --- a/workflow-ui/compose/src/androidTest/java/com/squareup/workflow1/ui/compose/RenderAsStateTest.kt +++ b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/RenderAsStateTestIos.kt @@ -1,19 +1,19 @@ -@file:OptIn(ExperimentalCoroutinesApi::class) +@file:OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class) package com.squareup.workflow1.ui.compose import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.State +import androidx.compose.runtime.currentComposer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.LocalSaveableStateRegistry import androidx.compose.runtime.saveable.SaveableStateRegistry import androidx.compose.runtime.setValue -import androidx.compose.ui.test.junit4.StateRestorationTester -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.google.common.truth.Truth.assertThat +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.StateRestorationTester +import androidx.compose.ui.test.runComposeUiTest import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.Workflow @@ -22,10 +22,7 @@ import com.squareup.workflow1.parse import com.squareup.workflow1.readUtf8WithLength import com.squareup.workflow1.rendering import com.squareup.workflow1.stateless -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.RenderAsStateTest.SnapshottingWorkflow.SnapshottedRendering -import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule -import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule +import com.squareup.workflow1.ui.compose.RenderAsStateTestIos.SnapshottingWorkflow.SnapshottedRendering import com.squareup.workflow1.writeUtf8WithLength import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -36,93 +33,83 @@ import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import leakcanary.DetectLeaksAfterTestSuccess import okio.ByteString import okio.ByteString.Companion.decodeBase64 -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain -import org.junit.runner.RunWith +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) -@OptIn(WorkflowUiExperimentalApi::class) -internal class RenderAsStateTest { +internal class RenderAsStateTestIos { - private val composeRule = createComposeRule() - - @get:Rule val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) - .around(IdleAfterTestRule) - .around(composeRule) - .around(IdlingDispatcherRule) - - @Test fun passesPropsThrough() { + @Test fun passesPropsThrough() = runComposeUiTest { val workflow = Workflow.stateless { it } lateinit var initialRendering: String - composeRule.setContent { + setContentWithLifecycle { initialRendering = workflow.renderAsState(props = "foo", onOutput = {}).value } - composeRule.runOnIdle { - assertThat(initialRendering).isEqualTo("foo") + runOnIdle { + assertEquals("foo", initialRendering) } } - @Test fun seesPropsAndRenderingUpdates() { + @Test fun seesPropsAndRenderingUpdates() = runComposeUiTest { val workflow = Workflow.stateless { it } val props = mutableStateOf("foo") lateinit var rendering: String - composeRule.setContent { + setContentWithLifecycle { rendering = workflow.renderAsState(props.value, onOutput = {}).value } - composeRule.runOnIdle { - assertThat(rendering).isEqualTo("foo") + runOnIdle { + assertEquals("foo", rendering) props.value = "bar" } - composeRule.runOnIdle { - assertThat(rendering).isEqualTo("bar") + runOnIdle { + assertEquals("bar", rendering) } } - @Test fun invokesOutputCallback() { + @Test fun invokesOutputCallback() = runComposeUiTest { val workflow = Workflow.stateless Unit> { - { - string -> + { string -> actionSink.send(action { setOutput(string) }) } } val receivedOutputs = mutableListOf() lateinit var rendering: (String) -> Unit - composeRule.setContent { + setContentWithLifecycle { rendering = workflow.renderAsState(props = Unit, onOutput = { receivedOutputs += it }).value } - composeRule.runOnIdle { - assertThat(receivedOutputs).isEmpty() + runOnIdle { + assertTrue { receivedOutputs.isEmpty() } rendering("one") } - composeRule.runOnIdle { - assertThat(receivedOutputs).isEqualTo(listOf("one")) + runOnIdle { + assertEquals(listOf("one"), receivedOutputs) rendering("two") } - composeRule.runOnIdle { - assertThat(receivedOutputs).isEqualTo(listOf("one", "two")) + runOnIdle { + assertEquals(listOf("one", "two"), receivedOutputs) } } - @Test fun savesSnapshot() { + @Test fun savesSnapshot() = runComposeUiTest { val workflow = SnapshottingWorkflow() val savedStateRegistry = SaveableStateRegistry(emptyMap()) { true } lateinit var rendering: SnapshottedRendering val scope = TestScope() - composeRule.setContent { + setContentWithLifecycle { CompositionLocalProvider(LocalSaveableStateRegistry provides savedStateRegistry) { rendering = renderAsState( workflow = workflow, @@ -135,15 +122,15 @@ internal class RenderAsStateTest { } } - composeRule.runOnIdle { - assertThat(rendering.string).isEmpty() + runOnIdle { + assertTrue { rendering.string.isEmpty() } rendering.updateString("foo") } // Move along the Workflow. scope.advanceUntilIdle() - composeRule.runOnIdle { + runOnIdle { val savedValues = savedStateRegistry.performSave() println("saved keys: ${savedValues.keys}") // Relying on the int key across all runtimes is brittle, so use an explicit key. @@ -151,18 +138,18 @@ internal class RenderAsStateTest { val snapshot = ByteString.of(*((savedValues.getValue(SNAPSHOT_KEY).single() as State).value)) println("snapshot: ${snapshot.base64()}") - assertThat(snapshot).isEqualTo(EXPECTED_SNAPSHOT) + assertEquals(EXPECTED_SNAPSHOT, snapshot) } } - @Test fun restoresSnapshot() { + @Test fun restoresSnapshot() = runComposeUiTest { val workflow = SnapshottingWorkflow() val restoreValues = mapOf(SNAPSHOT_KEY to listOf(mutableStateOf(EXPECTED_SNAPSHOT.toByteArray()))) val savedStateRegistry = SaveableStateRegistry(restoreValues) { true } lateinit var rendering: SnapshottedRendering - composeRule.setContent { + setContentWithLifecycle { CompositionLocalProvider(LocalSaveableStateRegistry provides savedStateRegistry) { rendering = renderAsState( workflow = workflow, @@ -175,13 +162,16 @@ internal class RenderAsStateTest { } } - composeRule.runOnIdle { - assertThat(rendering.string).isEqualTo("foo") + runOnIdle { + assertEquals("foo", rendering.string) } } - @Test fun savesAndRestoresSnapshotOnConfigChange() { - val stateRestorationTester = StateRestorationTester(composeRule) + // This test can't run because we can't provide a LocalSaveableStateRegistry in the test due to + // how StateRestorationTester is setup + @Ignore + @Test fun savesAndRestoresSnapshotOnConfigChange() = runComposeUiTest { + val stateRestorationTester = StateRestorationTester(this) val workflow = SnapshottingWorkflow() lateinit var rendering: SnapshottedRendering val scope = TestScope() @@ -193,24 +183,24 @@ internal class RenderAsStateTest { interceptors = emptyList(), onOutput = {}, ).value - } - composeRule.runOnIdle { - assertThat(rendering.string).isEmpty() - rendering.updateString("foo") - } + runOnIdle { + assertTrue { rendering.string.isEmpty() } + rendering.updateString("foo") + } - // Move along workflow before saving state! - scope.advanceUntilIdle() + // Move along workflow before saving state! + scope.advanceUntilIdle() - stateRestorationTester.emulateSavedInstanceStateRestore() + stateRestorationTester.emulateSaveAndRestore() - composeRule.runOnIdle { - assertThat(rendering.string).isEqualTo("foo") + runOnIdle { + assertEquals("foo", rendering.string) + } } } - @Test fun restoresFromSnapshotWhenWorkflowChanged() { + @Test fun restoresFromSnapshotWhenWorkflowChanged() = runComposeUiTest { val workflow1 = SnapshottingWorkflow() val workflow2 = SnapshottingWorkflow() val currentWorkflow = mutableStateOf(workflow1) @@ -222,19 +212,19 @@ internal class RenderAsStateTest { var compositionCount = 0 var lastCompositionCount = 0 fun assertWasRecomposed() { - assertThat(compositionCount).isGreaterThan(lastCompositionCount) + assertTrue { compositionCount > lastCompositionCount } lastCompositionCount = compositionCount } - composeRule.setContent { + setContentWithLifecycle { compositionCount++ rendering = currentWorkflow.value.renderAsState(props = Unit, onOutput = {}, scope = scope).value } // Initialize the first workflow. - composeRule.runOnIdle { - assertThat(rendering.string).isEmpty() + runOnIdle { + assertTrue { rendering.string.isEmpty() } assertWasRecomposed() rendering.updateString("one") } @@ -242,9 +232,9 @@ internal class RenderAsStateTest { // Move along the workflow. scope.advanceUntilIdle() - composeRule.runOnIdle { + runOnIdle { assertWasRecomposed() - assertThat(rendering.string).isEqualTo("one") + assertEquals("one", rendering.string) } // Change the workflow instance being rendered. This should restart the runtime, but restore @@ -253,27 +243,28 @@ internal class RenderAsStateTest { scope.advanceUntilIdle() - composeRule.runOnIdle { + runOnIdle { assertWasRecomposed() - assertThat(rendering.string).isEqualTo("one") + assertEquals("one", rendering.string) } } - @Test fun renderingIsAvailableImmediatelyWhenWorkflowScopeUsesDifferentDispatcher() { - val workflow = Workflow.rendering("hello") - val scope = TestScope() + @Test fun renderingIsAvailableImmediatelyWhenWorkflowScopeUsesDifferentDispatcher() = + runComposeUiTest { + val workflow = Workflow.rendering("hello") + val scope = TestScope() - composeRule.setContent { - val initialRendering = workflow.renderAsState( - props = Unit, - onOutput = {}, - scope = scope - ) - assertThat(initialRendering.value).isNotNull() + setContentWithLifecycle { + val initialRendering = workflow.renderAsState( + props = Unit, + onOutput = {}, + scope = scope + ) + assertTrue { initialRendering.value.isNotEmpty() } + } } - } - @Test fun runtimeIsCancelledWhenCompositionFails() { + @Test fun runtimeIsCancelledWhenCompositionFails() = runComposeUiTest { var innerJob: Job? = null val workflow = Workflow.stateless { runningSideEffect("test") { @@ -287,45 +278,45 @@ internal class RenderAsStateTest { scope.runTest { assertFailsWith { - composeRule.setContent { + setContentWithLifecycle { workflow.renderAsState(props = Unit, onOutput = {}, scope = scope) throw CancelCompositionException() } } - composeRule.runOnIdle { - assertThat(innerJob).isNotNull() - assertThat(innerJob!!.isCancelled).isTrue() + runOnIdle { + assertNotNull(innerJob) + assertTrue { innerJob!!.isCancelled } } } } - @Test fun workflowScopeIsNotCancelledWhenRemovedFromComposition() { + @Test fun workflowScopeIsNotCancelledWhenRemovedFromComposition() = runComposeUiTest { val workflow = Workflow.stateless {} val scope = TestScope() var shouldRunWorkflow by mutableStateOf(true) scope.runTest { - composeRule.setContent { + setContentWithLifecycle { if (shouldRunWorkflow) { workflow.renderAsState(props = Unit, onOutput = {}, scope = scope) } } - composeRule.runOnIdle { - assertThat(scope.isActive).isTrue() + runOnIdle { + assertTrue { scope.isActive } } shouldRunWorkflow = false - composeRule.runOnIdle { + runOnIdle { scope.advanceUntilIdle() - assertThat(scope.isActive).isTrue() + assertTrue { scope.isActive } } } } - @Test fun runtimeIsCancelledWhenRemovedFromComposition() { + @Test fun runtimeIsCancelledWhenRemovedFromComposition() = runComposeUiTest { var innerJob: Job? = null val workflow = Workflow.stateless { runningSideEffect("test") { @@ -335,21 +326,21 @@ internal class RenderAsStateTest { } var shouldRunWorkflow by mutableStateOf(true) - composeRule.setContent { + setContentWithLifecycle { if (shouldRunWorkflow) { workflow.renderAsState(props = Unit, onOutput = {}) } } - composeRule.runOnIdle { - assertThat(innerJob).isNotNull() - assertThat(innerJob!!.isActive).isTrue() + runOnIdle { + assertNotNull(innerJob) + assertTrue { innerJob!!.isActive } } shouldRunWorkflow = false - composeRule.runOnIdle { - assertThat(innerJob!!.isCancelled).isTrue() + runOnIdle { + assertTrue { innerJob!!.isCancelled } } } diff --git a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTestIos.kt similarity index 50% rename from workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt rename to workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTestIos.kt index 8895a3bb7..1a2a3477d 100644 --- a/workflow-ui/compose-multiplatform/src/androidInstrumentedTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTest.kt +++ b/workflow-ui/compose/src/iosTest/kotlin/com/squareup/workflow1/ui/compose/WorkflowRenderingTestIos.kt @@ -1,7 +1,8 @@ +@file:Suppress("TestFunctionName") +@file:OptIn(ExperimentalTestApi::class) + package com.squareup.workflow1.ui.compose -import android.view.View -import android.widget.TextView import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -10,25 +11,23 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertHeightIsEqualTo import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.assertWidthIsEqualTo -import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.Event @@ -47,53 +46,20 @@ import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText -import com.google.common.truth.Truth.assertThat -import com.squareup.workflow1.ui.AndroidScreen import com.squareup.workflow1.ui.Compatible -import com.squareup.workflow1.ui.NamedScreen import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewHolder import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewEnvironmentKey import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.ComposeScreen -import com.squareup.workflow1.ui.compose.ScreenComposableFactory -import com.squareup.workflow1.ui.compose.ScreenComposableFactoryFinder -import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.compose.withCompositionRoot -import com.squareup.workflow1.ui.internal.test.IdleAfterTestRule -import com.squareup.workflow1.ui.internal.test.IdlingDispatcherRule import com.squareup.workflow1.ui.plus -import com.squareup.workflow1.ui.withEnvironment -import leakcanary.DetectLeaksAfterTestSuccess -import org.hamcrest.Description -import org.hamcrest.TypeSafeMatcher -import org.junit.Rule -import org.junit.Test -import org.junit.rules.RuleChain import kotlin.reflect.KClass +import kotlin.test.Test +import kotlin.test.assertEquals @OptIn(WorkflowUiExperimentalApi::class) -class WorkflowRenderingTest { - - private val composeRule = createComposeRule() - - @get:Rule - val rules: RuleChain = RuleChain.outerRule(DetectLeaksAfterTestSuccess()) - .around(IdleAfterTestRule) - .around(composeRule) - .around(IdlingDispatcherRule) +internal class WorkflowRenderingTestIos { - @Test fun doesNotRecompose_whenFactoryChanged() { + @Test fun doesNotRecompose_whenFactoryChanged() = runComposeUiTest { data class TestRendering( val text: String ) : Screen @@ -110,17 +76,17 @@ class WorkflowRenderingTest { ) val registry = mutableStateOf(registry1) - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering(TestRendering("hello"), ViewEnvironment.EMPTY + registry.value) } - composeRule.onNodeWithText("hello").assertIsDisplayed() + onNodeWithText("hello").assertIsDisplayed() registry.value = registry2 - composeRule.onNodeWithText("hello").assertIsDisplayed() - composeRule.onNodeWithText("olleh").assertDoesNotExist() + onNodeWithText("hello").assertIsDisplayed() + onNodeWithText("olleh").assertDoesNotExist() } - @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() { + @Test fun wrapsFactoryWithRoot_whenAlreadyInComposition() = runComposeUiTest { data class TestRendering(val text: String) : Screen val testFactory = ScreenComposableFactory { rendering, _ -> @@ -134,86 +100,15 @@ class WorkflowRenderingTest { } } - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering(TestRendering("two"), viewEnvironment) } - composeRule.onNodeWithText("one").assertIsDisplayed() - composeRule.onNodeWithText("two").assertIsDisplayed() - } - - @Test fun legacyAndroidViewRendersUpdates() { - val wrapperText = mutableStateOf("two") - - composeRule.setContent { - WorkflowRendering(LegacyViewRendering(wrapperText.value), env) - } - - onView(withText("two")).check(matches(isDisplayed())) - wrapperText.value = "OWT" - onView(withText("OWT")).check(matches(isDisplayed())) - } - - // https://github.com/square/workflow-kotlin/issues/538 - @Test fun includesSupportForNamed() { - val wrapperText = mutableStateOf("two") - - composeRule.setContent { - val rendering = NamedScreen(LegacyViewRendering(wrapperText.value), "fnord") - WorkflowRendering(rendering, env) - } - - onView(withText("two")).check(matches(isDisplayed())) - wrapperText.value = "OWT" - onView(withText("OWT")).check(matches(isDisplayed())) - } - - @Test fun namedScreenStaysInTheSameComposeView() { - composeRule.setContent { - val outer = LocalView.current - - WorkflowRendering( - viewEnvironment = env, - rendering = NamedScreen( - name = "fnord", - content = ComposeScreen { - val inner = LocalView.current - assertThat(inner).isSameInstanceAs(outer) - - BasicText("hello", Modifier.testTag("tag")) - } - ) - ) - } - - composeRule.onNodeWithTag("tag") - .assertTextEquals("hello") - } - - @Test fun environmentScreenStaysInTheSameComposeView() { - val someKey = object : ViewEnvironmentKey() { - override val default = "default" - } - - composeRule.setContent { - val outer = LocalView.current - - WorkflowRendering( - viewEnvironment = env, - rendering = ComposeScreen { environment -> - val inner = LocalView.current - assertThat(inner).isSameInstanceAs(outer) - - BasicText(environment[someKey], Modifier.testTag("tag")) - }.withEnvironment((someKey to "fnord")) - ) - } - - composeRule.onNodeWithTag("tag") - .assertTextEquals("fnord") + onNodeWithText("one").assertIsDisplayed() + onNodeWithText("two").assertIsDisplayed() } - @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() { + @Test fun destroysChildLifecycle_fromCompose_whenIncompatibleRendering() = runComposeUiTest { val lifecycleEvents = mutableListOf() class LifecycleRecorder : ComposableRendering { @@ -238,65 +133,23 @@ class WorkflowRenderingTest { } var rendering: Screen by mutableStateOf(LifecycleRecorder()) - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering(rendering, env) } - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() + runOnIdle { + assertEquals(listOf(ON_CREATE, ON_START, ON_RESUME), lifecycleEvents) lifecycleEvents.clear() } rendering = EmptyRendering() - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() + runOnIdle { + assertEquals(listOf(ON_PAUSE, ON_STOP, ON_DESTROY), lifecycleEvents) } } - @Test fun destroysChildLifecycle_fromLegacyView_whenIncompatibleRendering() { - val lifecycleEvents = mutableListOf() - - class LifecycleRecorder : AndroidScreen { - override val viewFactory = - ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> - val view = object : View(context) { - override fun onAttachedToWindow() { - super.onAttachedToWindow() - val lifecycle = this.findViewTreeLifecycleOwner()!!.lifecycle - lifecycle.addObserver( - LifecycleEventObserver { _, event -> lifecycleEvents += event } - ) - // Yes, we're leaking the observer. That's intentional: we need to make sure we see - // any lifecycle events that happen even after the composable is destroyed. - } - } - ScreenViewHolder(initialEnvironment, view) { _, _ -> } - } - } - - class EmptyRendering : ComposableRendering { - @Composable override fun Content(viewEnvironment: ViewEnvironment) {} - } - - var rendering: Screen by mutableStateOf(LifecycleRecorder()) - composeRule.setContent { - WorkflowRendering(rendering, env) - } - - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_CREATE, ON_START, ON_RESUME).inOrder() - lifecycleEvents.clear() - } - - rendering = EmptyRendering() - - composeRule.runOnIdle { - assertThat(lifecycleEvents).containsExactly(ON_PAUSE, ON_STOP, ON_DESTROY).inOrder() - } - } - - @Test fun followsParentLifecycle() { + @Test fun followsParentLifecycle() = runComposeUiTest { val states = mutableListOf() val parentOwner = object : LifecycleOwner { val registry = LifecycleRegistry(this) @@ -304,66 +157,62 @@ class WorkflowRenderingTest { get() = registry } - composeRule.setContent { - CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { - WorkflowRendering(LifecycleRecorder(states), env) - } + setContentWithLifecycle(parentOwner) { + WorkflowRendering(LifecycleRecorder(states), env) } - composeRule.runOnIdle { - assertThat(states).containsExactly(INITIALIZED).inOrder() + runOnIdle { + assertEquals(listOf(INITIALIZED), states) states.clear() parentOwner.registry.currentState = STARTED } - composeRule.runOnIdle { - assertThat(states).containsExactly(CREATED, STARTED).inOrder() + runOnIdle { + assertEquals(listOf(CREATED, STARTED), states) states.clear() parentOwner.registry.currentState = CREATED } - composeRule.runOnIdle { - assertThat(states).containsExactly(CREATED).inOrder() + runOnIdle { + assertEquals(listOf(CREATED), states) states.clear() parentOwner.registry.currentState = RESUMED } - composeRule.runOnIdle { - assertThat(states).containsExactly(STARTED, RESUMED).inOrder() + runOnIdle { + assertEquals(listOf(STARTED, RESUMED), states) states.clear() parentOwner.registry.currentState = DESTROYED } - composeRule.runOnIdle { - assertThat(states).containsExactly(STARTED, CREATED, DESTROYED).inOrder() + runOnIdle { + assertEquals(listOf(STARTED, CREATED, DESTROYED), states) } } - @Test fun handlesParentInitiallyDestroyed() { + @Test fun handlesParentInitiallyDestroyed() = runComposeUiTest { val states = mutableListOf() val parentOwner = object : LifecycleOwner { val registry = LifecycleRegistry(this) override val lifecycle: Lifecycle get() = registry } - composeRule.runOnIdle { + runOnIdle { // Cannot go directly to DESTROYED parentOwner.registry.currentState = CREATED parentOwner.registry.currentState = DESTROYED } - composeRule.setContent { - CompositionLocalProvider(LocalLifecycleOwner provides parentOwner) { - WorkflowRendering(LifecycleRecorder(states), env) - } + setContentWithLifecycle(parentOwner) { + WorkflowRendering(LifecycleRecorder(states), env) } - composeRule.runOnIdle { - assertThat(states).containsExactly(INITIALIZED).inOrder() + runOnIdle { + assertEquals(listOf(INITIALIZED), states) } } - @Test fun appliesModifierToComposableContent() { + @Test fun appliesModifierToComposableContent() = runComposeUiTest { class Rendering : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { Box( @@ -374,7 +223,7 @@ class WorkflowRenderingTest { } } - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering( Rendering(), env, @@ -382,19 +231,19 @@ class WorkflowRenderingTest { ) } - composeRule.onNodeWithTag("box") + onNodeWithTag("box") .assertWidthIsEqualTo(42.dp) .assertHeightIsEqualTo(43.dp) } - @Test fun propagatesMinConstraints() { + @Test fun propagatesMinConstraints() = runComposeUiTest { class Rendering : ComposableRendering { @Composable override fun Content(viewEnvironment: ViewEnvironment) { Box(Modifier.testTag("box")) } } - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering( Rendering(), env, @@ -402,38 +251,12 @@ class WorkflowRenderingTest { ) } - composeRule.onNodeWithTag("box") + onNodeWithTag("box") .assertWidthIsEqualTo(42.dp) .assertHeightIsEqualTo(43.dp) } - @Test fun appliesModifierToViewContent() { - val viewId = View.generateViewId() - - class LegacyRendering(private val viewId: Int) : AndroidScreen { - override val viewFactory = - ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> - val view = View(context) - ScreenViewHolder(initialEnvironment, view) { rendering, _ -> - view.id = rendering.viewId - } - } - } - - composeRule.setContent { - with(LocalDensity.current) { - WorkflowRendering( - LegacyRendering(viewId), - env, - Modifier.size(42.toDp(), 43.toDp()) - ) - } - } - - onView(withId(viewId)).check(matches(hasSize(42, 43))) - } - - @Test fun skipsPreviousContentWhenIncompatible() { + @Test fun skipsPreviousContentWhenIncompatible() = runComposeUiTest { var disposeCount = 0 class Rendering( @@ -458,34 +281,34 @@ class WorkflowRenderingTest { } var key by mutableStateOf("one") - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering(Rendering(key), env) } - composeRule.onNodeWithTag("tag") + onNodeWithTag("tag") .assertTextEquals("one: 0") .performClick() .assertTextEquals("one: 1") key = "two" - composeRule.onNodeWithTag("tag") + onNodeWithTag("tag") .assertTextEquals("two: 0") - composeRule.runOnIdle { - assertThat(disposeCount).isEqualTo(1) + runOnIdle { + assertEquals(1, disposeCount) } key = "one" // State should not be restored. - composeRule.onNodeWithTag("tag") + onNodeWithTag("tag") .assertTextEquals("one: 0") - composeRule.runOnIdle { - assertThat(disposeCount).isEqualTo(2) + runOnIdle { + assertEquals(2, disposeCount) } } - @Test fun doesNotSkipPreviousContentWhenCompatible() { + @Test fun doesNotSkipPreviousContentWhenCompatible() = runComposeUiTest { var disposeCount = 0 class Rendering(val text: String) : ComposableRendering { @@ -508,11 +331,11 @@ class WorkflowRenderingTest { } var text by mutableStateOf("one") - composeRule.setContent { + setContentWithLifecycle { WorkflowRendering(Rendering(text), env) } - composeRule.onNodeWithTag("tag") + onNodeWithTag("tag") .assertTextEquals("one: 0") .performClick() .assertTextEquals("one: 1") @@ -520,24 +343,10 @@ class WorkflowRenderingTest { text = "two" // Counter state should be preserved. - composeRule.onNodeWithTag("tag") + onNodeWithTag("tag") .assertTextEquals("two: 1") - composeRule.runOnIdle { - assertThat(disposeCount).isEqualTo(0) - } - } - - @Suppress("SameParameterValue") - private fun hasSize( - width: Int, - height: Int - ) = object : TypeSafeMatcher() { - override fun describeTo(description: Description) { - description.appendText("has size ${width}x${height}px") - } - - override fun matchesSafely(item: View): Boolean { - return item.width == width && item.height == height + runOnIdle { + assertEquals(0, disposeCount) } } @@ -591,21 +400,13 @@ class WorkflowRenderingTest { } } + + private val env = (ViewEnvironment.EMPTY + (ScreenComposableFactoryFinder to InefficientComposableFinder)) - .withComposeInteropSupport() private interface ComposableRendering : Screen { @Composable fun Content(viewEnvironment: ViewEnvironment) } - private data class LegacyViewRendering(val text: String) : AndroidScreen { - override val viewFactory = - ScreenViewFactory.fromCode { _, initialEnvironment, context, _ -> - val view = TextView(context) - ScreenViewHolder(initialEnvironment, view) { rendering, _ -> - view.text = rendering.text - } - } - } } diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt deleted file mode 100644 index 06a00f645..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ComposeScreen.kt +++ /dev/null @@ -1,98 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import androidx.compose.runtime.Composable -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi - -/** - * Interface implemented by a rendering class to allow it to drive a composable UI via an - * appropriate [ScreenComposableFactory] implementation, by simply overriding the [Content] method. - * - * Note that it is generally an error for a [Workflow][com.squareup.workflow1.Workflow] - * to declare [ComposeScreen] as its `RenderingT` type -- prefer [Screen] for that. - * [ComposeScreen], like [AndroidScreen][com.squareup.workflow1.ui.AndroidScreen], - * is strictly a possible implementation detail of [Screen]. It is a convenience to - * minimize the boilerplate required to set up a [ScreenComposableFactory]. - * That interface is the fundamental unit of Compose tooling for Workflow UI. - * But in day to day use, most developer will work with [ComposeScreen] and be only - * vaguely aware of the existence of [ScreenComposableFactory], - * so the bulk of our description of working with Compose is here. - * - * **NB**: A Workflow app that relies on Compose must call [withComposeInteropSupport] - * on its top-level [ViewEnvironment]. See that function for details. - * - * Note that unlike most workflow view functions, [Content] does not take the rendering as a - * parameter. Instead, the rendering is the receiver, i.e. the current value of `this`. - * - * Example: - * - * @OptIn(WorkflowUiExperimentalApi::class) - * data class HelloScreen( - * val message: String, - * val onClick: () -> Unit - * ) : ComposeScreen { - * - * @Composable override fun Content(viewEnvironment: ViewEnvironment) { - * Button(onClick) { - * Text(message) - * } - * } - * } - * - * This is the simplest way to bridge the gap between your workflows and the UI, but using it - * requires your workflows code to reside in Android modules and depend upon the Compose runtime, - * instead of being pure Kotlin. If this is a problem, or you need more flexibility for any other - * reason, you can use [ViewRegistry] to bind your renderings to [ScreenComposableFactory] - * implementations at runtime. - * - * ## Nesting child renderings - * - * Workflows can render other workflows, and renderings from one workflow can contain renderings - * from other workflows. These renderings may all be bound to their own UI factories. - * A classic [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory] can - * use [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] to recursively show nested - * renderings. - * - * Compose-based UI may also show nested renderings. Doing so is as simple - * as calling [WorkflowRendering] and passing in the nested rendering. - * See the kdoc on that function for an example. - * - * Nested renderings will have access to any - * [composition locals][androidx.compose.runtime.CompositionLocal] defined in outer composable, even - * if there are legacy views in between them, as long as the [ViewEnvironment] is propagated - * continuously between the two factories. - * - * ## Initializing Compose context (Theming) - * - * Often all the [ScreenComposableFactory] factories in an app need to share some context – - * for example, certain composition locals need to be provided, such as `MaterialTheme`. - * To configure this shared context, call [withCompositionRoot] on your top-level [ViewEnvironment]. - * The first time a [ScreenComposableFactory] is used to show a rendering, its [Content] function - * will be wrapped with the [CompositionRoot]. See the documentation on [CompositionRoot] for - * more information. - */ -@WorkflowUiExperimentalApi -public interface ComposeScreen : Screen { - - /** - * The composable content of this rendering. This method will be called with the current rendering - * instance as the receiver, any time a new rendering is emitted, or the [viewEnvironment] - * changes. - */ - @Composable public fun Content(viewEnvironment: ViewEnvironment) -} - -/** - * Convenience function for creating anonymous [ComposeScreen]s since composable fun interfaces - * aren't supported. See the [ComposeScreen] class for more information. - */ -@WorkflowUiExperimentalApi -public inline fun ComposeScreen( - crossinline content: @Composable (ViewEnvironment) -> Unit -): ComposeScreen = object : ComposeScreen { - @Composable override fun Content(viewEnvironment: ViewEnvironment) { - content(viewEnvironment) - } -} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt deleted file mode 100644 index d91019f11..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/RenderAsState.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.RememberObserver -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.LocalSaveableStateRegistry -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.saveable.rememberSaveable -import com.squareup.workflow1.RuntimeConfig -import com.squareup.workflow1.RuntimeConfigOptions -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.TreeSnapshot -import com.squareup.workflow1.Workflow -import com.squareup.workflow1.WorkflowInterceptor -import com.squareup.workflow1.renderWorkflowIn -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart.UNDISPATCHED -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import okio.ByteString - -/** - * Runs this [Workflow] as long as this composable is part of the composition, and returns a - * [State] object that will be updated whenever the runtime emits a new [RenderingT]. Note that - * here, and in the rest of the documentation for this class, the "`State`" type refers to Compose's - * snapshot [State] type, _not_ the concept of the `StateT` type in a particular workflow. - * - * The workflow runtime will be started when this function is first added to the composition, and - * cancelled when it is removed or if the composition fails. The first rendering will be available - * immediately as soon as this function returns as [State.value]. Composables that read this value - * will automatically recompose whenever the runtime emits a new rendering. If you are driving UI - * from the Workflow tree managed by [renderAsState] then you will probably want to pass the - * returned [State]'s value (which is the Workflow rendering) to the [WorkflowRendering] composable. - * - * [Snapshot]s from the runtime will automatically be saved and restored using Compose's - * [rememberSaveable]. - * - * ## Example - * - * ``` - * private val appViewRegistry = ViewRegistry(…) - * - * @Composable fun App(workflow: Workflow<…>, props: Props) { - * val scaffoldState = … - * - * // Run the workflow in the current composition's coroutine scope. - * val rendering by workflow.renderAsState(props, onOutput = { output -> - * // Note that onOutput is a suspend function, so you can run animations - * // and call other suspend functions. - * scaffoldState.snackbarHostState - * .showSnackbar(output.toString()) - * }) - * val viewEnvironment = remember { - * ViewEnvironment(mapOf(ViewRegistry to appViewRegistry)) - * } - * - * Scaffold(…) { padding -> - * // Display the root rendering using the view environment's ViewRegistry. - * WorkflowRendering(rendering, viewEnvironment, Modifier.padding(padding)) - * } - * } - * ``` - * - * ## Caveat on threading and composition - * - * Note that the initial render pass will occur on whatever thread this function is called from. - * That may be a background thread, as Compose supports performing composition on background - * threads. Well-behaved workflows should have pure `initialState` and `render` functions, so this - * should not be a problem. Any side effects performed by workflows using the `runningSideEffect` - * method or Workers will be executed in [scope] as usual. - * - * Also note that composition is an operation that may fail, or be cancelled, and the "result" - * of a given composition pass may be thrown away and never used to update UI. When this happens, - * the composition is said to have failed to commit. If the composition that initializes a workflow - * runtime using this function fails to commit, the runtime will be started and then immediately - * cancelled. Since the workflow runtime may perform side effects, this may cause effects that look - * like they spontaneously occur, or happen more often than they should. - * - * @receiver The [Workflow] to run. If the value of the receiver changes to a different [Workflow] - * while this function is in the composition, the runtime will be restarted with the new workflow. - * @param props The [PropsT] for the root [Workflow]. Changes to this value across different - * compositions will cause the root workflow to re-render with the new props. - * @param interceptors - * An optional list of [WorkflowInterceptor]s that will wrap every workflow rendered by the runtime. - * Interceptors will be invoked in 0-to-`length` order: the interceptor at index 0 will process the - * workflow first, then the interceptor at index 1, etc. - * @param scope - * The [CoroutineScope] in which to launch the workflow runtime. If not specified, the value of - * [rememberCoroutineScope] will be used. Any exceptions thrown in any workflows, after the initial - * render pass, will be handled by this scope, and cancelling this scope will cancel the workflow - * runtime and any running workers. Note that any dispatcher in this scope will _not_ be used to - * execute the very first render pass. - * @param runtimeConfig - * The [RuntimeConfig] for the Workflow runtime started to power this state. - * @param onOutput A function that will be executed whenever the root [Workflow] emits an output. - */ -@Composable -public fun Workflow.renderAsState( - props: PropsT, - interceptors: List = emptyList(), - scope: CoroutineScope = rememberCoroutineScope(), - runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, - onOutput: suspend (OutputT) -> Unit -): State = renderAsState(this, scope, props, interceptors, runtimeConfig, onOutput) - -/** - * @param snapshotKey Allows tests to pass in a custom key to use to save/restore the snapshot from - * the [LocalSaveableStateRegistry]. If null, will use the default key based on source location. - */ -@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) -@Composable -internal fun renderAsState( - workflow: Workflow, - scope: CoroutineScope, - props: PropsT, - interceptors: List, - runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, - onOutput: suspend (OutputT) -> Unit, - snapshotKey: String? = null -): State { - val snapshotState = rememberSaveable(key = snapshotKey, stateSaver = TreeSnapshotSaver) { - mutableStateOf(null) - } - val updatedOnOutput by rememberUpdatedState(onOutput) - - // We can't use DisposableEffect because it won't run until the composition is successfully - // committed, which will be after this function returns, and we need to run this immediately so we - // get the rendering synchronously. The thread running this composition might also not be the - // main thread or whatever thread the workflow context is configured to run on, but that should - // be fine as long as the workflows are correctly performing side effects in effects and not their - // render or related methods. - // The WorkflowState object remembered here is a RememberObserver – it will automatically cancel - // the workflow runtime when it leaves the composition or if the composition doesn't commit. - // The remember is keyed on any values that we can't update the runtime with dynamically, and - // therefore require completely restarting the runtime to take effect. - val state = remember(workflow, scope, interceptors) { - WorkflowRuntimeState( - workflowScope = scope, - initialProps = props, - snapshotState = snapshotState, - runtimeConfig = runtimeConfig, - onOutput = { updatedOnOutput(it) } - ).apply { - start(workflow, interceptors) - } - } - - // Use a side effect to update props so that it waits for the composition to commit. - SideEffect { - state.setProps(props) - } - - return state.rendering -} - -/** - * State hoisted out of [renderAsState]. - */ -private class WorkflowRuntimeState( - workflowScope: CoroutineScope, - initialProps: PropsT, - private val snapshotState: MutableState, - private val runtimeConfig: RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG, - private val onOutput: suspend (OutputT) -> Unit, -) : RememberObserver { - - private val renderingState = mutableStateOf(null) - private val propsFlow = MutableStateFlow(initialProps) - - /** - * The actual scope used to run the workflow. It has a child [Job] of the incoming scope so - * we can cancel the runtime without cancelling the incoming scope. - */ - private val workflowScope = workflowScope + Job(parent = workflowScope.coroutineContext[Job]) - - // The value is guaranteed to be set before returning, so this cast is fine. - @Suppress("UNCHECKED_CAST") - val rendering: State - get() = renderingState as State - - fun start( - workflow: Workflow, - interceptors: List - ) { - val renderings = renderWorkflowIn( - workflow = workflow, - scope = workflowScope, - props = propsFlow, - initialSnapshot = snapshotState.value, - interceptors = interceptors, - runtimeConfig = runtimeConfig, - onOutput = onOutput - ) - - workflowScope.launch( - start = UNDISPATCHED, - context = Dispatchers.Unconfined - ) { - // We collect the renderings in the workflowScope to participate in structured concurrency, - // however we don't need to use its dispatcher – this collector is simply setting snapshot - // state values, which is thread safe. - // Also, if the scope uses a non-immediate dispatcher, the initial states won't get set until - // the dispatcher dispatches the collection coroutine, but our contract requires them to be - // set by the time this function returns and using the Unconfined dispatcher along with - // launching this coroutine as CoroutineStart.UNDISPATCHED guarantees that. - - renderings.collect { - renderingState.value = it.rendering - snapshotState.value = it.snapshot - } - } - } - - fun setProps(props: PropsT) { - propsFlow.value = props - } - - override fun onAbandoned() { - workflowScope.cancel() - } - - override fun onRemembered() {} - - override fun onForgotten() { - workflowScope.cancel() - } -} - -private object TreeSnapshotSaver : Saver { - override fun SaverScope.save(value: TreeSnapshot?): ByteArray { - return value?.toByteString()?.toByteArray() ?: ByteArray(0) - } - - override fun restore(value: ByteArray): TreeSnapshot? { - return value.takeUnless { it.isEmpty() } - ?.let { bytes -> TreeSnapshot.parse(ByteString.of(*bytes)) } - } -} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt deleted file mode 100644 index b8a6e6b20..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactory.kt +++ /dev/null @@ -1,234 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import android.content.Context -import android.view.ViewGroup -import androidx.activity.OnBackPressedDispatcherOwner -import androidx.activity.compose.LocalOnBackPressedDispatcherOwner -import androidx.activity.setViewTreeOnBackPressedDispatcherOwner -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.setViewTreeLifecycleOwner -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewHolder -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.ViewRegistry.Key -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.androidx.OnBackPressedDispatcherOwnerKey -import com.squareup.workflow1.ui.show -import com.squareup.workflow1.ui.startShowing -import kotlin.reflect.KClass - -@WorkflowUiExperimentalApi -public inline fun ScreenComposableFactory( - noinline content: @Composable ( - rendering: ScreenT, - environment: ViewEnvironment - ) -> Unit -): ScreenComposableFactory = ScreenComposableFactory(ScreenT::class, content) - -@PublishedApi -@WorkflowUiExperimentalApi -internal fun ScreenComposableFactory( - type: KClass, - content: @Composable ( - rendering: ScreenT, - environment: ViewEnvironment - ) -> Unit -): ScreenComposableFactory = object : ScreenComposableFactory { - override val type: KClass = type - - @Composable override fun Content( - rendering: ScreenT, - environment: ViewEnvironment - ) { - content(rendering, environment) - } -} - -/** - * A [ViewRegistry.Entry] that uses a [Composable] function to display [ScreenT]. - * This is the fundamental unit of Compose tooling in Workflow UI, the Compose analogue of - * [ScreenViewFactory][com.squareup.workflow1.ui.ScreenViewFactory]. - * - * [ScreenComposableFactory] is also a bit cumbersome to use directly, - * so [ComposeScreen] is provided as a convenience. Most developers will - * have no reason to work with [ScreenComposableFactory] directly, or even - * be aware of it. - * - * - See [ComposeScreen] for a more complete description of using Compose to - * build a Workflow-based UI. - * - * - See [WorkflowRendering] to display a nested [Screen] from [ComposeScreen.Content] - * or from [ScreenComposableFactory.Content] - * - * Use [ScreenComposableFactory] directly if you need to prevent your - * [Screen] rendering classes from depending on Compose at compile time. - * - * Example: - * - * val fooComposableFactory = ScreenComposableFactory { screen, _ -> - * Text(screen.message) - * } - * - * val viewRegistry = ViewRegistry(fooComposableFactory, …) - * val viewEnvironment = ViewEnvironment.EMPTY + viewRegistry - * - * renderWorkflowIn( - * workflow = MyWorkflow.mapRendering { it.withEnvironment(viewEnvironment) } - * ) - */ -@WorkflowUiExperimentalApi -public interface ScreenComposableFactory : ViewRegistry.Entry { - public val type: KClass - - override val key: Key> - get() = Key(type, ScreenComposableFactory::class) - - /** - * The composable content of this [ScreenComposableFactory]. This method will be called - * any time [rendering] or [environment] change. It is the Compose-based analogue of - * [ScreenViewRunner.showRendering][com.squareup.workflow1.ui.ScreenViewRunner.showRendering]. - */ - @Composable public fun Content( - rendering: ScreenT, - environment: ViewEnvironment - ) -} - -/** - * It is rare to call this method directly. Instead the most common path is to pass [Screen] - * instances to [WorkflowRendering], which will apply the [ScreenComposableFactory] - * and [ScreenComposableFactoryFinder] machinery for you. - */ -@WorkflowUiExperimentalApi -public fun ScreenT.toComposableFactory( - environment: ViewEnvironment -): ScreenComposableFactory { - return environment[ScreenComposableFactoryFinder] - .requireComposableFactoryForRendering(environment, this) -} - -/** - * Convert a [ScreenComposableFactory] into a [ScreenViewFactory] - * by using a [ComposeView] to host [ScreenComposableFactory.Content]. - * - * It is unusual to use this function directly, it is mainly an implementation detail - * of [ViewEnvironment.withComposeInteropSupport]. - */ -@WorkflowUiExperimentalApi -public fun ScreenComposableFactory.asViewFactory(): - ScreenViewFactory { - - return object : ScreenViewFactory { - override val type = this@asViewFactory.type - - override fun buildView( - initialRendering: ScreenT, - initialEnvironment: ViewEnvironment, - context: Context, - container: ViewGroup? - ): ScreenViewHolder { - val view = ComposeView(context) - return ScreenViewHolder(initialEnvironment, view) { newRendering, environment -> - // Update the state whenever a new rendering is emitted. - // This lambda will be executed synchronously before ScreenViewHolder.show returns. - view.setContent { Content(newRendering, environment) } - } - } - } -} - -/** - * Convert a [ScreenViewFactory] to a [ScreenComposableFactory], - * using [AndroidView] to host the `View` it builds. - * - * It is unusual to use this function directly, it is mainly an implementation detail - * of [ViewEnvironment.withComposeInteropSupport]. - */ -@WorkflowUiExperimentalApi -public fun ScreenViewFactory.asComposableFactory(): - ScreenComposableFactory { - return object : ScreenComposableFactory { - private val viewFactory = this@asComposableFactory - - override val type: KClass get() = viewFactory.type - - /** - * This is effectively the logic of `WorkflowViewStub`, but translated into Compose idioms. - * This approach has a few advantages: - * - * - Avoids extra custom views required to host `WorkflowViewStub` inside a Composition. Its trick - * of replacing itself in its parent doesn't play nicely with Compose. - * - Allows us to pass the correct parent view for inflation (the root of the composition). - * - Avoids `WorkflowViewStub` having to do its own lookup to find the correct - * [ScreenViewFactory], since we already have the correct one. - * - Propagate the current `LifecycleOwner` from [LocalLifecycleOwner] by setting it as the - * [ViewTreeLifecycleOwner] on the view. - * - Propagate the current [OnBackPressedDispatcherOwner] from either - * [LocalOnBackPressedDispatcherOwner] or the [viewEnvironment], - * both on the [AndroidView] via [setViewTreeOnBackPressedDispatcherOwner], - * and in the [ViewEnvironment] for use by any nested [WorkflowViewStub] - * - * Like `WorkflowViewStub`, this function uses the [viewFactory] to create and memoize a - * `View` to display the [rendering], keeps it updated with the latest [rendering] and - * [environment], and adds it to the composition. - */ - @Composable override fun Content( - rendering: ScreenT, - environment: ViewEnvironment - ) { - val lifecycleOwner = LocalLifecycleOwner.current - - // Make sure any nested WorkflowViewStub will be able to propagate the - // OnBackPressedDispatcherOwner, if we found one. No need to fail fast here. - // It's only an issue if someone tries to use it, and the error message - // at those call sites should be clear enough. - val onBackOrNull = LocalOnBackPressedDispatcherOwner.current - ?: environment.map[OnBackPressedDispatcherOwnerKey] as? OnBackPressedDispatcherOwner - - val envWithOnBack = onBackOrNull - ?.let { environment + (OnBackPressedDispatcherOwnerKey to it) } - ?: environment - - AndroidView( - factory = { context -> - - // We pass in a null container because the container isn't a View, it's a composable. The - // compose machinery will generate an intermediate view that it ends up adding this to but - // we don't have access to that. - viewFactory - .startShowing(rendering, envWithOnBack, context, container = null) - .let { viewHolder -> - // Put the viewHolder in a tag so that we can find it in the update lambda, below. - viewHolder.view.setTag(R.id.workflow_screen_view_holder, viewHolder) - - // Unfortunately AndroidView doesn't propagate these itself. - viewHolder.view.setViewTreeLifecycleOwner(lifecycleOwner) - onBackOrNull?.let { - viewHolder.view.setViewTreeOnBackPressedDispatcherOwner(it) - } - - // We don't propagate the (non-compose) SavedStateRegistryOwner, or the (compose) - // SaveableStateRegistry, because currently all our navigation is implemented as - // Android views, which ensures there is always an Android view between any state - // registry and any Android view shown as a child of it, even if there's a compose - // view in between. - viewHolder.view - } - }, - // This function will be invoked every time this composable is recomposed, which means that - // any time a new rendering or view environment are passed in we'll send them to the view. - update = { view -> - @Suppress("UNCHECKED_CAST") - val viewHolder = - view.getTag(R.id.workflow_screen_view_holder) as ScreenViewHolder - viewHolder.show(rendering, envWithOnBack) - } - ) - } - } -} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt deleted file mode 100644 index 3466c48bb..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import com.squareup.workflow1.ui.EnvironmentScreen -import com.squareup.workflow1.ui.NamedScreen -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewEnvironmentKey -import com.squareup.workflow1.ui.ViewRegistry -import com.squareup.workflow1.ui.ViewRegistry.Key -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.getFactoryFor - -@WorkflowUiExperimentalApi -public interface ScreenComposableFactoryFinder { - public fun getComposableFactoryForRendering( - environment: ViewEnvironment, - rendering: ScreenT - ): ScreenComposableFactory? { - val factoryOrNull: ScreenComposableFactory? = - environment[ViewRegistry].getFactoryFor(rendering) - - @Suppress("UNCHECKED_CAST") - return factoryOrNull - ?: (rendering as? ComposeScreen)?.let { - ScreenComposableFactory { rendering, environment -> - rendering.Content(environment) - } as ScreenComposableFactory - } - - // Support for Compose BackStackScreen, BodyAndOverlaysScreen treatments would go here, - // if it were planned. See similar blocks in ScreenViewFactoryFinder - - ?: (rendering as? NamedScreen<*>)?.let { - ScreenComposableFactory> { rendering, environment -> - val innerFactory = rendering.content.toComposableFactory(environment) - innerFactory.Content(rendering.content, environment) - // WorkflowRendering(rendering.content, environment) - } as ScreenComposableFactory - } - ?: (rendering as? EnvironmentScreen<*>)?.let { - ScreenComposableFactory> { rendering, environment -> - val comboEnv = environment + rendering.environment - val innerFactory = rendering.content.toComposableFactory(comboEnv) - innerFactory.Content(rendering.content, comboEnv) - // WorkflowRendering(rendering.content, comboEnv) - } as ScreenComposableFactory - } - } - - public companion object : ViewEnvironmentKey() { - override val default: ScreenComposableFactoryFinder - get() = object : ScreenComposableFactoryFinder {} - } -} - -@WorkflowUiExperimentalApi -public fun ScreenComposableFactoryFinder.requireComposableFactoryForRendering( - environment: ViewEnvironment, - rendering: ScreenT -): ScreenComposableFactory { - return getComposableFactoryForRendering(environment, rendering) - ?: throw IllegalArgumentException( - "A ScreenComposableFactory should have been registered to display $rendering, " + - "or that class should implement ComposeScreen. Instead found " + - "${ - environment[ViewRegistry] - .getEntryFor(Key(rendering::class, ScreenComposableFactory::class)) - }." - ) -} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt deleted file mode 100644 index f78073649..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/TextControllerAsMutableState.kt +++ /dev/null @@ -1,47 +0,0 @@ -@file:OptIn(WorkflowUiExperimentalApi::class) - -package com.squareup.workflow1.ui.compose - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import com.squareup.workflow1.ui.TextController -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import kotlinx.coroutines.launch - -/** - * Exposes the [textValue][TextController.textValue] of a [TextController] - * as a remembered [MutableState], suitable for use from `@Composable` - * functions. - * - * Usage: - * - * var text by rendering.textController.asMutableState() - * - * OutlinedTextField( - * label = {}, - * placeholder = { Text("Enter some text") }, - * value = text, - * onValueChange = { text = it } - * ) - */ -@Composable public fun TextController.asMutableState(): MutableState { - // keys are set to `this` to reset the state if a different controller is passed in… - return remember(this) { mutableStateOf(textValue) }.also { state -> - // …and to restart the effect. - LaunchedEffect(this) { - // Push changes from the workflow to the state. - launch { - onTextChanged.collect { state.value = it } - } - // And the other way – push changes to the state to the workflow. - // This won't cause an infinite loop because both MutableState and - // MutableSnapshotFlow ignore duplicate values. - snapshotFlow { state.value } - .collect { textValue = it } - } - } -} diff --git a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt b/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt deleted file mode 100644 index 04c809dbd..000000000 --- a/workflow-ui/compose/src/main/java/com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupport.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.squareup.workflow1.ui.compose - -import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ScreenViewFactory -import com.squareup.workflow1.ui.ScreenViewFactoryFinder -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi - -/** - * Replaces the [ScreenComposableFactoryFinder] and [ScreenViewFactoryFinder] - * found in the receiving [ViewEnvironment] with wrappers that are able to - * delegate from one platform to the other. Required to allow - * [WorkflowViewStub][com.squareup.workflow1.ui.WorkflowViewStub] - * to handle renderings bound to `@Composable` functions, and to allow - * [WorkflowRendering] to handle renderings bound to [ScreenViewFactory]. - * - * Note that the standard navigation related [Screen] types - * (e.g. [BackStackScreen][com.squareup.workflow1.ui.navigation.BackStackScreen]) - * are mainly bound to [View][android.view.View]-based implementations. - * Until that changes, effectively every Compose-based app must call this method. - * - * App-specific customizations of [ScreenComposableFactoryFinder] and [ScreenViewFactoryFinder] - * must be placed in the [ViewEnvironment] before calling this method. - */ -@WorkflowUiExperimentalApi -public fun ViewEnvironment.withComposeInteropSupport(): ViewEnvironment { - val rawViewFactoryFinder = get(ScreenViewFactoryFinder) - val rawComposableFactoryFinder = get(ScreenComposableFactoryFinder) - - val convertingViewFactoryFinder = object : ScreenViewFactoryFinder { - override fun getViewFactoryForRendering( - environment: ViewEnvironment, - rendering: ScreenT - ): ScreenViewFactory? { - return rawViewFactoryFinder.getViewFactoryForRendering(environment, rendering) - ?: rawComposableFactoryFinder.getComposableFactoryForRendering(environment, rendering) - ?.asViewFactory() - } - } - - val convertingComposableFactoryFinder = object : ScreenComposableFactoryFinder { - override fun getComposableFactoryForRendering( - environment: ViewEnvironment, - rendering: ScreenT - ): ScreenComposableFactory? { - return rawComposableFactoryFinder.getComposableFactoryForRendering(environment, rendering) - ?: rawViewFactoryFinder.getViewFactoryForRendering(environment, rendering) - ?.asComposableFactory() - } - } - - return this + (ScreenViewFactoryFinder to convertingViewFactoryFinder) + - (ScreenComposableFactoryFinder to convertingComposableFactoryFinder) -} diff --git a/workflow-ui/compose/src/main/res/values/ids.xml b/workflow-ui/compose/src/main/res/values/ids.xml deleted file mode 100644 index 39544e2f0..000000000 --- a/workflow-ui/compose/src/main/res/values/ids.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - From 9748d8bd5121fa821a5eb6310a1db7cbe48e6880 Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Wed, 26 Jun 2024 14:48:03 -0700 Subject: [PATCH 05/15] Fix package being /workflow1.ui instead of /workflow1/ui --- .../com/squareup/{workflow1.ui => workflow1/ui}/Compatible.kt | 0 .../{workflow1.ui => workflow1/ui}/CompositeViewRegistry.kt | 0 .../com/squareup/{workflow1.ui => workflow1/ui}/Container.kt | 0 .../squareup/{workflow1.ui => workflow1/ui}/EnvironmentScreen.kt | 0 .../com/squareup/{workflow1.ui => workflow1/ui}/NamedScreen.kt | 0 .../kotlin/com/squareup/{workflow1.ui => workflow1/ui}/Screen.kt | 0 .../com/squareup/{workflow1.ui => workflow1/ui}/TextController.kt | 0 .../squareup/{workflow1.ui => workflow1/ui}/TypedViewRegistry.kt | 0 .../squareup/{workflow1.ui => workflow1/ui}/ViewEnvironment.kt | 0 .../com/squareup/{workflow1.ui => workflow1/ui}/ViewRegistry.kt | 0 .../{workflow1.ui => workflow1/ui}/WorkflowUiExperimentalApi.kt | 0 .../{workflow1.ui => workflow1/ui}/navigation/AlertOverlay.kt | 0 .../{workflow1.ui => workflow1/ui}/navigation/BackStackConfig.kt | 0 .../{workflow1.ui => workflow1/ui}/navigation/BackStackScreen.kt | 0 .../ui}/navigation/BodyAndOverlaysScreen.kt | 0 .../{workflow1.ui => workflow1/ui}/navigation/FullScreenModal.kt | 0 .../{workflow1.ui => workflow1/ui}/navigation/ModalOverlay.kt | 0 .../squareup/{workflow1.ui => workflow1/ui}/navigation/Overlay.kt | 0 .../{workflow1.ui => workflow1/ui}/navigation/ScreenOverlay.kt | 0 19 files changed, 0 insertions(+), 0 deletions(-) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/Compatible.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/CompositeViewRegistry.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/Container.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/EnvironmentScreen.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/NamedScreen.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/Screen.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/TextController.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/TypedViewRegistry.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/ViewEnvironment.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/ViewRegistry.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/WorkflowUiExperimentalApi.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/AlertOverlay.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/BackStackConfig.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/BackStackScreen.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/BodyAndOverlaysScreen.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/FullScreenModal.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/ModalOverlay.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/Overlay.kt (100%) rename workflow-ui/core/src/commonMain/kotlin/com/squareup/{workflow1.ui => workflow1/ui}/navigation/ScreenOverlay.kt (100%) 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Compatible.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/Compatible.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/CompositeViewRegistry.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/CompositeViewRegistry.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Container.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/Container.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/EnvironmentScreen.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/EnvironmentScreen.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/NamedScreen.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/NamedScreen.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/Screen.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/Screen.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TextController.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/TextController.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/TypedViewRegistry.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/TypedViewRegistry.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewEnvironment.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/ViewEnvironment.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/ViewRegistry.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/ViewRegistry.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/WorkflowUiExperimentalApi.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/WorkflowUiExperimentalApi.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/AlertOverlay.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/AlertOverlay.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackConfig.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/BackStackConfig.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BackStackScreen.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/BackStackScreen.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/BodyAndOverlaysScreen.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/FullScreenModal.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/FullScreenModal.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ModalOverlay.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/ModalOverlay.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/Overlay.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/Overlay.kt 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 similarity index 100% rename from workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1.ui/navigation/ScreenOverlay.kt rename to workflow-ui/core/src/commonMain/kotlin/com/squareup/workflow1/ui/navigation/ScreenOverlay.kt From da7ac8ce92f8e88f2e10ef770ec6c3f7b66f34d3 Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Wed, 26 Jun 2024 14:52:34 -0700 Subject: [PATCH 06/15] Remove some unnecessary files --- build-logic/build.gradle.kts | 4 ---- .../ComposeMultiplatformUiTestsPlugin.kt | 20 ------------------- .../compose/ScreenComposableFactoryAndroid.kt | 1 - 3 files changed, 25 deletions(-) delete mode 100644 build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index b4fdb5d48..2d5697a74 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -33,10 +33,6 @@ gradlePlugin { id = "compose-ui-tests" implementationClass = "com.squareup.workflow1.buildsrc.ComposeUiTestsPlugin" } - create("compose-multiplatform-ui-tests") { - id = "compose-multiplatform-ui-tests" - implementationClass = "com.squareup.workflow1.buildsrc.ComposeMultiplatformUiTestsPlugin" - } create("dependency-guard") { id = "dependency-guard" implementationClass = "com.squareup.workflow1.buildsrc.DependencyGuardConventionPlugin" diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt deleted file mode 100644 index 46c98b7c4..000000000 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/ComposeMultiplatformUiTestsPlugin.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.squareup.workflow1.buildsrc - -import com.rickbusarow.kgx.library -import com.rickbusarow.kgx.libsCatalog -import com.squareup.workflow1.buildsrc.internal.androidTestImplementation -import com.squareup.workflow1.buildsrc.internal.invoke -import org.gradle.api.Plugin -import org.gradle.api.Project - -class ComposeMultiplatformUiTestsPlugin : Plugin { - - override fun apply(target: Project) { - target.plugins.apply(AndroidDefaultsPlugin::class.java) - - target.dependencies { - androidTestImplementation(target.project(":workflow-ui:internal-testing-compose")) - androidTestImplementation(target.libsCatalog.library("androidx-compose-ui-test-junit4")) - } - } -} diff --git a/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt b/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt index 67610006c..84b487a45 100644 --- a/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt +++ b/workflow-ui/compose/src/androidMain/kotlin/com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroid.kt @@ -50,7 +50,6 @@ public fun ScreenComposableFactory.asViewFactory(): } } - /** * Convert a [ScreenViewFactory] to a [ScreenComposableFactory], * using [AndroidView] to host the `View` it builds. From 2cf22c1295bda6bf6ba2544c748dc02501032c1c Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Wed, 26 Jun 2024 14:53:34 -0700 Subject: [PATCH 07/15] Add ios sample app --- .../iosApp/Configuration/Config.xcconfig | 3 + .../iosApp/iosApp.xcodeproj/project.pbxproj | 403 ++++++++++++++++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 14 + .../AppIcon.appiconset/app-icon-1024.png | Bin 0 -> 67285 bytes .../iosApp/Assets.xcassets/Contents.json | 6 + .../iosApp/iosApp/ContentView.swift | 21 + .../compose-samples/iosApp/iosApp/Info.plist | 50 +++ .../Preview Assets.xcassets/Contents.json | 6 + .../iosApp/iosApp/iOSApp.swift | 10 + 10 files changed, 524 insertions(+) create mode 100644 samples/compose-samples/iosApp/Configuration/Config.xcconfig create mode 100644 samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj create mode 100644 samples/compose-samples/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png create mode 100644 samples/compose-samples/iosApp/iosApp/Assets.xcassets/Contents.json create mode 100644 samples/compose-samples/iosApp/iosApp/ContentView.swift create mode 100644 samples/compose-samples/iosApp/iosApp/Info.plist create mode 100644 samples/compose-samples/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 samples/compose-samples/iosApp/iosApp/iOSApp.swift diff --git a/samples/compose-samples/iosApp/Configuration/Config.xcconfig b/samples/compose-samples/iosApp/Configuration/Config.xcconfig new file mode 100644 index 000000000..176ea0167 --- /dev/null +++ b/samples/compose-samples/iosApp/Configuration/Config.xcconfig @@ -0,0 +1,3 @@ +TEAM_ID= +BUNDLE_ID=com.squareup.sample.compose +APP_NAME=Workflow Compose Samples diff --git a/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj b/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj new file mode 100644 index 000000000..7f0dfa7ea --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj @@ -0,0 +1,403 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557BA273AAA24004C7B11 /* Assets.xcassets */; }; + 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; + 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; + 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; + 7555FF7B242A565900829871 /* Workflow Multiplatform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workflow Multiplatform.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + B92378962B6B1156000C7307 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 058557D7273AAEEB004C7B11 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; + 42799AB246E5F90AF97AA0EF /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 7555FF72242A565900829871 = { + isa = PBXGroup; + children = ( + AB1DB47929225F7C00F7AF9C /* Configuration */, + 7555FF7D242A565900829871 /* iosApp */, + 7555FF7C242A565900829871 /* Products */, + 42799AB246E5F90AF97AA0EF /* Frameworks */, + ); + sourceTree = ""; + }; + 7555FF7C242A565900829871 /* Products */ = { + isa = PBXGroup; + children = ( + 7555FF7B242A565900829871 /* Workflow Multiplatform.app */, + ); + name = Products; + sourceTree = ""; + }; + 7555FF7D242A565900829871 /* iosApp */ = { + isa = PBXGroup; + children = ( + 058557BA273AAA24004C7B11 /* Assets.xcassets */, + 7555FF82242A565900829871 /* ContentView.swift */, + 7555FF8C242A565B00829871 /* Info.plist */, + 2152FB032600AC8F00CF470E /* iOSApp.swift */, + 058557D7273AAEEB004C7B11 /* Preview Content */, + ); + path = iosApp; + sourceTree = ""; + }; + AB1DB47929225F7C00F7AF9C /* Configuration */ = { + isa = PBXGroup; + children = ( + AB3632DC29227652001CCB65 /* Config.xcconfig */, + ); + path = Configuration; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7555FF7A242A565900829871 /* iosApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */; + buildPhases = ( + F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */, + 7555FF77242A565900829871 /* Sources */, + B92378962B6B1156000C7307 /* Frameworks */, + 7555FF79242A565900829871 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = iosApp; + packageProductDependencies = ( + ); + productName = iosApp; + productReference = 7555FF7B242A565900829871 /* Workflow Multiplatform.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7555FF73242A565900829871 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1130; + LastUpgradeCheck = 1540; + ORGANIZATIONNAME = orgName; + TargetAttributes = { + 7555FF7A242A565900829871 = { + CreatedOnToolsVersion = 11.3.1; + }; + }; + }; + buildConfigurationList = 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7555FF72242A565900829871; + packageReferences = ( + ); + productRefGroup = 7555FF7C242A565900829871 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7555FF7A242A565900829871 /* iosApp */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7555FF79242A565900829871 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */, + 058557BB273AAA24004C7B11 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + F36B1CEB2AD83DDC00CB74D5 /* Compile Kotlin Framework */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Compile Kotlin Framework"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7555FF77242A565900829871 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, + 7555FF83242A565900829871 /* ContentView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7555FFA3242A565B00829871 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 7555FFA4242A565B00829871 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB3632DC29227652001CCB65 /* Config.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 7555FFA6242A565B00829871 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + INFOPLIST_FILE = iosApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + ); + PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; + PRODUCT_NAME = "${APP_NAME}"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7555FFA7242A565B00829871 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; + DEVELOPMENT_TEAM = "${TEAM_ID}"; + ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + ); + INFOPLIST_FILE = iosApp/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.3; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-framework", + ComposeApp, + ); + PRODUCT_BUNDLE_IDENTIFIER = "${BUNDLE_ID}${TEAM_ID}"; + PRODUCT_NAME = "${APP_NAME}"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7555FF76242A565900829871 /* Build configuration list for PBXProject "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7555FFA3242A565B00829871 /* Debug */, + 7555FFA4242A565B00829871 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7555FFA5242A565B00829871 /* Build configuration list for PBXNativeTarget "iosApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7555FFA6242A565B00829871 /* Debug */, + 7555FFA7242A565B00829871 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 7555FF73242A565900829871 /* Project object */; +} \ No newline at end of file diff --git a/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..ee7e3ca03 --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..8edf56e7a --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "app-icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..53fc536fb9ac5c1dbb27c7e1da13db3760070a11 GIT binary patch literal 67285 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 BXNCX( literal 0 HcmV?d00001 diff --git a/samples/compose-samples/iosApp/iosApp/Assets.xcassets/Contents.json b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/Contents.json new file mode 100644 index 000000000..4aa7c5350 --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/samples/compose-samples/iosApp/iosApp/ContentView.swift b/samples/compose-samples/iosApp/iosApp/ContentView.swift new file mode 100644 index 000000000..3cd5c325b --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/ContentView.swift @@ -0,0 +1,21 @@ +import UIKit +import SwiftUI +import ComposeApp + +struct ComposeView: UIViewControllerRepresentable { + func makeUIViewController(context: Context) -> UIViewController { + MainViewControllerKt.MainViewController() + } + + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +struct ContentView: View { + var body: some View { + ComposeView() + .ignoresSafeArea(.keyboard) // Compose has own keyboard handler + } +} + + + diff --git a/samples/compose-samples/iosApp/iosApp/Info.plist b/samples/compose-samples/iosApp/iosApp/Info.plist new file mode 100644 index 000000000..412e37812 --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/Info.plist @@ -0,0 +1,50 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/samples/compose-samples/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json b/samples/compose-samples/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..4aa7c5350 --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} \ No newline at end of file diff --git a/samples/compose-samples/iosApp/iosApp/iOSApp.swift b/samples/compose-samples/iosApp/iosApp/iOSApp.swift new file mode 100644 index 000000000..0648e8602 --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/iOSApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct iOSApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} \ No newline at end of file From 61ad117e94f1bf29a293565db653c247c8e58131 Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Thu, 27 Jun 2024 11:04:18 -0700 Subject: [PATCH 08/15] Move code from main to their respective androidMain commonMain places * jdk target 8 issue is occurring so this is a progress commit --- samples/compose-samples/build.gradle.kts | 158 +++++++++++++----- .../{main => androidMain}/AndroidManifest.xml | 0 .../sample/compose/DefaultsAndroid.kt | 16 ++ .../sample/compose/hellocompose/AppAndroid.kt | 10 ++ .../hellocompose/HelloComposeActivity.kt | 0 .../hellocompose/HelloComposeScreenPreview.kt | 16 ++ .../HelloBindingActivity.kt | 14 -- .../HelloBindingPreview.kt | 14 ++ .../HelloComposeWorkflowActivity.kt | 0 .../HelloComposeWorkflowPreview.kt | 22 +++ .../InlineRenderingActivity.kt | 10 +- .../InlineRenderingWorkflowPreview.kt | 10 ++ .../launcher/SampleLauncherActivity.kt | 0 .../compose/launcher/SampleLauncherApp.kt | 0 .../sample/compose/launcher/Samples.kt | 0 .../compose/nestedrenderings/LegacyRunner.kt | 0 .../NestedRenderingsActivity.kt | 0 .../nestedrenderings/RecursiveViewFactory.kt | 0 .../nestedrenderings/RecursiveWorkflow.kt | 0 .../nestedrenderings/StringRendering.kt | 0 .../sample/compose/preview/PreviewActivity.kt | 34 ++++ .../sample/compose/textinput/AppAndroid.kt} | 7 +- .../compose/textinput/TextInputActivity.kt | 0 .../textinput/TextInputViewFactoryPreview.kt | 20 +++ .../res/layout/legacy_view.xml | 0 .../res/values/dimens.xml | 0 .../res/values/strings.xml | 0 .../res/values/styles.xml | 0 .../com/squareup/sample/compose/Defaults.kt | 10 ++ .../sample/compose/hellocompose/App.kt | 18 +- .../hellocompose/HelloComposeScreen.kt | 12 -- .../hellocompose/HelloComposeWorkflow.kt | 0 .../hellocomposebinding/HelloBinding.kt | 10 -- .../HelloBindingViewEnvironment.kt | 15 ++ .../hellocomposebinding/HelloWorkflow.kt | 0 .../hellocomposeworkflow/ComposeWorkflow.kt | 0 .../ComposeWorkflowImpl.kt | 0 .../HelloComposeWorkflow.kt | 20 --- .../hellocomposeworkflow/HelloWorkflow.kt | 0 .../InlineRenderingWorkflow.kt | 14 +- .../sample/compose/preview/PreviewApp.kt} | 42 +---- .../compose/textinput/TextInputViewFactory.kt | 16 -- .../compose/textinput/TextInputWorkflow.kt | 0 .../sample/compose/DefaultsNonAndroid.kt | 11 ++ workflow-ui/compose/build.gradle.kts | 10 +- 45 files changed, 317 insertions(+), 192 deletions(-) rename samples/compose-samples/src/{main => androidMain}/AndroidManifest.xml (100%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/AppAndroid.kt rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/hellocompose/HelloComposeActivity.kt (100%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreenPreview.kt rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt (76%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingPreview.kt rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt (100%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowPreview.kt rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt (82%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflowPreview.kt rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/launcher/SampleLauncherActivity.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/launcher/SampleLauncherApp.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/launcher/Samples.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt (100%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/nestedrenderings/StringRendering.kt (100%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/preview/PreviewActivity.kt rename samples/compose-samples/src/{main/java/com/squareup/sample/compose/textinput/App.kt => androidMain/kotlin/com/squareup/sample/compose/textinput/AppAndroid.kt} (76%) rename samples/compose-samples/src/{main/java => androidMain/kotlin}/com/squareup/sample/compose/textinput/TextInputActivity.kt (100%) create mode 100644 samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactoryPreview.kt rename samples/compose-samples/src/{main => androidMain}/res/layout/legacy_view.xml (100%) rename samples/compose-samples/src/{main => androidMain}/res/values/dimens.xml (100%) rename samples/compose-samples/src/{main => androidMain}/res/values/strings.xml (100%) rename samples/compose-samples/src/{main => androidMain}/res/values/styles.xml (100%) create mode 100644 samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocompose/App.kt (60%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt (74%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt (100%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt (68%) create mode 100644 samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingViewEnvironment.kt rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt (100%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt (100%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt (100%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt (64%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt (100%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt (83%) rename samples/compose-samples/src/{main/java/com/squareup/sample/compose/preview/PreviewActivity.kt => commonMain/kotlin/com/squareup/sample/compose/preview/PreviewApp.kt} (78%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/textinput/TextInputViewFactory.kt (77%) rename samples/compose-samples/src/{main/java => commonMain/kotlin}/com/squareup/sample/compose/textinput/TextInputWorkflow.kt (100%) create mode 100644 samples/compose-samples/src/nonAndroidMain/kotlin/com/squareup/sample/compose/DefaultsNonAndroid.kt diff --git a/samples/compose-samples/build.gradle.kts b/samples/compose-samples/build.gradle.kts index 4b230b5db..49ea47c20 100644 --- a/samples/compose-samples/build.gradle.kts +++ b/samples/compose-samples/build.gradle.kts @@ -1,60 +1,126 @@ +import com.squareup.workflow1.buildsrc.iosTargets +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions +import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { + alias(libs.plugins.jetbrains.compose.plugin) + id("kotlin-multiplatform") id("com.android.application") - id("kotlin-android") - id("android-sample-app") + id("android-defaults") id("android-ui-tests") - id("compose-ui-tests") + // id("android-sample-app") + // id("android-ui-tests") + // id("compose-ui-tests") +} + +kotlin { + val targets = project.findProperty("workflow.targets") ?: "kmp" + + listOf( + "ios" to { iosTargets() }, + "jvm" to { jvm() }, + "js" to { js(IR).browser() }, + "android" to { androidTargetWithTesting() }, + ).forEach { (target, action) -> + if (targets == "kmp" || targets == target) { + action() + } + } + + sourceSets { + commonMain.dependencies { + implementation(compose.components.uiToolingPreview) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.ui) + + implementation(project(":workflow-core")) + implementation(project(":workflow-runtime")) + implementation(project(":workflow-ui:compose")) + implementation(project(":workflow-ui:core")) + } + + androidMain.dependencies { + implementation(project.dependencies.platform(libs.androidx.compose.bom)) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.activity.core) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.material) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.geometry) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.savedstate) + implementation(libs.androidx.viewbinding) + implementation(libs.kotlin.common) + // For the LayoutInspector. + implementation(libs.kotlin.reflect) + + implementation(project(":workflow-config:config-android")) + implementation(project(":workflow-ui:compose-tooling")) + implementation(project(":workflow-ui:core-android")) + implementation(project(":workflow-ui:core-common")) + } + + applyDefaultHierarchyTemplate() + + val nonAndroidMain by creating { + dependsOn(commonMain.get()) + appleMain.get().dependsOn(this) + jsMain.get().dependsOn(this) + jvmMain.get().dependsOn(this) + } + } } android { + buildFeatures.viewBinding = true defaultConfig { applicationId = "com.squareup.sample.compose" } - buildFeatures { - compose = true + buildFeatures.compose = true + composeOptions.kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() + namespace = "com.squareup.sample.compose" + + dependencies { + debugImplementation(compose.uiTooling) } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() +} + +fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + instrumentedTestVariant { + sourceSetTree.set(KotlinSourceSetTree.test) + + dependencies { + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.activity.core) + androidTestImplementation(libs.androidx.compose.ui) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.truth) + androidTestImplementation(libs.kotlin.test.jdk) + + androidTestImplementation(project(":workflow-runtime")) + + debugImplementation(libs.squareup.leakcanary.android) + } + } } - namespace = "com.squareup.sample.compose" } -dependencies { - val composeBom = platform(libs.androidx.compose.bom) - - androidTestImplementation(libs.androidx.activity.core) - androidTestImplementation(composeBom) - androidTestImplementation(libs.androidx.compose.ui) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - androidTestImplementation(libs.androidx.test.core) - androidTestImplementation(libs.androidx.test.truth) - androidTestImplementation(libs.kotlin.test.jdk) - - androidTestImplementation(project(":workflow-runtime")) - - debugImplementation(libs.squareup.leakcanary.android) - - implementation(libs.androidx.activity.compose) - implementation(libs.androidx.activity.core) - implementation(composeBom) - implementation(libs.androidx.compose.foundation) - implementation(libs.androidx.compose.foundation.layout) - implementation(libs.androidx.compose.material) - implementation(libs.androidx.compose.runtime) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.geometry) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.lifecycle.viewmodel.ktx) - implementation(libs.androidx.lifecycle.viewmodel.savedstate) - implementation(libs.androidx.viewbinding) - implementation(libs.kotlin.common) - // For the LayoutInspector. - implementation(libs.kotlin.reflect) - - implementation(project(":workflow-ui:compose")) - implementation(project(":workflow-ui:compose-tooling")) - implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) +android.compileOptions.targetCompatibility = JavaVersion.VERSION_1_8 +java.targetCompatibility = JavaVersion.VERSION_1_8 + +tasks.withType { + compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8) } diff --git a/samples/compose-samples/src/main/AndroidManifest.xml b/samples/compose-samples/src/androidMain/AndroidManifest.xml similarity index 100% rename from samples/compose-samples/src/main/AndroidManifest.xml rename to samples/compose-samples/src/androidMain/AndroidManifest.xml diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt new file mode 100644 index 000000000..0029b1d32 --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt @@ -0,0 +1,16 @@ +package com.squareup.sample.compose + +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.config.AndroidRuntimeConfigTools +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.withComposeInteropSupport + +@OptIn(WorkflowExperimentalRuntime::class) +actual fun defaultRuntimeConfig(): RuntimeConfig = + AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + +@OptIn(WorkflowUiExperimentalApi::class) +actual fun defaultViewEnvironment(): ViewEnvironment = + ViewEnvironment.EMPTY.withComposeInteropSupport() diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/AppAndroid.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/AppAndroid.kt new file mode 100644 index 000000000..be7eaa139 --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/AppAndroid.kt @@ -0,0 +1,10 @@ +package com.squareup.sample.compose.hellocompose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +@Preview(showBackground = true) +@Composable +private fun AppPreview() { + App() +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeActivity.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeActivity.kt diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreenPreview.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreenPreview.kt new file mode 100644 index 000000000..2a795eec1 --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreenPreview.kt @@ -0,0 +1,16 @@ +package com.squareup.sample.compose.hellocompose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.tooling.Preview + +@OptIn(WorkflowUiExperimentalApi::class) +@Preview(heightDp = 150, showBackground = true) +@Composable +private fun HelloPreview() { + HelloComposeScreen( + "Hello!", + onClick = {} + ).Preview() +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt similarity index 76% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt index f20954ba3..c00bbbd36 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingActivity.kt @@ -5,7 +5,6 @@ package com.squareup.sample.compose.hellocomposebinding import android.os.Bundle import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.material.MaterialTheme import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -13,25 +12,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.withComposeInteropSupport -import com.squareup.workflow1.ui.compose.withCompositionRoot -import com.squareup.workflow1.ui.plus import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import kotlinx.coroutines.flow.StateFlow -@OptIn(WorkflowUiExperimentalApi::class) -private val viewEnvironment = - (ViewEnvironment.EMPTY + ViewRegistry(HelloBinding)) - .withCompositionRoot { content -> - MaterialTheme(content = content) - } - .withComposeInteropSupport() - /** * Demonstrates how to create and display a view factory with * [screenComposableFactory][com.squareup.workflow1.ui.compose.ScreenComposableFactory]. diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingPreview.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingPreview.kt new file mode 100644 index 000000000..cfa8aebbe --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingPreview.kt @@ -0,0 +1,14 @@ +package com.squareup.sample.compose.hellocomposebinding + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.sample.compose.hellocomposebinding.HelloWorkflow.Rendering +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.tooling.Preview + +@OptIn(WorkflowUiExperimentalApi::class) +@Preview(heightDp = 150, showBackground = true) +@Composable +fun DrawHelloRenderingPreview() { + HelloBinding.Preview(Rendering("Hello!", onClick = {})) +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowActivity.kt diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowPreview.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowPreview.kt new file mode 100644 index 000000000..9a562f64f --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflowPreview.kt @@ -0,0 +1,22 @@ +package com.squareup.sample.compose.hellocomposeworkflow + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.sample.compose.defaultRuntimeConfig +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.renderAsState + +@OptIn(WorkflowUiExperimentalApi::class) +@Preview(showBackground = true) +@Composable +fun HelloComposeWorkflowPreview() { + val rendering by HelloComposeWorkflow.renderAsState( + props = "hello", + onOutput = {}, + runtimeConfig = defaultRuntimeConfig() + ) + WorkflowRendering(rendering, ViewEnvironment.EMPTY) +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt similarity index 82% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt index 8386aaa99..59007075c 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingActivity.kt @@ -1,5 +1,3 @@ -@file:OptIn(WorkflowExperimentalRuntime::class) - package com.squareup.sample.compose.inlinerendering import android.os.Bundle @@ -8,14 +6,14 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.squareup.sample.compose.defaultRuntimeConfig +import com.squareup.sample.compose.defaultViewEnvironment import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.mapRendering import com.squareup.workflow1.ui.Screen -import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowLayout import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.withComposeInteropSupport import com.squareup.workflow1.ui.renderWorkflowIn import com.squareup.workflow1.ui.withEnvironment import kotlinx.coroutines.flow.StateFlow @@ -39,11 +37,11 @@ class InlineRenderingActivity : AppCompatActivity() { val renderings: StateFlow by lazy { renderWorkflowIn( workflow = InlineRenderingWorkflow.mapRendering { - it.withEnvironment(ViewEnvironment.EMPTY.withComposeInteropSupport()) + it.withEnvironment(defaultViewEnvironment()) }, scope = viewModelScope, savedStateHandle = savedState, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = defaultRuntimeConfig() ) } } diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflowPreview.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflowPreview.kt new file mode 100644 index 000000000..346110398 --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflowPreview.kt @@ -0,0 +1,10 @@ +package com.squareup.sample.compose.inlinerendering + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +@Preview(showBackground = true) +@Composable +internal fun InlineRenderingWorkflowPreview() { + InlineRenderingWorkflowRendering() +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/launcher/SampleLauncherActivity.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/launcher/SampleLauncherActivity.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherApp.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/launcher/SampleLauncherApp.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/SampleLauncherApp.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/launcher/SampleLauncherApp.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/Samples.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/launcher/Samples.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/launcher/Samples.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/launcher/Samples.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/LegacyRunner.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/NestedRenderingsActivity.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/RecursiveViewFactory.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/RecursiveWorkflow.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/StringRendering.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/StringRendering.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/nestedrenderings/StringRendering.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/nestedrenderings/StringRendering.kt diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/preview/PreviewActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/preview/PreviewActivity.kt new file mode 100644 index 000000000..2f2a4c915 --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/preview/PreviewActivity.kt @@ -0,0 +1,34 @@ +@file:OptIn(WorkflowUiExperimentalApi::class) + +package com.squareup.sample.compose.preview + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.tooling.Preview + +class PreviewActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + PreviewApp() + } + } +} + + +@Preview +@Composable +fun PreviewApp() { + MaterialTheme { + Surface { + previewContactRendering.Preview() + } + } +} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/AppAndroid.kt similarity index 76% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/AppAndroid.kt index 5533b1251..825dd5277 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/App.kt +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/AppAndroid.kt @@ -1,4 +1,4 @@ -@file:OptIn(WorkflowUiExperimentalApi::class, WorkflowExperimentalRuntime::class) +@file:OptIn(WorkflowUiExperimentalApi::class) package com.squareup.sample.compose.textinput @@ -6,8 +6,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.tooling.preview.Preview -import com.squareup.workflow1.WorkflowExperimentalRuntime -import com.squareup.workflow1.config.AndroidRuntimeConfigTools +import com.squareup.sample.compose.defaultRuntimeConfig import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.ViewRegistry import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -22,7 +21,7 @@ private val viewEnvironment = ViewEnvironment.EMPTY + ViewRegistry(TextInputView val rendering by TextInputWorkflow.renderAsState( props = Unit, onOutput = {}, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = defaultRuntimeConfig() ) WorkflowRendering(rendering, viewEnvironment) } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputActivity.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/TextInputActivity.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputActivity.kt rename to samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/TextInputActivity.kt diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactoryPreview.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactoryPreview.kt new file mode 100644 index 000000000..e68fa472c --- /dev/null +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactoryPreview.kt @@ -0,0 +1,20 @@ +package com.squareup.sample.compose.textinput + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.squareup.sample.compose.textinput.TextInputWorkflow.Rendering +import com.squareup.workflow1.ui.TextController +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.tooling.Preview + +@OptIn(WorkflowUiExperimentalApi::class) +@Preview(showBackground = true) +@Composable +private fun TextInputViewFactoryPreview() { + TextInputViewFactory.Preview( + Rendering( + textController = TextController("Hello world"), + onSwapText = {} + ) + ) +} diff --git a/samples/compose-samples/src/main/res/layout/legacy_view.xml b/samples/compose-samples/src/androidMain/res/layout/legacy_view.xml similarity index 100% rename from samples/compose-samples/src/main/res/layout/legacy_view.xml rename to samples/compose-samples/src/androidMain/res/layout/legacy_view.xml diff --git a/samples/compose-samples/src/main/res/values/dimens.xml b/samples/compose-samples/src/androidMain/res/values/dimens.xml similarity index 100% rename from samples/compose-samples/src/main/res/values/dimens.xml rename to samples/compose-samples/src/androidMain/res/values/dimens.xml diff --git a/samples/compose-samples/src/main/res/values/strings.xml b/samples/compose-samples/src/androidMain/res/values/strings.xml similarity index 100% rename from samples/compose-samples/src/main/res/values/strings.xml rename to samples/compose-samples/src/androidMain/res/values/strings.xml diff --git a/samples/compose-samples/src/main/res/values/styles.xml b/samples/compose-samples/src/androidMain/res/values/styles.xml similarity index 100% rename from samples/compose-samples/src/main/res/values/styles.xml rename to samples/compose-samples/src/androidMain/res/values/styles.xml diff --git a/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt new file mode 100644 index 000000000..2a1786562 --- /dev/null +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt @@ -0,0 +1,10 @@ +package com.squareup.sample.compose + +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +expect fun defaultRuntimeConfig(): RuntimeConfig + +@OptIn(WorkflowUiExperimentalApi::class) +expect fun defaultViewEnvironment(): ViewEnvironment diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt similarity index 60% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt index 9a4706258..750fde405 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/App.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt @@ -1,4 +1,4 @@ -@file:OptIn(WorkflowUiExperimentalApi::class, WorkflowExperimentalRuntime::class) +@file:OptIn(WorkflowUiExperimentalApi::class) package com.squareup.sample.compose.hellocompose @@ -9,23 +9,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.squareup.workflow1.WorkflowExperimentalRuntime -import com.squareup.workflow1.config.AndroidRuntimeConfigTools -import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.sample.compose.defaultRuntimeConfig +import com.squareup.sample.compose.defaultViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.WorkflowRendering import com.squareup.workflow1.ui.compose.renderAsState -import com.squareup.workflow1.ui.compose.withComposeInteropSupport -private val viewEnvironment = ViewEnvironment.EMPTY.withComposeInteropSupport() +private val viewEnvironment = defaultViewEnvironment() @Composable fun App() { MaterialTheme { val rendering by HelloComposeWorkflow.renderAsState( props = Unit, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig(), + runtimeConfig = defaultRuntimeConfig(), onOutput = {} ) WorkflowRendering( @@ -40,8 +37,3 @@ private val viewEnvironment = ViewEnvironment.EMPTY.withComposeInteropSupport() } } -@Preview(showBackground = true) -@Composable -private fun AppPreview() { - App() -} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt similarity index 74% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt index 88e8f8b87..066968fcd 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeScreen.kt @@ -7,11 +7,9 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.ComposeScreen -import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) data class HelloComposeScreen( @@ -28,13 +26,3 @@ data class HelloComposeScreen( ) } } - -@OptIn(WorkflowUiExperimentalApi::class) -@Preview(heightDp = 150, showBackground = true) -@Composable -private fun HelloPreview() { - HelloComposeScreen( - "Hello!", - onClick = {} - ).Preview() -} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/HelloComposeWorkflow.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt similarity index 68% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt index aa3f65e67..4ad66e697 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBinding.kt @@ -4,13 +4,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import com.squareup.sample.compose.hellocomposebinding.HelloWorkflow.Rendering import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.ScreenComposableFactory -import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) val HelloBinding = ScreenComposableFactory { rendering, _ -> @@ -22,10 +19,3 @@ val HelloBinding = ScreenComposableFactory { rendering, _ -> .wrapContentSize() ) } - -@OptIn(WorkflowUiExperimentalApi::class) -@Preview(heightDp = 150, showBackground = true) -@Composable -fun DrawHelloRenderingPreview() { - HelloBinding.Preview(Rendering("Hello!", onClick = {})) -} diff --git a/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingViewEnvironment.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingViewEnvironment.kt new file mode 100644 index 000000000..1cac0652e --- /dev/null +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloBindingViewEnvironment.kt @@ -0,0 +1,15 @@ +package com.squareup.sample.compose.hellocomposebinding + +import androidx.compose.material.MaterialTheme +import com.squareup.sample.compose.defaultViewEnvironment +import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.withCompositionRoot +import com.squareup.workflow1.ui.plus + +@OptIn(WorkflowUiExperimentalApi::class) +val viewEnvironment = + (defaultViewEnvironment() + ViewRegistry(HelloBinding)) + .withCompositionRoot { content -> + MaterialTheme(content = content) + } diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposebinding/HelloWorkflow.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflow.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/ComposeWorkflowImpl.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt similarity index 64% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt index ac91f5afb..e30b40411 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloComposeWorkflow.kt @@ -1,5 +1,3 @@ -@file:OptIn(WorkflowExperimentalRuntime::class) - package com.squareup.sample.compose.hellocomposeworkflow import androidx.compose.foundation.clickable @@ -8,18 +6,12 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import com.squareup.sample.compose.hellocomposeworkflow.HelloComposeWorkflow.Toggle import com.squareup.workflow1.Sink -import com.squareup.workflow1.WorkflowExperimentalRuntime -import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.renderAsState /** * A [ComposeWorkflow] that is used by [HelloWorkflow] to render the screen. @@ -47,15 +39,3 @@ object HelloComposeWorkflow : ComposeWorkflow() { } } } - -@OptIn(WorkflowUiExperimentalApi::class) -@Preview(showBackground = true) -@Composable -fun HelloComposeWorkflowPreview() { - val rendering by HelloComposeWorkflow.renderAsState( - props = "hello", - onOutput = {}, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() - ) - WorkflowRendering(rendering, ViewEnvironment.EMPTY) -} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocomposeworkflow/HelloWorkflow.kt diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt similarity index 83% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt index 3e77d96fb..09a966d00 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingWorkflow.kt @@ -1,4 +1,4 @@ -@file:OptIn(WorkflowUiExperimentalApi::class, WorkflowExperimentalRuntime::class) +@file:OptIn(WorkflowUiExperimentalApi::class) package com.squareup.sample.compose.inlinerendering @@ -15,11 +15,9 @@ import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.tooling.preview.Preview +import com.squareup.sample.compose.defaultRuntimeConfig import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.WorkflowExperimentalRuntime -import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.parse import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment @@ -58,17 +56,11 @@ fun InlineRenderingWorkflowRendering() { val rendering by InlineRenderingWorkflow.renderAsState( props = Unit, onOutput = {}, - runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() + runtimeConfig = defaultRuntimeConfig() ) WorkflowRendering(rendering, ViewEnvironment.EMPTY) } -@Preview(showBackground = true) -@Composable -internal fun InlineRenderingWorkflowPreview() { - InlineRenderingWorkflowRendering() -} - @OptIn(ExperimentalAnimationApi::class) @Composable private fun AnimatedCounter( diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/preview/PreviewApp.kt similarity index 78% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/preview/PreviewApp.kt index 1606fc23b..1bd310d74 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/preview/PreviewActivity.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/preview/PreviewApp.kt @@ -2,9 +2,6 @@ package com.squareup.sample.compose.preview -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy @@ -13,47 +10,16 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.padding import androidx.compose.material.Card import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.ComposeScreen import com.squareup.workflow1.ui.compose.WorkflowRendering -import com.squareup.workflow1.ui.compose.tooling.Preview - -class PreviewActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - PreviewApp() - } - } -} - -val previewContactRendering = ContactRendering( - name = "Dim Tonnelly", - details = ContactDetailsRendering( - phoneNumber = "555-555-5555", - address = "1234 Apgar Lane" - ) -) - -@Preview -@Composable -fun PreviewApp() { - MaterialTheme { - Surface { - previewContactRendering.Preview() - } - } -} data class ContactRendering( val name: String, @@ -95,3 +61,11 @@ private fun ContactDetails( } } } + +val previewContactRendering = ContactRendering( + name = "Dim Tonnelly", + details = ContactDetailsRendering( + phoneNumber = "555-555-5555", + address = "1234 Apgar Lane" + ) +) diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactory.kt similarity index 77% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactory.kt index 979dedf2e..6b4cb4710 100644 --- a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputViewFactory.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/textinput/TextInputViewFactory.kt @@ -9,19 +9,15 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material.Button import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.squareup.sample.compose.textinput.TextInputWorkflow.Rendering -import com.squareup.workflow1.ui.TextController import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.compose.ScreenComposableFactory import com.squareup.workflow1.ui.compose.asMutableState -import com.squareup.workflow1.ui.compose.tooling.Preview @OptIn(WorkflowUiExperimentalApi::class) val TextInputViewFactory = ScreenComposableFactory { rendering, _ -> @@ -47,15 +43,3 @@ val TextInputViewFactory = ScreenComposableFactory { rendering, _ -> } } } - -@OptIn(WorkflowUiExperimentalApi::class) -@Preview(showBackground = true) -@Composable -private fun TextInputViewFactoryPreview() { - TextInputViewFactory.Preview( - Rendering( - textController = TextController("Hello world"), - onSwapText = {} - ) - ) -} diff --git a/samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputWorkflow.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/textinput/TextInputWorkflow.kt similarity index 100% rename from samples/compose-samples/src/main/java/com/squareup/sample/compose/textinput/TextInputWorkflow.kt rename to samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/textinput/TextInputWorkflow.kt diff --git a/samples/compose-samples/src/nonAndroidMain/kotlin/com/squareup/sample/compose/DefaultsNonAndroid.kt b/samples/compose-samples/src/nonAndroidMain/kotlin/com/squareup/sample/compose/DefaultsNonAndroid.kt new file mode 100644 index 000000000..dcc7476cd --- /dev/null +++ b/samples/compose-samples/src/nonAndroidMain/kotlin/com/squareup/sample/compose/DefaultsNonAndroid.kt @@ -0,0 +1,11 @@ +package com.squareup.sample.compose + +import com.squareup.workflow1.RuntimeConfig +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +actual fun defaultRuntimeConfig(): RuntimeConfig = RuntimeConfigOptions.DEFAULT_CONFIG + +@OptIn(WorkflowUiExperimentalApi::class) +actual fun defaultViewEnvironment(): ViewEnvironment = ViewEnvironment.EMPTY diff --git a/workflow-ui/compose/build.gradle.kts b/workflow-ui/compose/build.gradle.kts index aa7eb72b0..c9a67a550 100644 --- a/workflow-ui/compose/build.gradle.kts +++ b/workflow-ui/compose/build.gradle.kts @@ -4,8 +4,6 @@ import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { alias(libs.plugins.jetbrains.compose.plugin) id("kotlin-multiplatform") @@ -23,10 +21,10 @@ fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { dependencies { debugImplementation(libs.androidx.compose.ui.test.manifest) - implementation(libs.androidx.compose.ui.test.junit4) - implementation(libs.squareup.leakcanary.instrumentation) - implementation(project(":workflow-ui:internal-testing-android")) - implementation(project.dependencies.platform(libs.androidx.compose.bom)) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.squareup.leakcanary.instrumentation) + androidTestImplementation(project(":workflow-ui:internal-testing-android")) } } } From d979a912418815409c830d4c86077f15eda74ba6 Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Thu, 27 Jun 2024 14:26:56 -0700 Subject: [PATCH 09/15] Get iOS app running with basic hello world! --- .../buildsrc/KotlinMultiplatformExtensions.kt | 11 +-- samples/compose-samples/README.md | 5 ++ samples/compose-samples/build.gradle.kts | 69 ++++++++++++++----- .../iosApp/iosApp.xcodeproj/project.pbxproj | 16 +++-- .../iosApp/iosApp/ContentView.swift | 3 - .../sample/compose/MainViewController.kt | 10 +++ 6 files changed, 84 insertions(+), 30 deletions(-) create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt index faf7f93ef..8a669825d 100644 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/KotlinMultiplatformExtensions.kt @@ -1,9 +1,12 @@ package com.squareup.workflow1.buildsrc import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -fun KotlinMultiplatformExtension.iosTargets() { - iosX64() - iosArm64() - iosSimulatorArm64() +fun KotlinMultiplatformExtension.iosTargets(): List { + return listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ) } diff --git a/samples/compose-samples/README.md b/samples/compose-samples/README.md index 2fb59c6b5..9ea8de665 100644 --- a/samples/compose-samples/README.md +++ b/samples/compose-samples/README.md @@ -2,3 +2,8 @@ This module is named "compose-samples" because the binary validation tool seems to refuse to look at the `:workflow-ui:compose` module if this one is also named `compose`. + +1. To run the iOS target you need to be on a Mac and have Xcode installed. +2. Then install the Kotlin Multiplatform plugin for your intellij IDE +3. Finally go to run configurations and add a new iOS Application configuration and select the iOS +project file in ./iosApp diff --git a/samples/compose-samples/build.gradle.kts b/samples/compose-samples/build.gradle.kts index 49ea47c20..61a78ecec 100644 --- a/samples/compose-samples/build.gradle.kts +++ b/samples/compose-samples/build.gradle.kts @@ -1,9 +1,8 @@ import com.squareup.workflow1.buildsrc.iosTargets +import org.gradle.api.JavaVersion.VERSION_1_9 import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinTargetContainerWithPresetFunctions import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetTree -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { alias(libs.plugins.jetbrains.compose.plugin) @@ -11,16 +10,22 @@ plugins { id("com.android.application") id("android-defaults") id("android-ui-tests") - // id("android-sample-app") - // id("android-ui-tests") - // id("compose-ui-tests") + id("compose-ui-tests") } kotlin { val targets = project.findProperty("workflow.targets") ?: "kmp" listOf( - "ios" to { iosTargets() }, + "ios" to { + iosTargets().forEach { iosTarget -> + // This allows us to import ComposeApp into the iOS project + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + }, "jvm" to { jvm() }, "js" to { js(IR).browser() }, "android" to { androidTargetWithTesting() }, @@ -33,6 +38,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(compose.components.uiToolingPreview) + implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material) implementation(compose.ui) @@ -70,10 +76,20 @@ kotlin { implementation(project(":workflow-ui:core-common")) } - applyDefaultHierarchyTemplate() + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + + val iosMain by creating { + dependsOn(commonMain.get()) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } val nonAndroidMain by creating { dependsOn(commonMain.get()) + iosMain.dependsOn(this) appleMain.get().dependsOn(this) jsMain.get().dependsOn(this) jvmMain.get().dependsOn(this) @@ -81,12 +97,34 @@ kotlin { } } +/** + * All of these are needed due needing Java 9 whenever databinding is enabled and the release + * flag is set. See [com.android.build.gradle.tasks.JavaCompileUtils::checkReleaseFlag]. Setting + * the language version here fixes the issue. + * TODO: Figure out how to disable the release flag for the sample app + */ +// java { +// toolchain.languageVersion.set(null as? JavaLanguageVersion) +// } + +tasks.withType { + kotlinOptions.jvmTarget = "9" +} + android { - buildFeatures.viewBinding = true + compileOptions { + sourceCompatibility = VERSION_1_9 + targetCompatibility = VERSION_1_9 + } + + buildFeatures { + viewBinding = true + compose = true + } + defaultConfig { applicationId = "com.squareup.sample.compose" } - buildFeatures.compose = true composeOptions.kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() namespace = "com.squareup.sample.compose" @@ -97,12 +135,18 @@ android { fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "9" + } + } @OptIn(ExperimentalKotlinGradlePluginApi::class) instrumentedTestVariant { sourceSetTree.set(KotlinSourceSetTree.test) dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.activity.core) androidTestImplementation(libs.androidx.compose.ui) androidTestImplementation(libs.androidx.compose.ui.test.junit4) @@ -117,10 +161,3 @@ fun KotlinTargetContainerWithPresetFunctions.androidTargetWithTesting() { } } } - -android.compileOptions.targetCompatibility = JavaVersion.VERSION_1_8 -java.targetCompatibility = JavaVersion.VERSION_1_8 - -tasks.withType { - compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8) -} diff --git a/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj b/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj index 7f0dfa7ea..46a3b6948 100644 --- a/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ 058557BA273AAA24004C7B11 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 2152FB032600AC8F00CF470E /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; - 7555FF7B242A565900829871 /* Workflow Multiplatform.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workflow Multiplatform.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7555FF7B242A565900829871 /* Workflow Compose Samples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workflow Compose Samples.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; @@ -62,7 +62,7 @@ 7555FF7C242A565900829871 /* Products */ = { isa = PBXGroup; children = ( - 7555FF7B242A565900829871 /* Workflow Multiplatform.app */, + 7555FF7B242A565900829871 /* Workflow Compose Samples.app */, ); name = Products; sourceTree = ""; @@ -107,7 +107,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 7555FF7B242A565900829871 /* Workflow Multiplatform.app */; + productReference = 7555FF7B242A565900829871 /* Workflow Compose Samples.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -175,7 +175,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\ncd \"$SRCROOT/..\"\n./gradlew :composeApp:embedAndSignAppleFrameworkForXcode\n"; + shellScript = "if [ \"YES\" = \"$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED\" ]; then\n echo \"Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \\\"YES\\\"\"\n exit 0\nfi\necho 'SRCROOT: ' + $SRCROOT \necho $(SRCROOT)/../compose-samples/\ncd \"$SRCROOT/..\"\ncd \"../..\"\n./gradlew :samples:compose-samples:embedAndSignAppleFrameworkForXcode\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -323,7 +323,8 @@ ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", - "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.3; @@ -355,7 +356,8 @@ ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", - "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)\n$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/../shared/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", + "$(SRCROOT)/../build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)", ); INFOPLIST_FILE = iosApp/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.3; @@ -400,4 +402,4 @@ /* End XCConfigurationList section */ }; rootObject = 7555FF73242A565900829871 /* Project object */; -} \ No newline at end of file +} diff --git a/samples/compose-samples/iosApp/iosApp/ContentView.swift b/samples/compose-samples/iosApp/iosApp/ContentView.swift index 3cd5c325b..dc574782c 100644 --- a/samples/compose-samples/iosApp/iosApp/ContentView.swift +++ b/samples/compose-samples/iosApp/iosApp/ContentView.swift @@ -16,6 +16,3 @@ struct ContentView: View { .ignoresSafeArea(.keyboard) // Compose has own keyboard handler } } - - - diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt new file mode 100644 index 000000000..ba6a00127 --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt @@ -0,0 +1,10 @@ +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.ComposeUIViewController + +fun MainViewController() = ComposeUIViewController { App() } + +@Composable +fun App() { + BasicText("Hello World") +} From f1826a1813b8bdde3f36a60e04fa4970947218c4 Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Fri, 28 Jun 2024 13:27:56 -0700 Subject: [PATCH 10/15] Add back handling for iOS as well as make iOS sample work for non-Preview and View related items --- samples/compose-samples/README.md | 6 ++ samples/compose-samples/build.gradle.kts | 17 ++++-- .../iosApp/iosApp.xcodeproj/project.pbxproj | 4 ++ .../iosApp/ContainerViewController.swift | 25 ++++++++ .../iosApp/iosApp/ContentView.swift | 52 ++++++++++++++-- .../sample/compose/DefaultsAndroid.kt | 5 ++ .../com/squareup/sample/compose/Defaults.kt | 4 ++ .../sample/compose/hellocompose/App.kt | 1 - .../squareup/sample/compose/DefaultsIos.kt | 16 +++++ .../sample/compose/MainViewController.kt | 31 +++++++++- .../inlinerendering/InlineRenderingApp.kt | 15 +++++ .../sample/compose/launcher/SampleScreen.kt | 44 ++++++++++++++ .../sample/compose/launcher/SampleWorkflow.kt | 60 +++++++++++++++++++ .../sample/compose/launcher/Samples.kt | 52 ++++++++++++++++ .../squareup/sample/compose/states/Store.kt | 30 ++++++++++ .../sample/compose/DefaultsNonMobile.kt | 6 ++ 16 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 samples/compose-samples/iosApp/iosApp/ContainerViewController.swift create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/DefaultsIos.kt create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingApp.kt create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleScreen.kt create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleWorkflow.kt create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/Samples.kt create mode 100644 samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/states/Store.kt create mode 100644 samples/compose-samples/src/nonMobileMain/kotlin/com/squareup/sample/compose/DefaultsNonMobile.kt diff --git a/samples/compose-samples/README.md b/samples/compose-samples/README.md index 9ea8de665..87bd9e664 100644 --- a/samples/compose-samples/README.md +++ b/samples/compose-samples/README.md @@ -3,7 +3,13 @@ This module is named "compose-samples" because the binary validation tool seems to refuse to look at the `:workflow-ui:compose` module if this one is also named `compose`. + +# iOS 1. To run the iOS target you need to be on a Mac and have Xcode installed. 2. Then install the Kotlin Multiplatform plugin for your intellij IDE 3. Finally go to run configurations and add a new iOS Application configuration and select the iOS project file in ./iosApp + +[To enable iOS debugging](https://appkickstarter.com/blog/debug-an-ios-kotlin-multiplatform-app-from-android-studio/) open Settings -> Advanced Settings -> Enable experimental Multiplatform IDE features + +[BackHandler implementation for iOS here](https://exyte.com/blog/jetpack-compose-multiplatform) diff --git a/samples/compose-samples/build.gradle.kts b/samples/compose-samples/build.gradle.kts index 61a78ecec..a12718bdb 100644 --- a/samples/compose-samples/build.gradle.kts +++ b/samples/compose-samples/build.gradle.kts @@ -94,6 +94,14 @@ kotlin { jsMain.get().dependsOn(this) jvmMain.get().dependsOn(this) } + + // Currently just used to make a noop BackHandler + val nonMobileMain by creating { + appleMain.get().dependsOn(this) + jsMain.get().dependsOn(this) + jvmMain.get().dependsOn(this) + dependsOn(commonMain.get()) + } } } @@ -103,10 +111,6 @@ kotlin { * the language version here fixes the issue. * TODO: Figure out how to disable the release flag for the sample app */ -// java { -// toolchain.languageVersion.set(null as? JavaLanguageVersion) -// } - tasks.withType { kotlinOptions.jvmTarget = "9" } @@ -119,12 +123,17 @@ android { buildFeatures { viewBinding = true + // This is needed for the layout inspector to work in Kotlin 2.0.0 even though we need it for + // the current Kotlin version we should leave this here when we upgrade to 2.0.0 compose = true } defaultConfig { applicationId = "com.squareup.sample.compose" } + + // In Kotlin 2.0.0 this isn't strictly necessary but it is necessary for the layout inspector to + // work in Kotlin 2.0.0 composeOptions.kotlinCompilerExtensionVersion = libs.versions.androidx.compose.compiler.get() namespace = "com.squareup.sample.compose" diff --git a/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj b/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj index 46a3b6948..3dc199bcf 100644 --- a/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/samples/compose-samples/iosApp/iosApp.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 058557D9273AAEEB004C7B11 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* Preview Assets.xcassets */; }; 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; }; 7555FF83242A565900829871 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7555FF82242A565900829871 /* ContentView.swift */; }; + 86B843592C2F435300048ED6 /* ContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86B843582C2F435300048ED6 /* ContainerViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -20,6 +21,7 @@ 7555FF7B242A565900829871 /* Workflow Compose Samples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Workflow Compose Samples.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF82242A565900829871 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 86B843582C2F435300048ED6 /* ContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerViewController.swift; sourceTree = ""; }; AB3632DC29227652001CCB65 /* Config.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ @@ -72,6 +74,7 @@ children = ( 058557BA273AAA24004C7B11 /* Assets.xcassets */, 7555FF82242A565900829871 /* ContentView.swift */, + 86B843582C2F435300048ED6 /* ContainerViewController.swift */, 7555FF8C242A565B00829871 /* Info.plist */, 2152FB032600AC8F00CF470E /* iOSApp.swift */, 058557D7273AAEEB004C7B11 /* Preview Content */, @@ -185,6 +188,7 @@ buildActionMask = 2147483647; files = ( 2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */, + 86B843592C2F435300048ED6 /* ContainerViewController.swift in Sources */, 7555FF83242A565900829871 /* ContentView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/samples/compose-samples/iosApp/iosApp/ContainerViewController.swift b/samples/compose-samples/iosApp/iosApp/ContainerViewController.swift new file mode 100644 index 000000000..0a8b490f8 --- /dev/null +++ b/samples/compose-samples/iosApp/iosApp/ContainerViewController.swift @@ -0,0 +1,25 @@ +import SwiftUI + +class ContainerViewController: UIViewController { + private let onTouchDown: (CGPoint) -> Void + + init(child: UIViewController, onTouchDown: @escaping (CGPoint) -> Void) { + self.onTouchDown = onTouchDown + super.init(nibName: nil, bundle: nil) + addChild(child) + child.view.frame = view.frame + view.addSubview(child.view) + child.didMove(toParent: self) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + if let startPoint = touches.first?.location(in: nil) { + onTouchDown(startPoint) + } + } +} diff --git a/samples/compose-samples/iosApp/iosApp/ContentView.swift b/samples/compose-samples/iosApp/iosApp/ContentView.swift index dc574782c..fb201e6a8 100644 --- a/samples/compose-samples/iosApp/iosApp/ContentView.swift +++ b/samples/compose-samples/iosApp/iosApp/ContentView.swift @@ -3,16 +3,60 @@ import SwiftUI import ComposeApp struct ComposeView: UIViewControllerRepresentable { + var onSwipe: () -> Void + func makeUIViewController(context: Context) -> UIViewController { - MainViewControllerKt.MainViewController() + let mainViewController = MainViewControllerKt.MainViewController() + + let containerController = ContainerViewController(child: mainViewController) { + context.coordinator.startPoint = $0 + } + + let swipeGestureRecognizer = UISwipeGestureRecognizer( + target: + context.coordinator, action: #selector(Coordinator.handleSwipe) + ) + swipeGestureRecognizer.direction = .right + swipeGestureRecognizer.numberOfTouchesRequired = 1 + containerController.view.addGestureRecognizer(swipeGestureRecognizer) + return containerController } - + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onSwipe: onSwipe) + } + + class Coordinator: NSObject, UIGestureRecognizerDelegate { + var onSwipe: () -> Void + var startPoint: CGPoint? + + init(onSwipe: @escaping () -> Void) { + self.onSwipe = onSwipe + } + + @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + if gesture.state == .ended, let startPoint = startPoint, startPoint.x < 50 { + onSwipe() + } + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + true + } + } } struct ContentView: View { var body: some View { - ComposeView() - .ignoresSafeArea(.keyboard) // Compose has own keyboard handler + ComposeView { + onBackGesture() + } + .ignoresSafeArea(.keyboard) } } + +public func onBackGesture() { + MainViewControllerKt.onBackGesture() +} diff --git a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt index 0029b1d32..0c9c32018 100644 --- a/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt +++ b/samples/compose-samples/src/androidMain/kotlin/com/squareup/sample/compose/DefaultsAndroid.kt @@ -1,5 +1,6 @@ package com.squareup.sample.compose +import androidx.compose.runtime.Composable import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools @@ -14,3 +15,7 @@ actual fun defaultRuntimeConfig(): RuntimeConfig = @OptIn(WorkflowUiExperimentalApi::class) actual fun defaultViewEnvironment(): ViewEnvironment = ViewEnvironment.EMPTY.withComposeInteropSupport() + +@Composable +actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) = + androidx.activity.compose.BackHandler(isEnabled, onBack) diff --git a/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt index 2a1786562..787a3ad79 100644 --- a/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/Defaults.kt @@ -1,5 +1,6 @@ package com.squareup.sample.compose +import androidx.compose.runtime.Composable import com.squareup.workflow1.RuntimeConfig import com.squareup.workflow1.ui.ViewEnvironment import com.squareup.workflow1.ui.WorkflowUiExperimentalApi @@ -8,3 +9,6 @@ expect fun defaultRuntimeConfig(): RuntimeConfig @OptIn(WorkflowUiExperimentalApi::class) expect fun defaultViewEnvironment(): ViewEnvironment + +@Composable +expect fun BackHandler(isEnabled: Boolean = true, onBack: () -> Unit) diff --git a/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt index 750fde405..493fa23e4 100644 --- a/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt +++ b/samples/compose-samples/src/commonMain/kotlin/com/squareup/sample/compose/hellocompose/App.kt @@ -36,4 +36,3 @@ private val viewEnvironment = defaultViewEnvironment() ) } } - diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/DefaultsIos.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/DefaultsIos.kt new file mode 100644 index 000000000..1790cdf0d --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/DefaultsIos.kt @@ -0,0 +1,16 @@ +package com.squareup.sample.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import store + +@Composable +actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) { + LaunchedEffect(isEnabled) { + if (isEnabled) { + store.events.collect { + onBack() + } + } + } +} diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt index ba6a00127..b5a2d9063 100644 --- a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/MainViewController.kt @@ -1,10 +1,37 @@ -import androidx.compose.foundation.text.BasicText +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.window.ComposeUIViewController +import com.squareup.sample.compose.defaultViewEnvironment +import com.squareup.sample.compose.launcher.SampleWorkflow +import com.squareup.sample.compose.states.Action +import com.squareup.sample.compose.states.Store +import com.squareup.sample.compose.states.createStore +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.renderAsState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.withContext fun MainViewController() = ComposeUIViewController { App() } +val store: Store = CoroutineScope(SupervisorJob()).createStore() + +@OptIn(WorkflowUiExperimentalApi::class) @Composable fun App() { - BasicText("Hello World") + MaterialTheme { + val rendering by SampleWorkflow.renderAsState(Unit) {} + + WorkflowRendering( + rendering, + defaultViewEnvironment() + ) + } +} + +fun onBackGesture() { + store.send(Action.OnBackPressed) } diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingApp.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingApp.kt new file mode 100644 index 000000000..a0cc88754 --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/inlinerendering/InlineRenderingApp.kt @@ -0,0 +1,15 @@ +package com.squareup.sample.compose.inlinerendering + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import com.squareup.sample.compose.defaultViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.compose.renderAsState + +@OptIn(WorkflowUiExperimentalApi::class) +@Composable +fun InlineRenderingApp() { + val rendering by InlineRenderingWorkflow.renderAsState(Unit) {} + WorkflowRendering(rendering, defaultViewEnvironment()) +} diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleScreen.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleScreen.kt new file mode 100644 index 000000000..cd5d076f5 --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleScreen.kt @@ -0,0 +1,44 @@ +@file:OptIn(WorkflowUiExperimentalApi::class) + +package com.squareup.sample.compose.launcher + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ComposeScreen + +data class SampleScreen(val onSampleClicked: (Sample) -> Unit) : ComposeScreen { + @Composable + override fun Content(viewEnvironment: ViewEnvironment) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxSize().padding(16.dp) + ) { + items(samples) { sample -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onSampleClicked(sample) } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(sample.name, style = MaterialTheme.typography.h6) + Text(sample.description, style = MaterialTheme.typography.body1) + } + } + } + } + } +} diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleWorkflow.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleWorkflow.kt new file mode 100644 index 000000000..8c4ab92de --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/SampleWorkflow.kt @@ -0,0 +1,60 @@ +@file:OptIn(WorkflowUiExperimentalApi::class) + +package com.squareup.sample.compose.launcher + +import androidx.compose.runtime.Composable +import com.squareup.sample.compose.BackHandler +import com.squareup.sample.compose.hellocomposebinding.HelloWorkflow +import com.squareup.sample.compose.hellocomposebinding.viewEnvironment +import com.squareup.sample.compose.hellocomposeworkflow.HelloComposeWorkflow +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.WorkflowAction.Companion.noAction +import com.squareup.workflow1.mapRendering +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.ViewEnvironment +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.compose.ComposeScreen +import com.squareup.workflow1.ui.compose.WorkflowRendering +import com.squareup.workflow1.ui.withEnvironment + +object SampleWorkflow : StatefulWorkflow() { + override fun render( + renderProps: Unit, + renderState: Sample?, + context: RenderContext + ): Screen { + val screen = when (val workflow = renderState?.workflow) { + null -> SampleScreen(context.eventHandler { sample -> state = sample }) + is HelloComposeWorkflow -> context.renderChild( + child = workflow, + props = "Hello", + ) { noAction() } + + is HelloWorkflow -> context.renderChild( + child = workflow.mapRendering { it.withEnvironment(viewEnvironment) } + ) { noAction() } + + else -> context.renderChild(child = workflow as Workflow) + } + + return BackHandlerScreen(screen, onBack = context.eventHandler { state = null }) + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ) = null + + override fun snapshotState(state: Sample?): Snapshot? = null +} + +data class BackHandlerScreen(val screen: Screen, val onBack: () -> Unit) : ComposeScreen { + @Composable + override fun Content(viewEnvironment: ViewEnvironment) { + BackHandler(onBack = onBack) + WorkflowRendering(screen, viewEnvironment) + } +} diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/Samples.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/Samples.kt new file mode 100644 index 000000000..eef5f6348 --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/launcher/Samples.kt @@ -0,0 +1,52 @@ +package com.squareup.sample.compose.launcher + +import com.squareup.sample.compose.inlinerendering.InlineRenderingWorkflow +import com.squareup.workflow1.Workflow +import com.squareup.workflow1.ui.Screen +import com.squareup.workflow1.ui.WorkflowUiExperimentalApi + +@OptIn(WorkflowUiExperimentalApi::class) +val samples = listOf( + Sample( + "Compose Workflow", + "Demonstrates a special implementation of Workflow that lets the workflow define " + + "its own composable content inline.", + com.squareup.sample.compose.hellocomposeworkflow.HelloWorkflow + ), + Sample( + "Hello Compose", + "A pure Compose app that launches its root Workflow from inside Compose.", + com.squareup.sample.compose.hellocompose.HelloComposeWorkflow + ), + Sample( + "Hello Compose Binding", + "Binds a Screen to a UI factory using ScreenComposableFactory().", + com.squareup.sample.compose.hellocomposebinding.HelloWorkflow + ), + + // TODO: Migrate :workflow-ui:compose-tooling to be multiplatform to display this sample + // since both of these samples utilize the special preview view environments + // Sample( + // "Text Input", + // "Demonstrates a workflow that drives a TextField.", + // TextInputWorkflow + // ), + // + // + // Sample( + // "ViewFactory Preview", + // "Demonstrates displaying @Previews of ViewFactories.", + // ComposeRendering(previewContactRendering) + // ), + Sample( + "Inline ComposeRendering", + "Demonstrates a workflow that returns an anonymous ComposeRendering.", + InlineRenderingWorkflow + ) +) + +data class Sample @OptIn(WorkflowUiExperimentalApi::class) constructor( + val name: String, + val description: String, + val workflow: Workflow<*, *, Screen> +) diff --git a/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/states/Store.kt b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/states/Store.kt new file mode 100644 index 000000000..d477f548a --- /dev/null +++ b/samples/compose-samples/src/iosMain/kotlin/com/squareup/sample/compose/states/Store.kt @@ -0,0 +1,30 @@ +package com.squareup.sample.compose.states + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +sealed interface Action { + data object OnBackPressed : Action +} + +interface Store { + fun send(action: Action) + val events: SharedFlow +} + +fun CoroutineScope.createStore(): Store { + val events = MutableSharedFlow() + + return object : Store { + override fun send(action: Action) { + launch { + events.emit(action) + } + } + + override val events: SharedFlow = events.asSharedFlow() + } +} diff --git a/samples/compose-samples/src/nonMobileMain/kotlin/com/squareup/sample/compose/DefaultsNonMobile.kt b/samples/compose-samples/src/nonMobileMain/kotlin/com/squareup/sample/compose/DefaultsNonMobile.kt new file mode 100644 index 000000000..05925923f --- /dev/null +++ b/samples/compose-samples/src/nonMobileMain/kotlin/com/squareup/sample/compose/DefaultsNonMobile.kt @@ -0,0 +1,6 @@ +package com.squareup.sample.compose + +import androidx.compose.runtime.Composable + +@Composable +actual fun BackHandler(isEnabled: Boolean, onBack: () -> Unit) = Unit From 41a851c03b93074cb68af72d3fab670d2d385285 Mon Sep 17 00:00:00 2001 From: blakelee Date: Fri, 28 Jun 2024 20:52:47 +0000 Subject: [PATCH 11/15] Apply changes from dependencyGuardBaseline --refresh-dependencies Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- dependencies/classpath.txt | 46 ++++++---- .../dependencies/runtimeClasspath.txt | 1 - .../dependencies/jsRuntimeClasspath.txt | 1 - .../dependencies/jsRuntimeClasspath.txt | 1 - .../dependencies/releaseRuntimeClasspath.txt | 86 ++++++++++++------- .../dependencies/releaseRuntimeClasspath.txt | 22 +++-- .../core/dependencies/jsRuntimeClasspath.txt | 1 - .../dependencies/releaseRuntimeClasspath.txt | 22 +++-- 8 files changed, 113 insertions(+), 67 deletions(-) diff --git a/dependencies/classpath.txt b/dependencies/classpath.txt index 178896e45..8357f9ab6 100644 --- a/dependencies/classpath.txt +++ b/dependencies/classpath.txt @@ -41,6 +41,11 @@ com.android.tools:sdk-common:31.1.2 com.android.tools:sdklib:31.1.2 com.android:signflinger:8.1.2 com.android:zipflinger:8.1.2 +com.autonomousapps.dependency-analysis:com.autonomousapps.dependency-analysis.gradle.plugin:1.32.0 +com.autonomousapps:antlr:4.10.1.6 +com.autonomousapps:asm-relocated:9.6.0.1 +com.autonomousapps:dependency-analysis-gradle-plugin:1.32.0 +com.autonomousapps:graph-support:0.2 com.dropbox.dependency-guard:dependency-guard:0.4.3 com.fasterxml.jackson.core:jackson-annotations:2.12.7 com.fasterxml.jackson.core:jackson-core:2.12.7 @@ -50,6 +55,7 @@ com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.12.7 com.fasterxml.jackson.module:jackson-module-kotlin:2.12.7 com.fasterxml.jackson:jackson-bom:2.12.7 com.fasterxml.woodstox:woodstox-core:6.2.4 +com.github.ben-manes.caffeine:caffeine:3.1.8 com.google.android:annotations:4.1.1.4 com.google.api.grpc:proto-google-common-protos:2.0.1 com.google.auto.value:auto-value-annotations:1.6.2 @@ -57,11 +63,13 @@ com.google.code.findbugs:jsr305:3.0.2 com.google.code.gson:gson:2.8.9 com.google.crypto.tink:tink:1.7.0 com.google.dagger:dagger:2.28.3 +com.google.devtools.ksp:symbol-processing-api:1.9.24-1.0.20 +com.google.devtools.ksp:symbol-processing-common-deps:1.9.24-1.0.20 com.google.devtools.ksp:symbol-processing-gradle-plugin:1.9.24-1.0.20 -com.google.errorprone:error_prone_annotations:2.11.0 +com.google.errorprone:error_prone_annotations:2.26.1 com.google.flatbuffers:flatbuffers-java:1.12.0 -com.google.guava:failureaccess:1.0.1 -com.google.guava:guava:31.1-jre +com.google.guava:failureaccess:1.0.2 +com.google.guava:guava:33.1.0-jre com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava com.google.j2objc:j2objc-annotations:1.3 com.google.jimfs:jimfs:1.1 @@ -75,14 +83,18 @@ com.rickbusarow.kgx:names:0.1.12 com.rickbusarow.ktlint:com.rickbusarow.ktlint.gradle.plugin:0.2.2 com.rickbusarow.ktlint:ktlint-gradle-plugin:0.2.2 com.squareup.moshi:moshi-adapters:1.15.0 -com.squareup.moshi:moshi:1.15.0 +com.squareup.moshi:moshi-kotlin:1.15.1 +com.squareup.moshi:moshi:1.15.1 com.squareup.okhttp3:okhttp:4.12.0 -com.squareup.okio:okio-jvm:3.6.0 -com.squareup.okio:okio:3.6.0 +com.squareup.okio:okio-bom:3.9.0 +com.squareup.okio:okio-jvm:3.9.0 +com.squareup.okio:okio:3.9.0 com.squareup.retrofit2:converter-moshi:2.9.0 com.squareup.retrofit2:retrofit:2.9.0 com.squareup:javapoet:1.10.0 com.squareup:javawriter:2.5.0 +com.squareup:kotlinpoet-jvm:1.15.1 +com.squareup:kotlinpoet:1.15.1 com.sun.activation:javax.activation:1.2.0 com.sun.istack:istack-commons-runtime:3.0.8 com.sun.xml.fastinfoset:FastInfoset:1.2.16 @@ -91,6 +103,8 @@ com.vanniktech:nexus:0.27.0 commons-codec:commons-codec:1.11 commons-io:commons-io:2.4 commons-logging:commons-logging:1.2 +dev.zacsweers.moshix:moshi-sealed-reflect:0.25.1 +dev.zacsweers.moshix:moshi-sealed-runtime:0.25.1 io.github.java-diff-utils:java-diff-utils:4.12 io.grpc:grpc-api:1.45.1 io.grpc:grpc-context:1.45.1 @@ -126,14 +140,14 @@ org.apache.httpcomponents:httpmime:4.5.6 org.bitbucket.b_c:jose4j:0.7.0 org.bouncycastle:bcpkix-jdk15on:1.67 org.bouncycastle:bcprov-jdk15on:1.67 -org.checkerframework:checker-qual:3.12.0 +org.checkerframework:checker-qual:3.42.0 org.codehaus.mojo:animal-sniffer-annotations:1.19 org.codehaus.woodstox:stax2-api:4.2.1 org.glassfish.jaxb:jaxb-runtime:2.3.2 org.glassfish.jaxb:txw2:2.3.2 org.jdom:jdom2:2.0.6 -org.jetbrains.dokka:dokka-core:1.9.24 -org.jetbrains.dokka:dokka-gradle-plugin:1.9.24 +org.jetbrains.dokka:dokka-core:1.9.20 +org.jetbrains.dokka:dokka-gradle-plugin:1.9.20 org.jetbrains.intellij.deps:trove4j:1.0.20200330 org.jetbrains.kotlin:kotlin-android-extensions:1.9.24 org.jetbrains.kotlin:kotlin-bom:1.9.24 @@ -152,24 +166,24 @@ org.jetbrains.kotlin:kotlin-gradle-plugins-bom:1.9.24 org.jetbrains.kotlin:kotlin-klib-commonizer-api:1.9.24 org.jetbrains.kotlin:kotlin-native-utils:1.9.24 org.jetbrains.kotlin:kotlin-project-model:1.9.24 -org.jetbrains.kotlin:kotlin-reflect:1.9.24 +org.jetbrains.kotlin:kotlin-reflect:1.9.10 org.jetbrains.kotlin:kotlin-scripting-common:1.9.24 org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:1.9.24 org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:1.9.24 org.jetbrains.kotlin:kotlin-scripting-jvm:1.9.24 org.jetbrains.kotlin:kotlin-serialization:1.9.24 -org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 -org.jetbrains.kotlin:kotlin-stdlib:1.9.24 +org.jetbrains.kotlin:kotlin-stdlib:1.9.10 org.jetbrains.kotlin:kotlin-tooling-core:1.9.24 org.jetbrains.kotlin:kotlin-util-io:1.9.24 org.jetbrains.kotlin:kotlin-util-klib:1.9.24 org.jetbrains.kotlinx:binary-compatibility-validator:0.13.2 -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.6.3 -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.6.3 -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3 -org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.6.2 +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.kotlinx:kotlinx-metadata-jvm:0.9.0 org.jetbrains:annotations:13.0 org.jvnet.staxex:stax-ex:1.8.1 org.ow2.asm:asm-analysis:9.2 diff --git a/internal-testing-utils/dependencies/runtimeClasspath.txt b/internal-testing-utils/dependencies/runtimeClasspath.txt index 3cb16cdb0..0b32e703e 100644 --- a/internal-testing-utils/dependencies/runtimeClasspath.txt +++ b/internal-testing-utils/dependencies/runtimeClasspath.txt @@ -1,5 +1,4 @@ org.jetbrains.kotlin:kotlin-bom:1.9.24 -org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24 org.jetbrains.kotlin:kotlin-stdlib:1.9.24 diff --git a/workflow-core/dependencies/jsRuntimeClasspath.txt b/workflow-core/dependencies/jsRuntimeClasspath.txt index 9edbfbcf1..a5e6dd8ce 100644 --- a/workflow-core/dependencies/jsRuntimeClasspath.txt +++ b/workflow-core/dependencies/jsRuntimeClasspath.txt @@ -8,4 +8,3 @@ 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-runtime/dependencies/jsRuntimeClasspath.txt b/workflow-runtime/dependencies/jsRuntimeClasspath.txt index 9edbfbcf1..a5e6dd8ce 100644 --- a/workflow-runtime/dependencies/jsRuntimeClasspath.txt +++ b/workflow-runtime/dependencies/jsRuntimeClasspath.txt @@ -8,4 +8,3 @@ 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/compose-tooling/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt index a3ea2fb39..fdfa30543 100644 --- a/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/compose-tooling/dependencies/releaseRuntimeClasspath.txt @@ -1,41 +1,63 @@ -androidx.activity:activity-compose:1.6.1 -androidx.activity:activity-ktx:1.6.1 -androidx.activity:activity:1.6.1 +androidx.activity:activity-compose:1.7.0 +androidx.activity:activity-ktx:1.7.0 +androidx.activity:activity:1.7.0 androidx.annotation:annotation-experimental:1.3.0 -androidx.annotation:annotation-jvm:1.6.0 -androidx.annotation:annotation:1.6.0 +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.autofill:autofill:1.0.0 -androidx.collection:collection:1.1.0 -androidx.compose.animation:animation-core:1.3.3 -androidx.compose.animation:animation:1.3.3 -androidx.compose.foundation:foundation-layout:1.3.1 -androidx.compose.foundation:foundation:1.3.1 -androidx.compose.runtime:runtime-saveable:1.3.3 -androidx.compose.runtime:runtime:1.3.3 -androidx.compose.ui:ui-geometry:1.3.3 -androidx.compose.ui:ui-graphics:1.3.3 -androidx.compose.ui:ui-text:1.3.3 -androidx.compose.ui:ui-tooling-preview:1.3.3 -androidx.compose.ui:ui-unit:1.3.3 -androidx.compose.ui:ui-util:1.3.3 -androidx.compose.ui:ui:1.3.3 -androidx.compose:compose-bom:2023.01.00 +androidx.collection:collection-jvm:1.4.0 +androidx.collection:collection-ktx:1.4.0 +androidx.collection:collection:1.4.0 +androidx.compose.animation:animation-android:1.6.7 +androidx.compose.animation:animation-core-android:1.6.7 +androidx.compose.animation:animation-core:1.6.7 +androidx.compose.animation:animation:1.6.7 +androidx.compose.foundation:foundation-android:1.6.7 +androidx.compose.foundation:foundation-layout-android:1.6.7 +androidx.compose.foundation:foundation-layout:1.6.7 +androidx.compose.foundation:foundation:1.6.7 +androidx.compose.runtime:runtime-android:1.6.7 +androidx.compose.runtime:runtime-saveable-android:1.6.7 +androidx.compose.runtime:runtime-saveable:1.6.7 +androidx.compose.runtime:runtime:1.6.7 +androidx.compose.ui:ui-android:1.6.7 +androidx.compose.ui:ui-geometry-android:1.6.7 +androidx.compose.ui:ui-geometry:1.6.7 +androidx.compose.ui:ui-graphics-android:1.6.7 +androidx.compose.ui:ui-graphics:1.6.7 +androidx.compose.ui:ui-text-android:1.6.7 +androidx.compose.ui:ui-text:1.6.7 +androidx.compose.ui:ui-tooling-preview-android:1.6.7 +androidx.compose.ui:ui-tooling-preview:1.6.7 +androidx.compose.ui:ui-unit-android:1.6.7 +androidx.compose.ui:ui-unit:1.6.7 +androidx.compose.ui:ui-util-android:1.6.7 +androidx.compose.ui:ui-util:1.6.7 +androidx.compose.ui:ui:1.6.7 +androidx.compose:compose-bom:2024.05.00 androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 androidx.customview:customview-poolingcontainer:1.0.0 +androidx.emoji2:emoji2:1.3.0 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common-java8:2.6.1 -androidx.lifecycle:lifecycle-common:2.6.1 -androidx.lifecycle:lifecycle-livedata-core:2.6.1 -androidx.lifecycle:lifecycle-runtime-ktx:2.6.1 -androidx.lifecycle:lifecycle-runtime:2.6.1 -androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1 -androidx.lifecycle:lifecycle-viewmodel:2.6.1 -androidx.profileinstaller:profileinstaller:1.3.0 +androidx.lifecycle:lifecycle-common-jvm:2.8.0 +androidx.lifecycle:lifecycle-common:2.8.0 +androidx.lifecycle:lifecycle-livedata-core:2.8.0 +androidx.lifecycle:lifecycle-process:2.8.0 +androidx.lifecycle:lifecycle-runtime-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-compose-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-compose:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx:2.8.0 +androidx.lifecycle:lifecycle-runtime:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-android:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0 +androidx.lifecycle:lifecycle-viewmodel:2.8.0 +androidx.profileinstaller:profileinstaller:1.3.1 androidx.savedstate:savedstate-ktx:1.2.1 androidx.savedstate:savedstate:1.2.1 androidx.startup:startup-runtime:1.1.1 @@ -45,6 +67,12 @@ androidx.versionedparcelable:versionedparcelable:1.1.1 com.google.guava:listenablefuture:1.0 com.squareup.okio:okio-jvm:3.3.0 com.squareup.okio:okio:3.3.0 +org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose:2.8.0 +org.jetbrains.compose.components:components-ui-tooling-preview-android:1.6.11 +org.jetbrains.compose.components:components-ui-tooling-preview:1.6.11 +org.jetbrains.compose.foundation:foundation:1.6.11 +org.jetbrains.compose.runtime:runtime:1.6.11 +org.jetbrains.compose.ui:ui:1.6.11 org.jetbrains.kotlin:kotlin-bom:1.9.24 org.jetbrains.kotlin:kotlin-stdlib-common:1.9.24 org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.24 diff --git a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt index e997cccb6..a63664772 100644 --- a/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/core-android/dependencies/releaseRuntimeClasspath.txt @@ -1,7 +1,7 @@ androidx.activity:activity:1.6.1 androidx.annotation:annotation-experimental:1.3.0 -androidx.annotation:annotation-jvm:1.6.0 -androidx.annotation:annotation:1.6.0 +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.collection:collection:1.1.0 @@ -9,13 +9,17 @@ androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common:2.6.1 -androidx.lifecycle:lifecycle-livedata-core:2.6.1 -androidx.lifecycle:lifecycle-runtime-ktx:2.6.1 -androidx.lifecycle:lifecycle-runtime:2.6.1 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1 -androidx.lifecycle:lifecycle-viewmodel:2.6.1 -androidx.profileinstaller:profileinstaller:1.3.0 +androidx.lifecycle:lifecycle-common-jvm:2.8.0 +androidx.lifecycle:lifecycle-common:2.8.0 +androidx.lifecycle:lifecycle-livedata-core:2.8.0 +androidx.lifecycle:lifecycle-runtime-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx:2.8.0 +androidx.lifecycle:lifecycle-runtime:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-android:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0 +androidx.lifecycle:lifecycle-viewmodel:2.8.0 +androidx.profileinstaller:profileinstaller:1.3.1 androidx.savedstate:savedstate:1.2.1 androidx.startup:startup-runtime:1.1.1 androidx.tracing:tracing:1.0.0 diff --git a/workflow-ui/core/dependencies/jsRuntimeClasspath.txt b/workflow-ui/core/dependencies/jsRuntimeClasspath.txt index a34d1c638..029bf6660 100644 --- a/workflow-ui/core/dependencies/jsRuntimeClasspath.txt +++ b/workflow-ui/core/dependencies/jsRuntimeClasspath.txt @@ -6,4 +6,3 @@ 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/radiography/dependencies/releaseRuntimeClasspath.txt b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt index 8160b295c..30d07fe27 100644 --- a/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt +++ b/workflow-ui/radiography/dependencies/releaseRuntimeClasspath.txt @@ -1,7 +1,7 @@ androidx.activity:activity:1.6.1 androidx.annotation:annotation-experimental:1.3.0 -androidx.annotation:annotation-jvm:1.6.0 -androidx.annotation:annotation:1.6.0 +androidx.annotation:annotation-jvm:1.8.0 +androidx.annotation:annotation:1.8.0 androidx.arch.core:core-common:2.2.0 androidx.arch.core:core-runtime:2.2.0 androidx.collection:collection:1.1.0 @@ -9,13 +9,17 @@ androidx.concurrent:concurrent-futures:1.1.0 androidx.core:core-ktx:1.12.0 androidx.core:core:1.12.0 androidx.interpolator:interpolator:1.0.0 -androidx.lifecycle:lifecycle-common:2.6.1 -androidx.lifecycle:lifecycle-livedata-core:2.6.1 -androidx.lifecycle:lifecycle-runtime-ktx:2.6.1 -androidx.lifecycle:lifecycle-runtime:2.6.1 -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.6.1 -androidx.lifecycle:lifecycle-viewmodel:2.6.1 -androidx.profileinstaller:profileinstaller:1.3.0 +androidx.lifecycle:lifecycle-common-jvm:2.8.0 +androidx.lifecycle:lifecycle-common:2.8.0 +androidx.lifecycle:lifecycle-livedata-core:2.8.0 +androidx.lifecycle:lifecycle-runtime-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.0 +androidx.lifecycle:lifecycle-runtime-ktx:2.8.0 +androidx.lifecycle:lifecycle-runtime:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-android:2.8.0 +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.8.0 +androidx.lifecycle:lifecycle-viewmodel:2.8.0 +androidx.profileinstaller:profileinstaller:1.3.1 androidx.savedstate:savedstate:1.2.1 androidx.startup:startup-runtime:1.1.1 androidx.tracing:tracing:1.0.0 From ffc887690e2970305010efb2aa7ee3ff12acf1bd Mon Sep 17 00:00:00 2001 From: RBusarow Date: Fri, 28 Jun 2024 20:56:20 +0000 Subject: [PATCH 12/15] Apply changes from apiDump Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- workflow-ui/compose/api/android/compose.api | 78 +++++++++++++++++++++ workflow-ui/compose/api/jvm/compose.api | 69 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 workflow-ui/compose/api/android/compose.api create mode 100644 workflow-ui/compose/api/jvm/compose.api diff --git a/workflow-ui/compose/api/android/compose.api b/workflow-ui/compose/api/android/compose.api new file mode 100644 index 000000000..e1d73482a --- /dev/null +++ b/workflow-ui/compose/api/android/compose.api @@ -0,0 +1,78 @@ +public final class com/squareup/workflow1/ui/compose/ComposableSingletons$ScreenComposableFactoryFinderKt { + public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/ComposableSingletons$ScreenComposableFactoryFinderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-2 Lkotlin/jvm/functions/Function4; + public static field lambda-3 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$wf1_compose ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-2$wf1_compose ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-3$wf1_compose ()Lkotlin/jvm/functions/Function4; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ComposeScreen : com/squareup/workflow1/ui/Screen { + public abstract fun Content (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/squareup/workflow1/ui/compose/ComposeScreenKt { + public static final fun ComposeScreen (Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ComposeScreen; +} + +public final class com/squareup/workflow1/ui/compose/CompositionRootKt { + public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ViewEnvironment; + public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder; +} + +public final class com/squareup/workflow1/ui/compose/RenderAsStateKt { + public static final fun renderAsState (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/util/List;Lkotlinx/coroutines/CoroutineScope;Ljava/util/Set;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ScreenComposableFactory : com/squareup/workflow1/ui/ViewRegistry$Entry { + public abstract fun Content (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; + public abstract fun getType ()Lkotlin/reflect/KClass; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactory$DefaultImpls { + public static fun getKey (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory;)Lcom/squareup/workflow1/ui/ViewRegistry$Key; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryAndroidKt { + public static final fun asComposableFactory (Lcom/squareup/workflow1/ui/ScreenViewFactory;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; + public static final fun asViewFactory (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory;)Lcom/squareup/workflow1/ui/ScreenViewFactory; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder { + public static final field Companion Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$Companion; + public abstract fun getComposableFactoryForRendering (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$Companion : com/squareup/workflow1/ui/ViewEnvironmentKey { + public fun getDefault ()Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$DefaultImpls { + public static fun getComposableFactoryForRendering (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinderKt { + public static final fun requireComposableFactoryForRendering (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryKt { + public static final fun ScreenComposableFactory (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; + public static final fun toComposableFactory (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStateKt { + public static final fun asMutableState (Lcom/squareup/workflow1/ui/TextController;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState; +} + +public final class com/squareup/workflow1/ui/compose/ViewEnvironmentWithComposeSupportKt { + public static final fun withComposeInteropSupport (Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/ViewEnvironment; +} + +public final class com/squareup/workflow1/ui/compose/WorkflowRenderingKt { + public static final fun WorkflowRendering (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/workflow-ui/compose/api/jvm/compose.api b/workflow-ui/compose/api/jvm/compose.api new file mode 100644 index 000000000..ebe74e6df --- /dev/null +++ b/workflow-ui/compose/api/jvm/compose.api @@ -0,0 +1,69 @@ +public final class com/squareup/workflow1/ui/compose/ComposableSingletons$ScreenComposableFactoryFinderKt { + public static final field INSTANCE Lcom/squareup/workflow1/ui/compose/ComposableSingletons$ScreenComposableFactoryFinderKt; + public static field lambda-1 Lkotlin/jvm/functions/Function4; + public static field lambda-2 Lkotlin/jvm/functions/Function4; + public static field lambda-3 Lkotlin/jvm/functions/Function4; + public fun ()V + public final fun getLambda-1$wf1_compose ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-2$wf1_compose ()Lkotlin/jvm/functions/Function4; + public final fun getLambda-3$wf1_compose ()Lkotlin/jvm/functions/Function4; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ComposeScreen : com/squareup/workflow1/ui/Screen { + public abstract fun Content (Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V +} + +public final class com/squareup/workflow1/ui/compose/ComposeScreenKt { + public static final fun ComposeScreen (Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ComposeScreen; +} + +public final class com/squareup/workflow1/ui/compose/CompositionRootKt { + public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/ViewEnvironment; + public static final fun withCompositionRoot (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder; +} + +public final class com/squareup/workflow1/ui/compose/RenderAsStateKt { + public static final fun renderAsState (Lcom/squareup/workflow1/Workflow;Ljava/lang/Object;Ljava/util/List;Lkotlinx/coroutines/CoroutineScope;Ljava/util/Set;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ScreenComposableFactory : com/squareup/workflow1/ui/ViewRegistry$Entry { + public abstract fun Content (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V + public abstract fun getKey ()Lcom/squareup/workflow1/ui/ViewRegistry$Key; + public abstract fun getType ()Lkotlin/reflect/KClass; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactory$DefaultImpls { + public static fun getKey (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory;)Lcom/squareup/workflow1/ui/ViewRegistry$Key; +} + +public abstract interface class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder { + public static final field Companion Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$Companion; + public abstract fun getComposableFactoryForRendering (Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$Companion : com/squareup/workflow1/ui/ViewEnvironmentKey { + public fun getDefault ()Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder; + public synthetic fun getDefault ()Ljava/lang/Object; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder$DefaultImpls { + public static fun getComposableFactoryForRendering (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryFinderKt { + public static final fun requireComposableFactoryForRendering (Lcom/squareup/workflow1/ui/compose/ScreenComposableFactoryFinder;Lcom/squareup/workflow1/ui/ViewEnvironment;Lcom/squareup/workflow1/ui/Screen;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/ScreenComposableFactoryKt { + public static final fun ScreenComposableFactory (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; + public static final fun toComposableFactory (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;)Lcom/squareup/workflow1/ui/compose/ScreenComposableFactory; +} + +public final class com/squareup/workflow1/ui/compose/TextControllerAsMutableStateKt { + public static final fun asMutableState (Lcom/squareup/workflow1/ui/TextController;Landroidx/compose/runtime/Composer;I)Landroidx/compose/runtime/MutableState; +} + +public final class com/squareup/workflow1/ui/compose/WorkflowRenderingKt { + public static final fun WorkflowRendering (Lcom/squareup/workflow1/ui/Screen;Lcom/squareup/workflow1/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + From 0fb1c90349751de6c64a69cb902999cc254f884c Mon Sep 17 00:00:00 2001 From: RBusarow Date: Fri, 28 Jun 2024 20:59:39 +0000 Subject: [PATCH 13/15] Apply changes from artifactsDump Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- artifacts.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/artifacts.json b/artifacts.json index 8c9f90989..0128a04ea 100644 --- a/artifacts.json +++ b/artifacts.json @@ -170,15 +170,6 @@ "javaVersion": 8, "publicationName": "maven" }, - { - "gradlePath": ":workflow-ui:compose", - "group": "com.squareup.workflow1", - "artifactId": "workflow-ui-compose", - "description": "Workflow UI Compose", - "packaging": "aar", - "javaVersion": 8, - "publicationName": "maven" - }, { "gradlePath": ":workflow-ui:compose-tooling", "group": "com.squareup.workflow1", From b668657ea7649334b8ffd8597de620ae62e516d3 Mon Sep 17 00:00:00 2001 From: Blake Oliveira Date: Fri, 28 Jun 2024 15:44:06 -0700 Subject: [PATCH 14/15] Changing dependencies to rely on workflow-ui:common instead of workflow-ui:common-core * Fix some lint issues --- artifacts.json | 4 ++-- .../performance-poetry/complex-poetry/build.gradle.kts | 2 +- .../squareup/workflow1/buildsrc/AndroidSampleAppPlugin.kt | 6 +++--- samples/compose-samples/README.md | 7 +++---- samples/compose-samples/build.gradle.kts | 1 - samples/containers/android/build.gradle.kts | 2 +- samples/containers/common/build.gradle.kts | 2 +- samples/containers/hello-back-button/build.gradle.kts | 2 +- samples/containers/poetry/build.gradle.kts | 2 +- samples/dungeon/app/build.gradle.kts | 2 +- samples/dungeon/common/build.gradle.kts | 2 +- samples/dungeon/timemachine-shakeable/build.gradle.kts | 2 +- samples/hello-workflow-fragment/build.gradle.kts | 2 +- samples/hello-workflow/build.gradle.kts | 2 +- samples/nested-overlays/build.gradle.kts | 2 +- samples/stub-visibility/build.gradle.kts | 2 +- samples/tictactoe/app/build.gradle.kts | 2 +- samples/tictactoe/common/build.gradle.kts | 2 +- samples/todo-android/app/build.gradle.kts | 2 +- workflow-ui/compose-tooling/build.gradle.kts | 2 +- workflow-ui/core-android/build.gradle.kts | 2 +- workflow-ui/internal-testing-android/build.gradle.kts | 2 +- workflow-ui/radiography/build.gradle.kts | 2 +- 23 files changed, 27 insertions(+), 29 deletions(-) diff --git a/artifacts.json b/artifacts.json index 0128a04ea..4705f9acc 100644 --- a/artifacts.json +++ b/artifacts.json @@ -243,7 +243,7 @@ "publicationName": "maven" }, { - "gradlePath": ":workflow-ui:core-common", + "gradlePath": ":workflow-ui:core", "group": "com.squareup.workflow1", "artifactId": "workflow-ui-core-common-jvm", "description": "Workflow UI Core", @@ -260,4 +260,4 @@ "javaVersion": 8, "publicationName": "maven" } -] \ No newline at end of file +] diff --git a/benchmarks/performance-poetry/complex-poetry/build.gradle.kts b/benchmarks/performance-poetry/complex-poetry/build.gradle.kts index a228c7cfc..874960cd9 100644 --- a/benchmarks/performance-poetry/complex-poetry/build.gradle.kts +++ b/benchmarks/performance-poetry/complex-poetry/build.gradle.kts @@ -64,7 +64,7 @@ dependencies { api(project(":workflow-core")) api(project(":workflow-runtime")) api(project(":workflow-ui:core-android")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) debugImplementation(libs.squareup.leakcanary.android) diff --git a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidSampleAppPlugin.kt b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidSampleAppPlugin.kt index eef6fcb5f..90cc1f958 100644 --- a/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidSampleAppPlugin.kt +++ b/build-logic/src/main/java/com/squareup/workflow1/buildsrc/AndroidSampleAppPlugin.kt @@ -2,7 +2,7 @@ package com.squareup.workflow1.buildsrc import com.android.build.gradle.TestedExtension import com.android.build.gradle.internal.dsl.BaseAppModuleExtension -import com.rickbusarow.kgx.dependency +import com.rickbusarow.kgx.library import com.rickbusarow.kgx.libsCatalog import com.squareup.workflow1.buildsrc.internal.implementation import com.squareup.workflow1.buildsrc.internal.invoke @@ -31,8 +31,8 @@ class AndroidSampleAppPlugin : Plugin { implementation(target.project(":workflow-runtime")) implementation(target.project(":workflow-config:config-android")) - implementation(target.libsCatalog.dependency("androidx-appcompat")) - implementation(target.libsCatalog.dependency("timber")) + implementation(target.libsCatalog.library("androidx-appcompat")) + implementation(target.libsCatalog.library("timber")) } } } diff --git a/samples/compose-samples/README.md b/samples/compose-samples/README.md index 87bd9e664..f5915d784 100644 --- a/samples/compose-samples/README.md +++ b/samples/compose-samples/README.md @@ -3,12 +3,11 @@ This module is named "compose-samples" because the binary validation tool seems to refuse to look at the `:workflow-ui:compose` module if this one is also named `compose`. +## iOS -# iOS 1. To run the iOS target you need to be on a Mac and have Xcode installed. -2. Then install the Kotlin Multiplatform plugin for your intellij IDE -3. Finally go to run configurations and add a new iOS Application configuration and select the iOS -project file in ./iosApp +1. Then install the Kotlin Multiplatform plugin for your intellij IDE +1. Finally go to run configurations and add a new iOS Application configuration and select the iOS project file in ./iosApp [To enable iOS debugging](https://appkickstarter.com/blog/debug-an-ios-kotlin-multiplatform-app-from-android-studio/) open Settings -> Advanced Settings -> Enable experimental Multiplatform IDE features diff --git a/samples/compose-samples/build.gradle.kts b/samples/compose-samples/build.gradle.kts index a12718bdb..3f13fcd05 100644 --- a/samples/compose-samples/build.gradle.kts +++ b/samples/compose-samples/build.gradle.kts @@ -73,7 +73,6 @@ kotlin { implementation(project(":workflow-config:config-android")) implementation(project(":workflow-ui:compose-tooling")) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) } val iosX64Main by getting diff --git a/samples/containers/android/build.gradle.kts b/samples/containers/android/build.gradle.kts index bfa449036..93b33534e 100644 --- a/samples/containers/android/build.gradle.kts +++ b/samples/containers/android/build.gradle.kts @@ -27,7 +27,7 @@ dependencies { api(project(":samples:containers:common")) api(project(":workflow-ui:core-android")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.androidx.appcompat) implementation(libs.androidx.core) diff --git a/samples/containers/common/build.gradle.kts b/samples/containers/common/build.gradle.kts index 6616f0991..8a75c69aa 100644 --- a/samples/containers/common/build.gradle.kts +++ b/samples/containers/common/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.kotlin.jdk6) diff --git a/samples/containers/hello-back-button/build.gradle.kts b/samples/containers/hello-back-button/build.gradle.kts index 6cd6a7118..21218a84f 100644 --- a/samples/containers/hello-back-button/build.gradle.kts +++ b/samples/containers/hello-back-button/build.gradle.kts @@ -20,5 +20,5 @@ dependencies { implementation(project(":samples:containers:android")) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/samples/containers/poetry/build.gradle.kts b/samples/containers/poetry/build.gradle.kts index e362d69a1..3872cc39e 100644 --- a/samples/containers/poetry/build.gradle.kts +++ b/samples/containers/poetry/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { api(project(":samples:containers:common")) api(project(":workflow-core")) api(project(":workflow-ui:core-android")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.androidx.appcompat) implementation(libs.androidx.recyclerview) diff --git a/samples/dungeon/app/build.gradle.kts b/samples/dungeon/app/build.gradle.kts index 0c15f339a..624250147 100644 --- a/samples/dungeon/app/build.gradle.kts +++ b/samples/dungeon/app/build.gradle.kts @@ -49,7 +49,7 @@ dependencies { implementation(project(":samples:dungeon:timemachine-shakeable")) implementation(project(":workflow-tracing")) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/samples/dungeon/common/build.gradle.kts b/samples/dungeon/common/build.gradle.kts index c635fbdd4..e85a53c80 100644 --- a/samples/dungeon/common/build.gradle.kts +++ b/samples/dungeon/common/build.gradle.kts @@ -7,7 +7,7 @@ dependencies { api(libs.squareup.okio) api(project(":workflow-core")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.kotlin.jdk8) implementation(libs.kotlinx.coroutines.core) diff --git a/samples/dungeon/timemachine-shakeable/build.gradle.kts b/samples/dungeon/timemachine-shakeable/build.gradle.kts index be1aeab47..4535fc332 100644 --- a/samples/dungeon/timemachine-shakeable/build.gradle.kts +++ b/samples/dungeon/timemachine-shakeable/build.gradle.kts @@ -14,7 +14,7 @@ dependencies { api(project(":samples:dungeon:timemachine")) api(project(":workflow-core")) api(project(":workflow-ui:core-android")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraintlayout) diff --git a/samples/hello-workflow-fragment/build.gradle.kts b/samples/hello-workflow-fragment/build.gradle.kts index 720f031b7..d060e07d2 100644 --- a/samples/hello-workflow-fragment/build.gradle.kts +++ b/samples/hello-workflow-fragment/build.gradle.kts @@ -20,5 +20,5 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.savedstate) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/samples/hello-workflow/build.gradle.kts b/samples/hello-workflow/build.gradle.kts index 8a584863d..65f1e4710 100644 --- a/samples/hello-workflow/build.gradle.kts +++ b/samples/hello-workflow/build.gradle.kts @@ -21,5 +21,5 @@ dependencies { implementation(libs.androidx.viewbinding) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/samples/nested-overlays/build.gradle.kts b/samples/nested-overlays/build.gradle.kts index d3ecfb610..d618ed695 100644 --- a/samples/nested-overlays/build.gradle.kts +++ b/samples/nested-overlays/build.gradle.kts @@ -21,5 +21,5 @@ dependencies { implementation(libs.androidx.viewbinding) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/samples/stub-visibility/build.gradle.kts b/samples/stub-visibility/build.gradle.kts index 5faafe000..ff54d305b 100644 --- a/samples/stub-visibility/build.gradle.kts +++ b/samples/stub-visibility/build.gradle.kts @@ -21,5 +21,5 @@ dependencies { implementation(libs.androidx.viewbinding) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/samples/tictactoe/app/build.gradle.kts b/samples/tictactoe/app/build.gradle.kts index 3bf58b7d7..b0a330407 100644 --- a/samples/tictactoe/app/build.gradle.kts +++ b/samples/tictactoe/app/build.gradle.kts @@ -41,5 +41,5 @@ dependencies { implementation(project(":samples:tictactoe:common")) implementation(project(":workflow-tracing")) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/samples/tictactoe/common/build.gradle.kts b/samples/tictactoe/common/build.gradle.kts index ca3b3ad1c..58e325675 100644 --- a/samples/tictactoe/common/build.gradle.kts +++ b/samples/tictactoe/common/build.gradle.kts @@ -7,7 +7,7 @@ dependencies { api(libs.squareup.okio) api(project(":workflow-core")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.kotlin.jdk6) implementation(libs.kotlinx.coroutines.core) diff --git a/samples/todo-android/app/build.gradle.kts b/samples/todo-android/app/build.gradle.kts index c6b5b76b8..4c9196178 100644 --- a/samples/todo-android/app/build.gradle.kts +++ b/samples/todo-android/app/build.gradle.kts @@ -31,7 +31,7 @@ dependencies { implementation(project(":workflow-core")) implementation(project(":workflow-tracing")) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) testImplementation(libs.junit) testImplementation(libs.truth) diff --git a/workflow-ui/compose-tooling/build.gradle.kts b/workflow-ui/compose-tooling/build.gradle.kts index fddcb9a74..1ff5a300b 100644 --- a/workflow-ui/compose-tooling/build.gradle.kts +++ b/workflow-ui/compose-tooling/build.gradle.kts @@ -46,5 +46,5 @@ dependencies { implementation(project(":workflow-ui:compose")) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } diff --git a/workflow-ui/core-android/build.gradle.kts b/workflow-ui/core-android/build.gradle.kts index 9f634d9f6..1bcc3d53d 100644 --- a/workflow-ui/core-android/build.gradle.kts +++ b/workflow-ui/core-android/build.gradle.kts @@ -20,7 +20,7 @@ dependencies { // Needs to be API for the WorkflowInterceptor argument to WorkflowRunner.Config. api(project(":workflow-runtime")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) compileOnly(libs.androidx.viewbinding) diff --git a/workflow-ui/internal-testing-android/build.gradle.kts b/workflow-ui/internal-testing-android/build.gradle.kts index c61f8dfe7..d036f9788 100644 --- a/workflow-ui/internal-testing-android/build.gradle.kts +++ b/workflow-ui/internal-testing-android/build.gradle.kts @@ -27,7 +27,7 @@ dependencies { api(libs.truth) api(project(":workflow-ui:core-android")) - api(project(":workflow-ui:core-common")) + api(project(":workflow-ui:core")) implementation(libs.androidx.lifecycle.common) implementation(libs.squareup.leakcanary.instrumentation) diff --git a/workflow-ui/radiography/build.gradle.kts b/workflow-ui/radiography/build.gradle.kts index 57ae06267..84982144a 100644 --- a/workflow-ui/radiography/build.gradle.kts +++ b/workflow-ui/radiography/build.gradle.kts @@ -17,5 +17,5 @@ dependencies { implementation(libs.squareup.radiography) implementation(project(":workflow-ui:core-android")) - implementation(project(":workflow-ui:core-common")) + implementation(project(":workflow-ui:core")) } From d64f8d396ee4a308cb3bf12822e8fa9f412abada Mon Sep 17 00:00:00 2001 From: blakelee Date: Fri, 28 Jun 2024 22:46:42 +0000 Subject: [PATCH 15/15] Apply changes from artifactsDump Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- artifacts.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/artifacts.json b/artifacts.json index 4705f9acc..0128a04ea 100644 --- a/artifacts.json +++ b/artifacts.json @@ -243,7 +243,7 @@ "publicationName": "maven" }, { - "gradlePath": ":workflow-ui:core", + "gradlePath": ":workflow-ui:core-common", "group": "com.squareup.workflow1", "artifactId": "workflow-ui-core-common-jvm", "description": "Workflow UI Core", @@ -260,4 +260,4 @@ "javaVersion": 8, "publicationName": "maven" } -] +] \ No newline at end of file