diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 90e4a1ca11..4df9c5ac03 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,3 +1,7 @@
# These owners will be the default owners for everything in
# the repo, unless a later match takes precedence.
* @square/mobile-foundation-android zach-klippenstein
+
+# Any files under the compose directory in the repo root should
+# be reviewed by UI Systems as well.
+/compose/ @square/mobile-foundation-android @square/ui-systems-android @wardellbagby
diff --git a/.github/workflows/compose-kotlin.yml b/.github/workflows/compose-kotlin.yml
new file mode 100644
index 0000000000..c78e557ff0
--- /dev/null
+++ b/.github/workflows/compose-kotlin.yml
@@ -0,0 +1,143 @@
+name: Compose CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ paths:
+ - 'compose/**'
+ - '.github/workflows/compose-kotlin.yml'
+
+env:
+ GRADLE_HOME: ${{ github.workspace }}/gradle-home
+
+defaults:
+ run:
+ working-directory: compose
+
+jobs:
+ assemble:
+ name: Assemble
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ # These setup steps should be common across all jobs in this workflow.
+ - uses: actions/checkout@v2
+ - uses: gradle/wrapper-validation-action@v1
+ - name: set up JDK 1.8
+ uses: actions/setup-java@v1
+ with:
+ java-version: 1.8
+
+ ## Caching
+ - name: Cache gradle dependencies
+ uses: actions/cache@v1
+ with:
+ path: ${{ env.GRADLE_HOME }}/caches
+ # Include the SHA in the hash so this step always adds a cache entry. If we didn't use the SHA, the artifacts
+ # would only get cached once for each build config hash.
+ # Don't use ${{ runner.os }} in the key so we don't re-assemble for UI tests.
+ key: gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-${{ github.sha }}
+ # The first time a SHA is assembled, we still want to load dependencies from the cache.
+ # Note that none of jobs dependent on this one need restore keys, since they'll always have an exact hit.
+ restore-keys: |
+ gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-
+
+ # We want to keep the dependencies from the cache, but clear out the build cache which contains the actual
+ # compiled artifacts from this project. This ensures we don't run into any issues with stale cache entries,
+ # and that the resulting cache we upload for the other jobs won't waste any space on stale binaries.
+ # A simpler approach would be simply to delete the build-cache before uploading the cache archive, however
+ # if we did that in this job it would defeat the purpose of sharing that directory with dependent jobs,
+ # and there's no way to modify the cache after the job that created it finishes.
+ - name: Clean gradle build cache to assemble fresh
+ run: |
+ ls -lhrt "$GRADLE_HOME/caches" || true
+ rm -rf "$GRADLE_HOME/caches/build-cache-1"
+ ls -lhrt "$GRADLE_HOME/caches" || true
+
+ ## Actual task
+ - name: Assemble with gradle
+ run: ./gradlew assemble --build-cache --no-daemon --stacktrace --gradle-user-home "$GRADLE_HOME"
+
+ # Runs all check tasks in parallel.
+ check:
+ name: Check
+ needs: assemble
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ # Run all checks, even if some fail.
+ fail-fast: false
+ matrix:
+ gradle-task:
+ # Unit tests
+ - test
+ # Binary compatibility
+ - apiCheck
+ - lint
+ - ktlintCheck
+ - detekt
+ steps:
+ - uses: actions/checkout@v2
+ - name: set up JDK 1.8
+ uses: actions/setup-java@v1
+ with:
+ java-version: 1.8
+
+ ## Caching
+ - name: Cache build artifacts
+ uses: actions/cache@v1
+ with:
+ path: ${{ env.GRADLE_HOME }}/caches
+ # Don't set restore-keys so cache is always only valid for the current build config.
+ # Also don't use ${{ runner.os }} in the key so we don't re-assemble for UI tests.
+ key: gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-${{ github.sha }}
+
+ ## Actual task
+ - name: Check with Gradle
+ run: ./gradlew ${{ matrix.gradle-task }} --build-cache --no-daemon --stacktrace --gradle-user-home "$GRADLE_HOME"
+
+ instrumentation-tests:
+ name: Instrumentation tests
+ needs: assemble
+ runs-on: macos-latest
+ timeout-minutes: 30
+ strategy:
+ # Allow tests to continue on other devices if they fail on one device.
+ fail-fast: false
+ matrix:
+ api-level:
+ # See https://github.com/square/workflow-kotlin-compose/issues/54
+ # - 21
+ - 24
+ - 29
+ steps:
+ - uses: actions/checkout@v2
+ - name: set up JDK 1.8
+ uses: actions/setup-java@v1
+ with:
+ java-version: 1.8
+
+ ## Caching
+ - name: Cache build artifacts
+ uses: actions/cache@v1
+ with:
+ path: ${{ env.GRADLE_HOME }}/caches
+ # Don't set restore-keys so cache is always only valid for the current build config.
+ # Also don't use ${{ runner.os }} in the key so we don't re-assemble for UI tests.
+ key: gradle-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/buildSrc/**') }}-${{ github.sha }}
+
+ ## Actual task
+ - name: Instrumentation Tests
+ uses: reactivecircus/android-emulator-runner@v2
+ with:
+ api-level: ${{ matrix.api-level }}
+ arch: x86_64
+ # This task doesn't use the run default specified at the top of the file.
+ working-directory: compose
+ script: ./gradlew connectedCheck --build-cache --no-daemon --stacktrace --gradle-user-home "$GRADLE_HOME"
+ - name: Upload results
+ uses: actions/upload-artifact@v2
+ with:
+ name: instrumentation-test-results
+ path: ./**/build/reports/androidTests/connected/**
diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml
index a5502ad031..70a3e5aa74 100644
--- a/.github/workflows/kotlin.yml
+++ b/.github/workflows/kotlin.yml
@@ -8,6 +8,8 @@ on:
paths-ignore:
# Don't build the entire app when just changing tutorials, which have their own workflow.
- 'samples/tutorial/**'
+ # The compose integration has its own workflow.
+ - 'compose/**'
jobs:
dokka:
diff --git a/compose/.buildscript/android-sample-app.gradle b/compose/.buildscript/android-sample-app.gradle
new file mode 100644
index 0000000000..e9203bcec8
--- /dev/null
+++ b/compose/.buildscript/android-sample-app.gradle
@@ -0,0 +1,9 @@
+apply from: rootProject.file('.buildscript/configure-android-defaults.gradle')
+
+dependencies {
+ implementation(project(":compose-tooling"))
+ implementation(Deps.get("androidx.appcompat"))
+ implementation(Deps.get("timber"))
+ implementation(Deps.get("workflow.core"))
+ implementation(Deps.get("workflow.runtime"))
+}
diff --git a/compose/.buildscript/android-ui-tests.gradle b/compose/.buildscript/android-ui-tests.gradle
new file mode 100644
index 0000000000..ffa0eae327
--- /dev/null
+++ b/compose/.buildscript/android-ui-tests.gradle
@@ -0,0 +1,16 @@
+android {
+ defaultConfig {
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+ testOptions {
+ animationsDisabled = true
+ }
+}
+
+dependencies {
+ androidTestImplementation Deps.get("compose.test")
+ androidTestImplementation Deps.get("test.androidx.espresso.core")
+ androidTestImplementation Deps.get("test.androidx.junitExt")
+ androidTestImplementation Deps.get("test.kotlin")
+ androidTestImplementation Deps.get("test.truth")
+}
diff --git a/compose/.buildscript/binary-validation.gradle b/compose/.buildscript/binary-validation.gradle
new file mode 100644
index 0000000000..978899ca76
--- /dev/null
+++ b/compose/.buildscript/binary-validation.gradle
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * We use JetBrain's Kotlin Binary Compatibility Validator to track changes to our public binary
+ * APIs.
+ * When making a change that results in a public ABI change, the apiCheck task will fail. When this
+ * happens, run ./gradlew apiDump to generate updated *.api files, and add those to your commit.
+ * See https://github.com/Kotlin/binary-compatibility-validator
+ */
+
+apply plugin: 'binary-compatibility-validator'
+
+apiValidation {
+ // Ignore all sample projects, since they're not part of our API.
+ // Only leaf project name is valid configuration, and every project must be individually ignored.
+ // See https://github.com/Kotlin/binary-compatibility-validator/issues/3
+ ignoredProjects += project('samples').name
+}
diff --git a/compose/.buildscript/configure-android-defaults.gradle b/compose/.buildscript/configure-android-defaults.gradle
new file mode 100644
index 0000000000..87365868ca
--- /dev/null
+++ b/compose/.buildscript/configure-android-defaults.gradle
@@ -0,0 +1,29 @@
+android {
+ compileSdkVersion Versions.targetSdk
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+
+ defaultConfig {
+ minSdkVersion 21
+ targetSdkVersion Versions.targetSdk
+ versionCode 1
+ versionName "1.0"
+ }
+
+ buildFeatures.viewBinding = true
+
+ // See https://github.com/Kotlin/kotlinx.coroutines/issues/1064#issuecomment-479412940
+ packagingOptions {
+ exclude 'META-INF/*.kotlin_module'
+ exclude 'META-INF/AL2.0'
+ exclude 'META-INF/LGPL2.1'
+ }
+
+ lintOptions {
+ // Workaround lint bug.
+ disable "InvalidFragmentVersionForActivityResult"
+ }
+}
diff --git a/compose/.buildscript/configure-compose.gradle b/compose/.buildscript/configure-compose.gradle
new file mode 100644
index 0000000000..c9c46450e1
--- /dev/null
+++ b/compose/.buildscript/configure-compose.gradle
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+android {
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerVersion Versions.kotlin
+ kotlinCompilerExtensionVersion Versions.compose
+ }
+}
diff --git a/compose/.buildscript/configure-maven-publish.gradle b/compose/.buildscript/configure-maven-publish.gradle
new file mode 100644
index 0000000000..ad7c855d8b
--- /dev/null
+++ b/compose/.buildscript/configure-maven-publish.gradle
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2019 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+apply plugin: 'com.vanniktech.maven.publish'
+
+group = GROUP
+version = VERSION_NAME
diff --git a/compose/.editorconfig b/compose/.editorconfig
new file mode 100644
index 0000000000..17bc1fa451
--- /dev/null
+++ b/compose/.editorconfig
@@ -0,0 +1,3 @@
+[*.kt]
+indent_size = 2
+continuation_indent_size=4
\ No newline at end of file
diff --git a/compose/.gitignore b/compose/.gitignore
new file mode 100644
index 0000000000..08676baedf
--- /dev/null
+++ b/compose/.gitignore
@@ -0,0 +1,36 @@
+# macOS
+.DS_Store
+
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# BlueJ files
+*.ctxt
+
+# Mobile Tools for Java (J2ME)
+.mtj.tmp/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
+
+# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
+hs_err_pid*
+
+# Gradle
+out/
+.gradle/
+build/
+local.properties
+
+# Intellij
+*.iml
+.idea/
diff --git a/compose/.idea/dictionaries/workflow.xml b/compose/.idea/dictionaries/workflow.xml
new file mode 100644
index 0000000000..c2cb4ea13a
--- /dev/null
+++ b/compose/.idea/dictionaries/workflow.xml
@@ -0,0 +1,14 @@
+
+
+
+ atomicfu
+ coroutine
+ coroutines
+ flowable
+ okio
+ passthrough
+ squareup
+ workflows
+
+
+
diff --git a/compose/.idea/misc.xml b/compose/.idea/misc.xml
new file mode 100644
index 0000000000..24bbcf34aa
--- /dev/null
+++ b/compose/.idea/misc.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose/CHANGELOG.md b/compose/CHANGELOG.md
new file mode 100644
index 0000000000..2e0df90542
--- /dev/null
+++ b/compose/CHANGELOG.md
@@ -0,0 +1,28 @@
+Change Log
+==========
+
+Version 0.30.0
+--------------
+
+_2020-05-31_
+
+* Update Compose to dev12. (#41)
+* New/Breaking: Make `renderAsState` public, make `WorkflowContainer` take a ViewEnvironment. (#32)
+* Breaking: Rename `bindCompose` to `composedViewFactory`. (#35)
+* Breaking: Rename `showRendering` to `WorkflowRendering` and make it not an extension function. (#36)
+* Breaking: Rename `ComposeViewFactoryRoot` to `CompositionRoot` and decouple the implementation. (#34)
+* Fix: Make `ViewRegistry.showRendering` update if `ViewRegistry` changes. (#33)
+* Fix: Fix subcomposition of ComposableViewFactory and WorkflowRendering. (#37)
+
+Version 0.29.0
+--------------
+
+_2020-05-18_
+
+* First release from separate repo.
+* Update: Compose version to dev11. (#26)
+* New: Add the ability to display nested renderings with `bindCompose`. (#7)
+* New: Introduce `ComposeWorkflow`, a self-rendering Workflow. (#8)
+* New: Introduce tooling module with support for previewing ViewBindings with Compose's Preview. (#15)
+* New: Introduce WorkflowContainer for running a workflow inside a Compose app. (#16)
+* Breaking: Tidy up the package structure. (#23)
diff --git a/compose/CODE_OF_CONDUCT.md b/compose/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000000..48fae94d34
--- /dev/null
+++ b/compose/CODE_OF_CONDUCT.md
@@ -0,0 +1,101 @@
+Open Source Code of Conduct
+===========================
+
+At Square, we are committed to contributing to the open source community and simplifying the process
+of releasing and managing open source software. We’ve seen incredible support and enthusiasm from
+thousands of people who have already contributed to our projects — and we want to ensure ourcommunity
+continues to be truly open for everyone.
+
+This code of conduct outlines our expectations for participants, as well as steps to reporting
+unacceptable behavior. We are committed to providing a welcoming and inspiring community for all and
+expect our code of conduct to be honored.
+
+Square’s open source community strives to:
+
+* **Be open**: We invite anyone to participate in any aspect of our projects. Our community is
+ open, and any responsibility can be carried by a contributor who demonstrates the required
+ capacity and competence.
+
+* **Be considerate**: People use our work, and we depend on the work of others. Consider users and
+ colleagues before taking action. For example, changes to code, infrastructure, policy, and
+ documentation may negatively impact others.
+
+* **Be respectful**: We expect people to work together to resolve conflict, assume good intentions,
+ and act with empathy. Do not turn disagreements into personal attacks.
+
+* **Be collaborative**: Collaboration reduces redundancy and improves the quality of our work. We
+ strive for transparency within our open source community, and we work closely with upstream
+ developers and others in the free software community to coordinate our efforts.
+
+* **Be pragmatic**: Questions are encouraged and should be asked early in the process to avoid
+ problems later. Be thoughtful and considerate when seeking out the appropriate forum for your
+ questions. Those who are asked should be responsive and helpful.
+
+* **Step down considerately**: Members of every project come and go. When somebody leaves or
+ disengages from the project, they should make it known and take the proper steps to ensure that
+ others can pick up where they left off.
+
+This code is not exhaustive or complete. It serves to distill our common understanding of a
+collaborative, shared environment, and goals. We expect it to be followed in spirit as much as in
+the letter.
+
+Diversity Statement
+-------------------
+
+We encourage everyone to participate and are committed to building a community for all. Although we
+may not be able to satisfy everyone, we all agree that everyone is equal.
+
+Whenever a participant has made a mistake, we expect them to take responsibility for it. If someone
+has been harmed or offended, it is our responsibility to listen carefully and respectfully, and do
+our best to right the wrong.
+
+Although this list cannot be exhaustive, we explicitly honor diversity in age, culture, ethnicity,
+gender identity or expression, language, national origin, political beliefs, profession, race,
+religion, sexual orientation, socioeconomic status, and technical ability. We will not tolerate
+discrimination based on any of the protected characteristics above, including participants with
+disabilities.
+
+Reporting Issues
+----------------
+
+If you experience or witness unacceptable behavior — or have any other concerns — please report it by
+emailing [codeofconduct@squareup.com][codeofconduct_at]. For more details, please see our Reporting
+Guidelines below.
+
+Thanks
+------
+
+Some of the ideas and wording for the statements and guidelines above were based on work by the
+[Twitter][twitter_coc], [Ubuntu][ubuntu_coc], [GDC][gdc_coc], and [Django][django_coc] communities.
+We are thankful for their work.
+
+Reporting Guide
+---------------
+
+If you experience or witness unacceptable behavior — or have any other concerns — please report it by
+emailing [codeofconduct@squareup.com][codeofconduct_at]. All reports will be handled with
+discretion.
+
+In your report please include:
+
+* Your contact information.
+* Names (real, nicknames, or pseudonyms) of any individuals involved. If there are additional
+ witnesses, please include them as well.
+* Your account of what occurred, and if you believe the incident is ongoing. If there is a publicly
+ available record (e.g. a mailing list archive or a public IRC logger), please include a link.
+* Any additional information that may be helpful.
+
+After filing a report, a representative from the Square Code of Conduct committee will contact you
+personally. The committee will then review the incident, follow up with any additional questions,
+and make a decision as to how to respond.
+
+Anyone asked to stop unacceptable behavior is expected to comply immediately. If an individual
+engages in unacceptable behavior, the Square Code of Conduct committee may take any action they deem
+appropriate, up to and including a permanent ban from all of Square spaces without warning.
+
+[codeofconduct_at]: mailto:codeofconduct@squareup.com
+[twitter_coc]: https://github.com/twitter/code-of-conduct/blob/master/code-of-conduct.md
+[ubuntu_coc]: https://ubuntu.com/community/code-of-conduct
+[gdc_coc]: https://www.gdconf.com/code-of-conduct
+[django_coc]: https://www.djangoproject.com/conduct/reporting/
+
diff --git a/compose/CONTRIBUTING.md b/compose/CONTRIBUTING.md
new file mode 100644
index 0000000000..7725594c98
--- /dev/null
+++ b/compose/CONTRIBUTING.md
@@ -0,0 +1,16 @@
+Contributing
+============
+
+If you would like to contribute code to Workflow you can do so through GitHub by
+forking the repository and sending a pull request.
+
+When submitting code, please make every effort to follow existing conventions
+and style in order to keep the code as readable as possible. Please also make
+sure your code compiles by running `./gradlew clean build`. If you're using IntelliJ IDEA,
+we use [Square's code style definitions][2].
+
+Before your code can be accepted into the project you must also sign the
+[Individual Contributor License Agreement (CLA)][1].
+
+ [1]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1
+ [2]: https://github.com/square/java-code-styles
diff --git a/compose/LICENSE b/compose/LICENSE
new file mode 100644
index 0000000000..261eeb9e9f
--- /dev/null
+++ b/compose/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/compose/README.md b/compose/README.md
new file mode 100644
index 0000000000..5173043d35
--- /dev/null
+++ b/compose/README.md
@@ -0,0 +1,96 @@
+# workflow-kotlin-compose
+
+[](https://www.apache.org/licenses/LICENSE-2.0)
+[](https://search.maven.org/search?q=g:com.squareup.workflow%20AND%20a:workflow-ui-core-compose)
+
+This module provides experimental support for [Jetpack Compose UI][1] with workflows.
+
+The only integration that is currently supported is the ability to define [ViewFactories][2] that
+are implemented as `@Composable` functions. See the `hello-compose-binding` sample in `samples` for
+an example of how to use.
+
+----
+
+## Pre-Alpha
+
+**DO NOT USE this module in your production apps!**
+
+Jetpack Compose is in pre-alpha, developer preview stage. The API is incomplete and changes
+_very frequently_. This integration module exists as a proof-of-concept, to show what's possible,
+and to experiment with various ways to integrate Compose with Workflow.
+
+----
+
+## Usage
+
+### Add the dependency
+
+Add the dependencies from this project (they're on Maven Central):
+
+```groovy
+dependencies {
+ // Main dependency
+ implementation "com.squareup.workflow:workflow-ui-core-compose:${versions.workflow_compose}"
+
+ // For the preview helpers
+ implementation "com.squareup.workflow:workflow-ui-compose-tooling:${versions.workflow_compose}"
+}
+```
+
+### Enable Compose
+
+You must be using the latest Android Gradle Plugin 4.x version, and enable Compose support
+in your `build.gradle`:
+
+```groovy
+android {
+ buildFeatures {
+ compose true
+ }
+ composeOptions {
+ kotlinCompilerVersion "1.4.0-dev-withExperimentalGoogleExtensions-20200720"
+ kotlinCompilerExtensionVersion "${compose_version}"
+ }
+}
+```
+
+To create a `ViewFactory`, call `composedViewFactory`. The lambda passed to `composedViewFactory` is
+a `@Composable` function.
+
+```kotlin
+val HelloBinding = composedViewFactory { rendering, _ ->
+ MaterialTheme {
+ Clickable(onClick = { rendering.onClick() }) {
+ Text(rendering.message)
+ }
+ }
+}
+```
+
+The `composedViewFactory` function returns a regular [`ViewFactory`][2] which can be added to a
+[`ViewRegistry`][3] like any other:
+
+```kotlin
+val viewRegistry = ViewRegistry(HelloBinding)
+```
+
+## License
+```
+Copyright 2020 Square, Inc.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+```
+
+[1]: https://developer.android.com/jetpack/compose
+[2]: https://square.github.io/workflow/kotlin/api/workflow/com.squareup.workflow.ui/-view-factory/
+[3]: https://square.github.io/workflow/kotlin/api/workflow/com.squareup.workflow.ui/-view-registry/
diff --git a/compose/RELEASING.md b/compose/RELEASING.md
new file mode 100644
index 0000000000..f157ff163a
--- /dev/null
+++ b/compose/RELEASING.md
@@ -0,0 +1,104 @@
+Releasing
+=========
+
+Production Releases
+-------------------
+
+1. Merge an update of [the change log](CHANGELOG.md) with the changes since the last release.
+
+1. Make sure you're on the `main` branch (or fix branch, e.g. `v0.1-fixes`).
+
+1. Confirm that the kotlin build is green before committing any changes
+ ```bash
+ (cd kotlin && ./gradlew build connectedCheck)
+ ```
+
+1. In `kotlin/gradle.properties`, remove the `-SNAPSHOT` prefix from the `VERSION_NAME` property.
+ E.g. `VERSION_NAME=0.1.0`
+
+1. Create a commit and tag the commit with the version number:
+ ```bash
+ git commit -am "Releasing v0.1.0."
+ git tag v0.1.0
+ ```
+
+1. Upload the kotlin artifacts:
+ ```bash
+ (cd kotlin && ./gradlew build && ./gradlew uploadArchives --no-parallel)
+ ```
+
+1. Close and release the staging repository at https://oss.sonatype.org.
+
+1. Update the `VERSION_NAME` property in `kotlin/gradle.properties` to the new
+ snapshot version, e.g. `VERSION_NAME=0.2.0-SNAPSHOT`.
+
+1. Commit the new snapshot version:
+ ```
+ git commit -am "Finish releasing v0.1.0."
+ ```
+
+1. Push your commits and tag:
+ ```
+ git push origin main
+ # or git push origin fix-branch
+ git push origin v0.1.0
+ ```
+
+1. Create the release on GitHub:
+ 1. Go to the [Releases](https://github.com/square/workflow-kotlin-compose/releases) page for the GitHub
+ project.
+ 1. Click "Draft a new release".
+ 1. Enter the tag name you just pushed.
+ 1. Title the release with the same name as the tag.
+ 1. Copy & paste the changelog entry for this release into the description.
+ 1. If this is a pre-release version, check the pre-release box.
+ 1. Hit "Publish release".
+
+1. If this was a fix release, merge changes to the main branch:
+ ```bash
+ git checkout main
+ git pull
+ git merge --no-ff v0.1-fixes
+ # Resolve conflicts. Accept main's versions of gradle.properties and podspecs.
+ git push origin main
+ ```
+
+1. Publish the website. See below.
+
+---
+
+## Kotlin Notes
+
+### Development
+
+To build and install the current version to your local Maven repository (`~/.m2`), run:
+
+```bash
+./gradlew clean installArchives
+```
+
+### Deploying
+
+#### Configuration
+
+In order to deploy artifacts to a Maven repository, you'll need to set 4 properties in your private
+Gradle properties file (`~/.gradle/gradle.properties`):
+
+```
+RELEASE_REPOSITORY_URL=
+SNAPSHOT_REPOSITORY_URL=
+SONATYPE_NEXUS_PASSWORD=
+```
+
+#### Snapshot Releases
+
+Double-check that `gradle.properties` correctly contains the `-SNAPSHOT` suffix, then upload
+snapshot artifacts to Sonatype just like you would for a production release:
+
+```bash
+./gradlew clean build && ./gradlew uploadArchives --no-parallel
+```
+
+You can verify the artifacts are available by visiting
+https://oss.sonatype.org/content/repositories/snapshots/com/squareup/workflow/.
diff --git a/compose/build.gradle.kts b/compose/build.gradle.kts
new file mode 100644
index 0000000000..24495fddbc
--- /dev/null
+++ b/compose/build.gradle.kts
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2017 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import io.gitlab.arturbosch.detekt.extensions.DetektExtension
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+import org.jlleitschuh.gradle.ktlint.KtlintExtension
+import org.jlleitschuh.gradle.ktlint.reporter.ReporterType
+
+buildscript {
+ dependencies {
+ classpath(Dependencies.android_gradle_plugin)
+ classpath(Dependencies.detekt)
+ classpath(Dependencies.dokka)
+ classpath(Dependencies.Kotlin.binaryCompatibilityValidatorPlugin)
+ classpath(Dependencies.Kotlin.gradlePlugin)
+ classpath(Dependencies.ktlint)
+ classpath(Dependencies.mavenPublish)
+ }
+
+ repositories {
+ mavenCentral()
+ gradlePluginPortal()
+ google()
+ // For Kotlin 1.4.
+ maven("https://dl.bintray.com/kotlin/kotlin-eap")
+ // For binary compatibility validator.
+ maven("https://kotlin.bintray.com/kotlinx")
+ }
+}
+
+// See https://stackoverflow.com/questions/25324880/detect-ide-environment-with-gradle
+val isRunningFromIde get() = project.properties["android.injected.invoked.from.ide"] == "true"
+
+subprojects {
+ repositories {
+ google()
+ mavenCentral()
+ jcenter()
+ // For Kotlin 1.4.
+ maven("https://dl.bintray.com/kotlin/kotlin-eap")
+ }
+
+ apply(plugin = "org.jlleitschuh.gradle.ktlint")
+ apply(plugin = "io.gitlab.arturbosch.detekt")
+ afterEvaluate {
+ tasks.findByName("check")
+ ?.dependsOn("detekt")
+
+ configurations.configureEach {
+ // There could be transitive dependencies in tests with a lower version. This could cause
+ // problems with a newer Kotlin version that we use.
+ resolutionStrategy.force(Dependencies.Kotlin.reflect)
+ }
+ }
+
+ tasks.withType() {
+ kotlinOptions {
+ // Allow warnings when running from IDE, makes it easier to experiment.
+ if (!isRunningFromIde) {
+ allWarningsAsErrors = true
+ }
+
+ jvmTarget = "1.8"
+
+ // Don't panic, all this does is allow us to use the @OptIn meta-annotation.
+ // to define our own experiments, and some required args for compose dev15 taken from
+ // https://developer.android.com/jetpack/androidx/releases/compose-runtime
+ freeCompilerArgs += listOf(
+ "-Xopt-in=kotlin.RequiresOptIn",
+ "-Xallow-jvm-ir-dependencies",
+ "-Xskip-prerelease-check"
+ )
+
+ }
+ }
+
+ // Configuration documentation: https://github.com/JLLeitschuh/ktlint-gradle#configuration
+ configure {
+ // Prints the name of failed rules.
+ verbose.set(true)
+ reporters {
+ // Default "plain" reporter is actually harder to read.
+ reporter(ReporterType.JSON)
+ }
+
+ disabledRules.set(
+ setOf(
+ // IntelliJ refuses to sort imports correctly.
+ // This is a known issue: https://github.com/pinterest/ktlint/issues/527
+ "import-ordering",
+ // Ktlint doesn't know how to handle nullary annotations on function types, e.g.
+ // @Composable () -> Unit.
+ "paren-spacing"
+ )
+ )
+ }
+
+ configure {
+ config = files("${rootDir}/detekt.yml")
+ // Treat config file as an override for the default config.
+ buildUponDefaultConfig = true
+ }
+}
+
+apply(from = rootProject.file(".buildscript/binary-validation.gradle"))
diff --git a/compose/buildSrc/build.gradle.kts b/compose/buildSrc/build.gradle.kts
new file mode 100644
index 0000000000..9db828f6bc
--- /dev/null
+++ b/compose/buildSrc/build.gradle.kts
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ mavenCentral()
+}
+
+kotlinDslPluginOptions {
+ experimentalWarning.set(false)
+}
diff --git a/compose/buildSrc/src/main/java/Dependencies.kt b/compose/buildSrc/src/main/java/Dependencies.kt
new file mode 100644
index 0000000000..ecdf40e449
--- /dev/null
+++ b/compose/buildSrc/src/main/java/Dependencies.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:JvmName("Deps")
+
+import java.util.Locale.US
+import kotlin.reflect.full.declaredMembers
+
+object Versions {
+ const val compose = "1.0.0-alpha07"
+ const val kotlin = "1.4.10"
+
+ // This *is* actually used.
+ @Suppress("unused")
+ const val targetSdk = 29
+ const val workflow = "0.28.0"
+}
+
+@Suppress("unused")
+object Dependencies {
+ const val android_gradle_plugin = "com.android.tools.build:gradle:4.2.0-alpha15"
+
+ object AndroidX {
+ const val appcompat = "androidx.appcompat:appcompat:1.1.0"
+ }
+
+ object Compose {
+ const val foundation = "androidx.compose.foundation:foundation:${Versions.compose}"
+ const val layout = "androidx.compose.foundation:foundation-layout:${Versions.compose}"
+ const val material = "androidx.compose.material:material:${Versions.compose}"
+ const val savedstate =
+ "androidx.compose.runtime:runtime-saved-instance-state:${Versions.compose}"
+ const val test = "androidx.ui:ui-test:${Versions.compose}"
+ const val tooling = "androidx.ui:ui-tooling:${Versions.compose}"
+ }
+
+ const val timber = "com.jakewharton.timber:timber:4.7.1"
+
+ object Kotlin {
+ const val binaryCompatibilityValidatorPlugin =
+ "org.jetbrains.kotlinx:binary-compatibility-validator:0.2.3"
+ const val gradlePlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}"
+ const val reflect = "org.jetbrains.kotlin:kotlin-reflect:${Versions.kotlin}"
+ }
+
+ const val dokka = "org.jetbrains.dokka:dokka-gradle-plugin:0.10.0"
+ const val mavenPublish = "com.vanniktech:gradle-maven-publish-plugin:0.11.1"
+ const val ktlint = "org.jlleitschuh.gradle:ktlint-gradle:9.2.0"
+ const val detekt = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.0.1"
+
+ object Test {
+ object AndroidX {
+ const val junitExt = "androidx.test.ext:junit:1.1.1"
+ const val runner = "androidx.test:runner:1.2.0"
+ const val truthExt = "androidx.test.ext:truth:1.2.0"
+ const val uiautomator = "androidx.test.uiautomator:uiautomator:2.2.0"
+
+ object Espresso {
+ const val core = "androidx.test.espresso:espresso-core:3.2.0"
+ }
+ }
+
+ const val junit = "junit:junit:4.13"
+ const val kotlin = "org.jetbrains.kotlin:kotlin-test-junit:${Versions.kotlin}"
+ const val truth = "com.google.truth:truth:1.0.1"
+ }
+
+ object Workflow {
+ const val core = "com.squareup.workflow:workflow-core-jvm:${Versions.workflow}"
+ const val runtime = "com.squareup.workflow:workflow-runtime-jvm:${Versions.workflow}"
+
+ object UI {
+ const val coreAndroid = "com.squareup.workflow:workflow-ui-core-android:${Versions.workflow}"
+ }
+ }
+}
+
+/**
+ * Workaround to make [Dependencies] accessible from Groovy scripts. [path] is case-insensitive.
+ *
+ * ```
+ * dependencies {
+ * implementation Deps.get("kotlin.stdlib.common")
+ * }
+ * ```
+ */
+@JvmName("get")
+fun getDependencyFromGroovy(path: String): String = try {
+ Dependencies.resolveObject(
+ path.toLowerCase(US)
+ .split(".")
+ )
+} catch (e: Throwable) {
+ throw IllegalArgumentException("Error resolving dependency: $path", e)
+}
+
+private tailrec fun Any.resolveObject(pathParts: List): String {
+ require(pathParts.isNotEmpty())
+ val klass = this::class
+
+ if (pathParts.size == 1) {
+ @Suppress("UNCHECKED_CAST")
+ val member = klass.declaredMembers.single { it.name.toLowerCase(US) == pathParts.single() }
+ return member.call() as String
+ }
+
+ val nestedKlasses = klass.nestedClasses
+ val selectedKlass = nestedKlasses.single { it.simpleName!!.toLowerCase(US) == pathParts.first() }
+ return selectedKlass.objectInstance!!.resolveObject(pathParts.subList(1, pathParts.size))
+}
diff --git a/compose/compose-tooling/api/compose-tooling.api b/compose/compose-tooling/api/compose-tooling.api
new file mode 100644
index 0000000000..6179136415
--- /dev/null
+++ b/compose/compose-tooling/api/compose-tooling.api
@@ -0,0 +1,15 @@
+public final class com/squareup/workflow/ui/compose/tooling/BuildConfig {
+ public static final field BUILD_TYPE Ljava/lang/String;
+ public static final field DEBUG Z
+ public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
+ public fun ()V
+}
+
+public final class com/squareup/workflow/ui/compose/tooling/ComposeWorkflowsKt {
+ public static final fun preview (Lcom/squareup/workflow/ui/compose/ComposeWorkflow;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
+}
+
+public final class com/squareup/workflow/ui/compose/tooling/ViewFactoriesKt {
+ public static final fun preview (Lcom/squareup/workflow/ui/ViewFactory;Ljava/lang/Object;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V
+}
+
diff --git a/compose/compose-tooling/build.gradle.kts b/compose/compose-tooling/build.gradle.kts
new file mode 100644
index 0000000000..fe90d6941a
--- /dev/null
+++ b/compose/compose-tooling/build.gradle.kts
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+plugins {
+ id("com.android.library")
+ kotlin("android")
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle"))
+apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle"))
+apply(from = rootProject.file(".buildscript/android-ui-tests.gradle"))
+
+apply(from = rootProject.file(".buildscript/configure-compose.gradle"))
+
+dependencies {
+ api(project(":core-compose"))
+ api(Dependencies.Compose.tooling)
+
+ implementation(Dependencies.Compose.foundation)
+}
diff --git a/compose/compose-tooling/gradle.properties b/compose/compose-tooling/gradle.properties
new file mode 100644
index 0000000000..a2027bd02d
--- /dev/null
+++ b/compose/compose-tooling/gradle.properties
@@ -0,0 +1,18 @@
+#
+# Copyright 2020 Square Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+POM_ARTIFACT_ID=workflow-ui-compose-tooling
+POM_NAME=Workflow UI Compose Tooling
+POM_PACKAGING=aar
diff --git a/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt
new file mode 100644
index 0000000000..84b38af755
--- /dev/null
+++ b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewComposeWorkflowTest.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("TestFunctionName", "PrivatePropertyName")
+
+package com.squareup.workflow.ui.compose.tooling
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import androidx.ui.tooling.preview.Preview
+import com.squareup.workflow.Workflow
+import com.squareup.workflow.ui.ViewEnvironmentKey
+import com.squareup.workflow.ui.compose.WorkflowRendering
+import com.squareup.workflow.ui.compose.composed
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Duplicate of [PreviewViewFactoryTest] but for [com.squareup.workflow.ui.compose.ComposeWorkflow].
+ */
+@RunWith(AndroidJUnit4::class)
+class PreviewComposeWorkflowTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun singleChild() {
+ composeRule.setContent {
+ ParentWithOneChildPreview()
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ }
+
+ @Test fun twoChildren() {
+ composeRule.setContent {
+ ParentWithTwoChildrenPreview()
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ composeRule.onNodeWithText("three").assertIsDisplayed()
+ }
+
+ @Test fun modifierIsApplied() {
+ composeRule.setContent {
+ ParentWithModifier()
+ }
+
+ // The view factory will be rendered with size (0,0), so it should be reported as not displayed.
+ composeRule.onNodeWithText("one").assertIsNotDisplayed()
+ composeRule.onNodeWithText("two").assertIsNotDisplayed()
+ }
+
+ @Test fun placeholderModifierIsApplied() {
+ composeRule.setContent {
+ ParentWithPlaceholderModifier()
+ }
+
+ // The child will be rendered with size (0,0), so it should be reported as not displayed.
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsNotDisplayed()
+ }
+
+ @Test fun customViewEnvironment() {
+ composeRule.setContent {
+ ParentConsumesCustomKeyPreview()
+ }
+
+ composeRule.onNodeWithText("foo").assertIsDisplayed()
+ }
+
+ private val ParentWithOneChild =
+ Workflow.composed, Nothing> { props, _, environment ->
+ Column {
+ BasicText(props.first)
+ WorkflowRendering(props.second, environment)
+ }
+ }
+
+ @Preview @Composable private fun ParentWithOneChildPreview() {
+ ParentWithOneChild.preview(Pair("one", "two"))
+ }
+
+ private val ParentWithTwoChildren =
+ Workflow.composed, Nothing> { props, _, environment ->
+ Column {
+ WorkflowRendering(rendering = props.first, viewEnvironment = environment)
+ BasicText(props.second)
+ WorkflowRendering(rendering = props.third, viewEnvironment = environment)
+ }
+ }
+
+ @Preview @Composable private fun ParentWithTwoChildrenPreview() {
+ ParentWithTwoChildren.preview(Triple("one", "two", "three"))
+ }
+
+ @Preview @Composable private fun ParentWithModifier() {
+ ParentWithOneChild.preview(
+ Pair("one", "two"),
+ modifier = Modifier.size(0.dp)
+ )
+ }
+
+ @Preview @Composable private fun ParentWithPlaceholderModifier() {
+ ParentWithOneChild.preview(
+ Pair("one", "two"),
+ placeholderModifier = Modifier.size(0.dp)
+ )
+ }
+
+ object TestEnvironmentKey : ViewEnvironmentKey(String::class) {
+ override val default: String get() = error("Not specified")
+ }
+
+ private val ParentConsumesCustomKey = Workflow.composed { _, _, environment ->
+ BasicText(environment[TestEnvironmentKey])
+ }
+
+ @Preview @Composable private fun ParentConsumesCustomKeyPreview() {
+ ParentConsumesCustomKey.preview(Unit) {
+ it + (TestEnvironmentKey to "foo")
+ }
+ }
+}
diff --git a/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt
new file mode 100644
index 0000000000..de471822e9
--- /dev/null
+++ b/compose/compose-tooling/src/androidTest/java/com/squareup/workflow/ui/compose/tooling/PreviewViewFactoryTest.kt
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("TestFunctionName", "PrivatePropertyName")
+
+package com.squareup.workflow.ui.compose.tooling
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import androidx.ui.tooling.preview.Preview
+import com.squareup.workflow.ui.ViewEnvironmentKey
+import com.squareup.workflow.ui.compose.WorkflowRendering
+import com.squareup.workflow.ui.compose.composedViewFactory
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class PreviewViewFactoryTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun singleChild() {
+ composeRule.setContent {
+ ParentWithOneChildPreview()
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ }
+
+ @Test fun twoChildren() {
+ composeRule.setContent {
+ ParentWithTwoChildrenPreview()
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ composeRule.onNodeWithText("three").assertIsDisplayed()
+ }
+
+ @Test fun recursive() {
+ composeRule.setContent {
+ ParentRecursivePreview()
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ composeRule.onNodeWithText("three").assertIsDisplayed()
+ }
+
+ @Test fun modifierIsApplied() {
+ composeRule.setContent {
+ ParentWithModifier()
+ }
+
+ // The view factory will be rendered with size (0,0), so it should be reported as not displayed.
+ composeRule.onNodeWithText("one").assertIsNotDisplayed()
+ composeRule.onNodeWithText("two").assertIsNotDisplayed()
+ }
+
+ @Test fun placeholderModifierIsApplied() {
+ composeRule.setContent {
+ ParentWithPlaceholderModifier()
+ }
+
+ // The child will be rendered with size (0,0), so it should be reported as not displayed.
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsNotDisplayed()
+ }
+
+ @Test fun customViewEnvironment() {
+ composeRule.setContent {
+ ParentConsumesCustomKeyPreview()
+ }
+
+ composeRule.onNodeWithText("foo").assertIsDisplayed()
+ }
+
+ private val ParentWithOneChild =
+ composedViewFactory> { rendering, environment ->
+ Column {
+ BasicText(rendering.first)
+ WorkflowRendering(rendering.second, environment)
+ }
+ }
+
+ @Preview @Composable private fun ParentWithOneChildPreview() {
+ ParentWithOneChild.preview(Pair("one", "two"))
+ }
+
+ private val ParentWithTwoChildren =
+ composedViewFactory> { rendering, environment ->
+ Column {
+ WorkflowRendering(rendering.first, environment)
+ BasicText(rendering.second)
+ WorkflowRendering(rendering.third, environment)
+ }
+ }
+
+ @Preview @Composable private fun ParentWithTwoChildrenPreview() {
+ ParentWithTwoChildren.preview(Triple("one", "two", "three"))
+ }
+
+ data class RecursiveRendering(
+ val text: String,
+ val child: RecursiveRendering? = null
+ )
+
+ private val ParentRecursive = composedViewFactory { rendering, environment ->
+ Column {
+ BasicText(rendering.text)
+ rendering.child?.let { child ->
+ WorkflowRendering(rendering = child, viewEnvironment = environment)
+ }
+ }
+ }
+
+ @Preview @Composable private fun ParentRecursivePreview() {
+ ParentRecursive.preview(
+ RecursiveRendering(
+ text = "one",
+ child = RecursiveRendering(
+ text = "two",
+ child = RecursiveRendering(text = "three")
+ )
+ )
+ )
+ }
+
+ @Preview @Composable private fun ParentWithModifier() {
+ ParentWithOneChild.preview(
+ Pair("one", "two"),
+ modifier = Modifier.size(0.dp)
+ )
+ }
+
+ @Preview @Composable private fun ParentWithPlaceholderModifier() {
+ ParentWithOneChild.preview(
+ Pair("one", "two"),
+ placeholderModifier = Modifier.size(0.dp)
+ )
+ }
+
+ object TestEnvironmentKey : ViewEnvironmentKey(String::class) {
+ override val default: String get() = error("Not specified")
+ }
+
+ private val ParentConsumesCustomKey = composedViewFactory { _, environment ->
+ BasicText(environment[TestEnvironmentKey])
+ }
+
+ @Preview @Composable private fun ParentConsumesCustomKeyPreview() {
+ ParentConsumesCustomKey.preview(Unit) {
+ it + (TestEnvironmentKey to "foo")
+ }
+ }
+}
diff --git a/compose/compose-tooling/src/main/AndroidManifest.xml b/compose/compose-tooling/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..7b52cef9d5
--- /dev/null
+++ b/compose/compose-tooling/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt
new file mode 100644
index 0000000000..c13f2516f0
--- /dev/null
+++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ComposeWorkflows.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose.tooling
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.squareup.workflow.Sink
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.compose.ComposeWorkflow
+
+/**
+ * Draws this [ComposeWorkflow] using a special preview [ViewRegistry].
+ *
+ * Use inside `@Preview` Composable functions.
+ *
+ * *Note: [props] must be the `PropsT` of this [ComposeWorkflow], even though the type system does
+ * not enforce this constraint. This is due to a Compose compiler bug tracked
+ * [here](https://issuetracker.google.com/issues/156527332).*
+ *
+ * @param modifier [Modifier] that will be applied to this [ViewFactory].
+ * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory
+ * shows.
+ * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this
+ * factory.
+ */
+@Composable fun ComposeWorkflow.preview(
+ props: PropsT,
+ modifier: Modifier = Modifier,
+ placeholderModifier: Modifier = Modifier,
+ viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null
+) {
+ val previewEnvironment = previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater)
+ Box(modifier = modifier) {
+ render(props, NoopSink, previewEnvironment)
+ }
+}
+
+private object NoopSink : Sink {
+ override fun send(value: Any) = Unit
+}
diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt
new file mode 100644
index 0000000000..5623586037
--- /dev/null
+++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PlaceholderViewFactory.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("SameParameterValue", "DEPRECATION")
+
+package com.squareup.workflow.ui.compose.tooling
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.drawBorder
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.toRect
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Paint
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
+import androidx.compose.ui.graphics.drawscope.rotate
+import androidx.compose.ui.graphics.withSaveLayer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.ui.tooling.preview.Preview
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.compose.composedViewFactory
+
+/**
+ * A [ViewFactory] that will be used any time a [PreviewViewRegistry] is asked to show a rendering.
+ * It displays a placeholder graphic and the rendering's `toString()` result.
+ */
+internal fun placeholderViewFactory(modifier: Modifier): ViewFactory =
+ composedViewFactory { rendering, _ ->
+ BasicText(
+ modifier = modifier
+ .clipToBounds()
+ .drawBehind {
+ drawIntoCanvas { canvas ->
+ canvas.withSaveLayer(size.toRect(), Paint().apply { alpha = .2f }) {
+ canvas.drawRect(size.toRect(), Paint().apply { color = Color.Gray })
+ drawCrossHatch(
+ color = Color.Red,
+ strokeWidth = 2.dp,
+ spaceWidth = 5.dp,
+ angle = 45f
+ )
+ }
+ }
+ },
+ text = rendering.toString(),
+ style = TextStyle(
+ textAlign = TextAlign.Center,
+ color = Color.White,
+ shadow = Shadow(blurRadius = 5f, color = Color.Black)
+ )
+ )
+ }
+
+@Preview(widthDp = 200, heightDp = 200)
+@Composable private fun PreviewStubViewBindingOnWhite() {
+ Box(Modifier.background(Color.White)) {
+ PreviewStubBindingPreviewTemplate()
+ }
+}
+
+@Preview(widthDp = 200, heightDp = 200)
+@Composable private fun PreviewStubViewBindingOnBlack() {
+ Box(Modifier.background(Color.Black)) {
+ PreviewStubBindingPreviewTemplate()
+ }
+}
+
+@Composable private fun PreviewStubBindingPreviewTemplate() {
+ placeholderViewFactory(Modifier).preview(
+ rendering = "preview",
+ placeholderModifier = Modifier.fillMaxSize()
+ .drawBorder(size = 1.dp, color = Color.Red)
+ )
+}
+
+private fun DrawScope.drawCrossHatch(
+ color: Color,
+ strokeWidth: Dp,
+ spaceWidth: Dp,
+ angle: Float
+) {
+ drawHatch(color, strokeWidth, spaceWidth, angle)
+ drawHatch(color, strokeWidth, spaceWidth, angle + 90)
+}
+
+private fun DrawScope.drawHatch(
+ color: Color,
+ strokeWidth: Dp,
+ spaceWidth: Dp,
+ angle: Float
+) {
+ val strokeWidthPx = strokeWidth.toPx()
+ val spaceWidthPx = spaceWidth.toPx()
+ val strokeColor = color.scaleColors(.5f)
+
+ rotate(angle) {
+ // Draw outside our bounds to fill the space even when rotated.
+ val left = -size.width
+ val right = size.width * 2
+ val top = -size.height
+ val bottom = size.height * 2
+
+ var y = top + strokeWidthPx * 2f
+ while (y < bottom) {
+ drawLine(
+ strokeColor,
+ Offset(left, y),
+ Offset(right, y),
+ strokeWidthPx
+ )
+ y += spaceWidthPx * 2
+ }
+ }
+}
+
+private fun Color.scaleColors(factor: Float) =
+ copy(red = red * factor, green = green * factor, blue = blue * factor)
diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt
new file mode 100644
index 0000000000..388a0bc417
--- /dev/null
+++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/PreviewViewEnvironment.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose.tooling
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.ViewRegistry
+import kotlin.reflect.KClass
+
+/**
+ * Creates and [remember]s a [ViewEnvironment] that has a special [ViewRegistry] and any additional
+ * elements as configured by [viewEnvironmentUpdater].
+ *
+ * The [ViewRegistry] will contain [mainFactory] if specified, as well as a [placeholderViewFactory]
+ * that will be used to show any renderings that don't match [mainFactory]'s type. All placeholders
+ * will have [placeholderModifier] applied.
+ */
+@Composable internal fun previewViewEnvironment(
+ placeholderModifier: Modifier,
+ viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null,
+ mainFactory: ViewFactory<*>? = null
+): ViewEnvironment {
+ val viewRegistry = remember(mainFactory, placeholderModifier) {
+ PreviewViewRegistry(mainFactory, placeholderViewFactory(placeholderModifier))
+ }
+ return remember(viewRegistry, viewEnvironmentUpdater) {
+ ViewEnvironment(viewRegistry).let { environment ->
+ // Give the preview a chance to add its own elements to the ViewEnvironment.
+ viewEnvironmentUpdater?.let { it(environment) } ?: environment
+ }
+ }
+}
+
+/**
+ * A [ViewRegistry] that uses [mainFactory] for rendering [RenderingT]s, and [placeholderFactory]
+ * for all other [WorkflowRendering][com.squareup.workflow.ui.compose.WorkflowRendering] calls.
+ */
+@Immutable
+private class PreviewViewRegistry(
+ private val mainFactory: ViewFactory? = null,
+ private val placeholderFactory: ViewFactory
+) : ViewRegistry {
+ override val keys: Set> get() = mainFactory?.let { setOf(it.type) } ?: emptySet()
+
+ @Suppress("UNCHECKED_CAST")
+ override fun getFactoryFor(
+ renderingType: KClass
+ ): ViewFactory = when (renderingType) {
+ mainFactory?.type -> mainFactory
+ else -> placeholderFactory
+ } as ViewFactory
+}
diff --git a/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt
new file mode 100644
index 0000000000..783a97df64
--- /dev/null
+++ b/compose/compose-tooling/src/main/java/com/squareup/workflow/ui/compose/tooling/ViewFactories.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose.tooling
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.compose.WorkflowRendering
+
+/**
+ * Draws this [ViewFactory] using a special preview [ViewRegistry].
+ *
+ * Use inside `@Preview` Composable functions.
+ *
+ * *Note: [rendering] must be the same type as this [ViewFactory], even though the type system does
+ * not enforce this constraint. This is due to a Compose compiler bug tracked
+ * [here](https://issuetracker.google.com/issues/156527332).*
+ *
+ * @param modifier [Modifier] that will be applied to this [ViewFactory].
+ * @param placeholderModifier [Modifier] that will be applied to any nested renderings this factory
+ * shows.
+ * @param viewEnvironmentUpdater Function that configures the [ViewEnvironment] passed to this
+ * factory.
+ */
+@Composable fun ViewFactory.preview(
+ rendering: RenderingT,
+ modifier: Modifier = Modifier,
+ placeholderModifier: Modifier = Modifier,
+ viewEnvironmentUpdater: ((ViewEnvironment) -> ViewEnvironment)? = null
+) {
+ val previewEnvironment =
+ previewViewEnvironment(placeholderModifier, viewEnvironmentUpdater, mainFactory = this)
+ WorkflowRendering(rendering, previewEnvironment, modifier)
+}
diff --git a/compose/core-compose/api/core-compose.api b/compose/core-compose/api/core-compose.api
new file mode 100644
index 0000000000..3ab1ac14e3
--- /dev/null
+++ b/compose/core-compose/api/core-compose.api
@@ -0,0 +1,58 @@
+public final class com/squareup/workflow/ui/compose/ComposeRendering {
+ public static final field $stable I
+ public static final field Companion Lcom/squareup/workflow/ui/compose/ComposeRendering$Companion;
+ public fun (Lkotlin/jvm/functions/Function3;)V
+}
+
+public final class com/squareup/workflow/ui/compose/ComposeRendering$Companion {
+ public final fun getFactory ()Lcom/squareup/workflow/ui/ViewFactory;
+ public final fun getNoopRendering ()Lcom/squareup/workflow/ui/compose/ComposeRendering;
+}
+
+public final class com/squareup/workflow/ui/compose/ComposeViewFactory : com/squareup/workflow/ui/ViewFactory {
+ public fun (Lkotlin/reflect/KClass;Lkotlin/jvm/functions/Function4;)V
+ public fun buildView (Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroid/content/Context;Landroid/view/ViewGroup;)Landroid/view/View;
+ public fun getType ()Lkotlin/reflect/KClass;
+}
+
+public abstract class com/squareup/workflow/ui/compose/ComposeWorkflow : com/squareup/workflow/Workflow {
+ public static final field $stable I
+ public fun ()V
+ public fun asStatefulWorkflow ()Lcom/squareup/workflow/StatefulWorkflow;
+ public abstract fun render (Ljava/lang/Object;Lcom/squareup/workflow/Sink;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/runtime/Composer;I)V
+}
+
+public final class com/squareup/workflow/ui/compose/ComposeWorkflowKt {
+ public static final fun composed (Lcom/squareup/workflow/Workflow$Companion;Lkotlin/jvm/functions/Function5;)Lcom/squareup/workflow/ui/compose/ComposeWorkflow;
+}
+
+public final class com/squareup/workflow/ui/compose/CompositionRootKt {
+ public static final fun withCompositionRoot (Lcom/squareup/workflow/ui/ViewEnvironment;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow/ui/ViewEnvironment;
+ public static final fun withCompositionRoot (Lcom/squareup/workflow/ui/ViewRegistry;Lkotlin/jvm/functions/Function3;)Lcom/squareup/workflow/ui/ViewRegistry;
+}
+
+public final class com/squareup/workflow/ui/compose/RenderAsStateKt {
+ public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
+ public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
+ public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
+ public static final fun renderAsState (Lcom/squareup/workflow/Workflow;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)Landroidx/compose/runtime/State;
+}
+
+public final class com/squareup/workflow/ui/compose/ViewEnvironmentsKt {
+ public static final fun WorkflowRendering (Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
+}
+
+public final class com/squareup/workflow/ui/compose/WorkflowContainerKt {
+ public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V
+ public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V
+ public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V
+ public static final fun WorkflowContainer (Lcom/squareup/workflow/Workflow;Lkotlin/jvm/functions/Function1;Lcom/squareup/workflow/ui/ViewEnvironment;Landroidx/compose/ui/Modifier;Lcom/squareup/workflow/diagnostic/WorkflowDiagnosticListener;Landroidx/compose/runtime/Composer;II)V
+}
+
+public final class com/squareup/workflow/ui/core/compose/BuildConfig {
+ public static final field BUILD_TYPE Ljava/lang/String;
+ public static final field DEBUG Z
+ public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String;
+ public fun ()V
+}
+
diff --git a/compose/core-compose/build.gradle.kts b/compose/core-compose/build.gradle.kts
new file mode 100644
index 0000000000..ceb6d7c474
--- /dev/null
+++ b/compose/core-compose/build.gradle.kts
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2019 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+plugins {
+ id("com.android.library")
+ kotlin("android")
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+
+apply(from = rootProject.file(".buildscript/configure-maven-publish.gradle"))
+apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle"))
+apply(from = rootProject.file(".buildscript/android-ui-tests.gradle"))
+
+apply(from = rootProject.file(".buildscript/configure-compose.gradle"))
+
+dependencies {
+ api(Dependencies.Workflow.UI.coreAndroid)
+
+ implementation(Dependencies.Compose.foundation)
+ implementation(Dependencies.Compose.layout)
+ implementation(Dependencies.Compose.savedstate)
+ implementation(Dependencies.Workflow.runtime)
+}
diff --git a/compose/core-compose/gradle.properties b/compose/core-compose/gradle.properties
new file mode 100644
index 0000000000..293cedcf2e
--- /dev/null
+++ b/compose/core-compose/gradle.properties
@@ -0,0 +1,18 @@
+#
+# Copyright 2019 Square Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+POM_ARTIFACT_ID=workflow-ui-core-compose
+POM_NAME=Workflow UI Core Compose
+POM_PACKAGING=aar
diff --git a/compose/core-compose/src/androidTest/AndroidManifest.xml b/compose/core-compose/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000000..9ed9131013
--- /dev/null
+++ b/compose/core-compose/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt
new file mode 100644
index 0000000000..1b9490760e
--- /dev/null
+++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ComposeViewFactoryTest.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.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.mutableStateOf
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.WorkflowViewStub
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ComposeViewFactoryTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun wrapsFactoryWithRoot() {
+ val wrapperText = mutableStateOf("one")
+ val viewEnvironment = ViewEnvironment(ViewRegistry(TestFactory))
+ .withCompositionRoot { content ->
+ Column {
+ BasicText(wrapperText.value)
+ content()
+ }
+ }
+
+ composeRule.setContent {
+ AndroidView(::RootView) {
+ it.setViewEnvironment(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) {
+ private val stub = WorkflowViewStub(context).also(::addView)
+
+ fun setViewEnvironment(viewEnvironment: ViewEnvironment) {
+ stub.update(TestRendering("two"), viewEnvironment)
+ }
+ }
+
+ private data class TestRendering(val text: String)
+
+ private companion object {
+ val TestFactory = composedViewFactory { rendering, _ ->
+ BasicText(rendering.text)
+ }
+ }
+}
diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt
new file mode 100644
index 0000000000..c9ffaeeccc
--- /dev/null
+++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/CompositionRootTest.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.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.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertFailsWith
+
+@RunWith(AndroidJUnit4::class)
+class CompositionRootTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun wrapWithRootIfNecessary_wrapsWhenNecessary() {
+ val root: CompositionRoot = { content ->
+ Column {
+ BasicText("one")
+ content()
+ }
+ }
+
+ composeRule.setContent {
+ wrapWithRootIfNecessary(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 wrapWithRootIfNecessary_onlyWrapsOnce() {
+ val root: CompositionRoot = { content ->
+ Column {
+ BasicText("one")
+ content()
+ }
+ }
+
+ composeRule.setContent {
+ wrapWithRootIfNecessary(root) {
+ BasicText("two")
+ wrapWithRootIfNecessary(root) {
+ BasicText("three")
+ }
+ }
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ composeRule.onNodeWithText("three").assertIsDisplayed()
+ }
+
+ @Test fun wrapWithRootIfNecessary_seesUpdatesFromRootWrapper() {
+ val wrapperText = mutableStateOf("one")
+ val root: CompositionRoot = { content ->
+ Column {
+ BasicText(wrapperText.value)
+ content()
+ }
+ }
+
+ composeRule.setContent {
+ wrapWithRootIfNecessary(root) {
+ BasicText("two")
+ }
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ wrapperText.value = "ENO"
+ composeRule.onNodeWithText("ENO").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ }
+
+ @Test fun wrapWithRootIfNecessary_rewrapsWhenDifferentRoot() {
+ val root1: CompositionRoot = { content ->
+ Column {
+ BasicText("one")
+ content()
+ }
+ }
+ val root2: CompositionRoot = { content ->
+ Column {
+ BasicText("ENO")
+ content()
+ }
+ }
+ val viewEnvironment = mutableStateOf(root1)
+
+ composeRule.setContent {
+ wrapWithRootIfNecessary(viewEnvironment.value) {
+ BasicText("two")
+ }
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ viewEnvironment.value = root2
+ composeRule.onNodeWithText("ENO").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ }
+
+ @Test fun safeComposeViewFactoryRoot_wraps_content() {
+ val wrapped: CompositionRoot = { content ->
+ Column {
+ BasicText("Parent")
+ content()
+ }
+ }
+ val safeRoot = safeCompositionRoot(wrapped)
+
+ composeRule.setContent {
+ safeRoot {
+ BasicText("Child")
+ }
+ }
+
+ composeRule.onNodeWithText("Parent").assertIsDisplayed()
+ composeRule.onNodeWithText("Child").assertIsDisplayed()
+ }
+
+ @Test fun safeComposeViewFactoryRoot_throws_whenChildrenNotInvoked() {
+ val wrapped: CompositionRoot = { }
+ val safeRoot = safeCompositionRoot(wrapped)
+
+ val error = assertFailsWith {
+ composeRule.setContent {
+ safeRoot {}
+ }
+ }
+
+ assertThat(error).hasMessageThat()
+ .isEqualTo(
+ "Expected ComposableDecorator to invoke children exactly once, but was invoked 0 times."
+ )
+ }
+
+ @Test fun safeComposeViewFactoryRoot_throws_whenChildrenInvokedMultipleTimes() {
+ val wrapped: CompositionRoot = { children ->
+ children()
+ children()
+ }
+ val safeRoot = safeCompositionRoot(wrapped)
+
+ val error = assertFailsWith {
+ composeRule.setContent {
+ safeRoot {
+ BasicText("Hello")
+ }
+ }
+ }
+
+ assertThat(error).hasMessageThat()
+ .isEqualTo(
+ "Expected ComposableDecorator to invoke children exactly once, but was invoked 2 times."
+ )
+ }
+}
diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/RenderAsStateTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/RenderAsStateTest.kt
new file mode 100644
index 0000000000..21723b7f5b
--- /dev/null
+++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/RenderAsStateTest.kt
@@ -0,0 +1,225 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.runtime.Providers
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistry
+import androidx.compose.runtime.savedinstancestate.UiSavedStateRegistryAmbient
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import com.google.common.truth.Truth.assertThat
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.Workflow
+import com.squareup.workflow.action
+import com.squareup.workflow.parse
+import com.squareup.workflow.readUtf8WithLength
+import com.squareup.workflow.stateless
+import com.squareup.workflow.ui.compose.RenderAsStateTest.SnapshottingWorkflow.SnapshottedRendering
+import com.squareup.workflow.writeUtf8WithLength
+import okio.ByteString
+import okio.ByteString.Companion.decodeBase64
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class RenderAsStateTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun passesPropsThrough() {
+ val workflow = Workflow.stateless { it }
+ lateinit var initialRendering: String
+
+ composeRule.setContent {
+ initialRendering = workflow.renderAsState("foo").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).value
+ }
+
+ composeRule.waitForIdle()
+ assertThat(rendering).isEqualTo("foo")
+ props.value = "bar"
+ composeRule.waitForIdle()
+ 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(onOutput = { receivedOutputs += it }).value
+ }
+
+ composeRule.waitForIdle()
+ assertThat(receivedOutputs).isEmpty()
+ rendering("one")
+
+ composeRule.waitForIdle()
+ assertThat(receivedOutputs).isEqualTo(listOf("one"))
+ rendering("two")
+
+ composeRule.waitForIdle()
+ assertThat(receivedOutputs).isEqualTo(listOf("one", "two"))
+ }
+
+ @Test fun savesSnapshot() {
+ val workflow = SnapshottingWorkflow()
+ val savedStateRegistry = UiSavedStateRegistry(emptyMap()) { true }
+ lateinit var rendering: SnapshottedRendering
+
+ composeRule.setContent {
+ Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) {
+ rendering = renderAsStateImpl(
+ workflow,
+ props = Unit,
+ onOutput = {},
+ diagnosticListener = null,
+ snapshotKey = SNAPSHOT_KEY
+ ).value
+ }
+ }
+
+ composeRule.waitForIdle()
+ assertThat(rendering.string).isEmpty()
+ rendering.updateString("foo")
+
+ composeRule.waitForIdle()
+ val savedValues = savedStateRegistry.performSave()
+ println("saved keys: ${savedValues.keys}")
+ // Relying on the int key across all runtimes is brittle, so use an explicit key.
+ val snapshot = ByteString.of(*(savedValues.getValue(SNAPSHOT_KEY).single() as ByteArray))
+ println("snapshot: ${snapshot.base64()}")
+ assertThat(snapshot).isEqualTo(EXPECTED_SNAPSHOT)
+ }
+
+ @Test fun restoresSnapshot() {
+ val workflow = SnapshottingWorkflow()
+ val restoreValues = mapOf(SNAPSHOT_KEY to listOf(EXPECTED_SNAPSHOT.toByteArray()))
+ val savedStateRegistry = UiSavedStateRegistry(restoreValues) { true }
+ lateinit var rendering: SnapshottedRendering
+
+ composeRule.setContent {
+ Providers(UiSavedStateRegistryAmbient provides savedStateRegistry) {
+ rendering = renderAsStateImpl(
+ workflow,
+ props = Unit,
+ onOutput = {},
+ diagnosticListener = null,
+ snapshotKey = "workflow-snapshot"
+ ).value
+ }
+ }
+
+ composeRule.waitForIdle()
+ assertThat(rendering.string).isEqualTo("foo")
+ }
+
+ @Test fun restoresFromSnapshotWhenWorkflowChanged() {
+ val workflow1 = SnapshottingWorkflow()
+ val workflow2 = SnapshottingWorkflow()
+ val currentWorkflow = mutableStateOf(workflow1)
+ lateinit var rendering: SnapshottedRendering
+
+ var compositionCount = 0
+ var lastCompositionCount = 0
+ fun assertWasRecomposed() {
+ assertThat(compositionCount).isGreaterThan(lastCompositionCount)
+ lastCompositionCount = compositionCount
+ }
+
+ composeRule.setContent {
+ compositionCount++
+ rendering = currentWorkflow.value.renderAsState().value
+ }
+
+ // Initialize the first workflow.
+ composeRule.waitForIdle()
+ assertThat(rendering.string).isEmpty()
+ assertWasRecomposed()
+ rendering.updateString("one")
+ composeRule.waitForIdle()
+ 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
+
+ composeRule.waitForIdle()
+ assertWasRecomposed()
+ assertThat(rendering.string).isEqualTo("one")
+ }
+
+ 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(
+ props: Unit,
+ state: String,
+ context: RenderContext
+ ) = SnapshottedRendering(
+ string = state,
+ updateString = { newString -> context.actionSink.send(updateString(newString)) }
+ )
+
+ override fun snapshotState(state: String): Snapshot =
+ Snapshot.write { it.writeUtf8WithLength(state) }
+
+ private fun updateString(newString: String) = action {
+ nextState = newString
+ }
+ }
+}
diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ViewEnvironmentsTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ViewEnvironmentsTest.kt
new file mode 100644
index 0000000000..2328505394
--- /dev/null
+++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/ViewEnvironmentsTest.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ViewEnvironmentsTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun workflowRendering_recomposes_whenFactoryChanged() {
+ val registry1 = ViewRegistry(composedViewFactory { rendering, _ ->
+ BasicText(rendering)
+ })
+ val registry2 = ViewRegistry(composedViewFactory { rendering, _ ->
+ BasicText(rendering.reversed())
+ })
+ val registry = mutableStateOf(registry1)
+
+ composeRule.setContent {
+ WorkflowRendering("hello", ViewEnvironment(registry.value))
+ }
+
+ composeRule.onNodeWithText("hello").assertIsDisplayed()
+ registry.value = registry2
+ composeRule.onNodeWithText("olleh").assertIsDisplayed()
+ }
+}
diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/WorkflowContainerTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/WorkflowContainerTest.kt
new file mode 100644
index 0000000000..85f1dedcaf
--- /dev/null
+++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/WorkflowContainerTest.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import com.squareup.workflow.Workflow
+import com.squareup.workflow.stateless
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class WorkflowContainerTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun rendersFromViewRegistry() {
+ val workflow = Workflow.stateless { "hello" }
+ val registry = ViewRegistry(composedViewFactory { rendering, _ ->
+ BasicText(rendering)
+ })
+
+ composeRule.setContent {
+ WorkflowContainer(workflow, ViewEnvironment(registry))
+ }
+
+ composeRule.onNodeWithText("hello").assertIsDisplayed()
+ }
+
+ @Test fun automaticallyAddsComposeRenderingFactory() {
+ val workflow = Workflow.composed { _, _, _ ->
+ BasicText("it worked")
+ }
+ val registry = ViewRegistry()
+
+ composeRule.setContent {
+ WorkflowContainer(workflow, ViewEnvironment(registry))
+ }
+
+ composeRule.onNodeWithText("it worked").assertIsDisplayed()
+ }
+}
diff --git a/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt
new file mode 100644
index 0000000000..abfb507072
--- /dev/null
+++ b/compose/core-compose/src/androidTest/java/com/squareup/workflow/ui/compose/internal/ViewFactoriesTest.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose.internal
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+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.onNodeWithText
+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.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.ui.test.createComposeRule
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.bindShowRendering
+import com.squareup.workflow.ui.compose.WorkflowRendering
+import com.squareup.workflow.ui.compose.composedViewFactory
+import com.squareup.workflow.ui.compose.withCompositionRoot
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ViewFactoriesTest {
+
+ @Rule @JvmField val composeRule = createComposeRule()
+
+ @Test fun WorkflowRendering_wrapsFactoryWithRoot_whenAlreadyInComposition() {
+ val viewEnvironment = ViewEnvironment(ViewRegistry(TestFactory))
+ .withCompositionRoot { content ->
+ Column {
+ BasicText("one")
+ content()
+ }
+ }
+
+ composeRule.setContent {
+ WorkflowRendering(TestRendering("two"), viewEnvironment)
+ }
+
+ composeRule.onNodeWithText("one").assertIsDisplayed()
+ composeRule.onNodeWithText("two").assertIsDisplayed()
+ }
+
+ @Test fun WorkflowRendering_legacyAndroidViewRendersUpdates() {
+ val wrapperText = mutableStateOf("two")
+ val viewEnvironment = ViewEnvironment(ViewRegistry(LegacyViewViewFactory))
+
+ composeRule.setContent {
+ WorkflowRendering(LegacyViewRendering(wrapperText.value), viewEnvironment)
+ }
+
+ onView(withText("two")).check(matches(isDisplayed()))
+ wrapperText.value = "OWT"
+ onView(withText("OWT")).check(matches(isDisplayed()))
+ }
+
+ private data class TestRendering(val text: String)
+ private data class LegacyViewRendering(val text: String)
+
+ private companion object {
+ val TestFactory = composedViewFactory { rendering, _ ->
+ BasicText(rendering.text)
+ }
+ val LegacyViewViewFactory = object : ViewFactory {
+ override val type = LegacyViewRendering::class
+
+ override fun buildView(
+ initialRendering: LegacyViewRendering,
+ initialViewEnvironment: ViewEnvironment,
+ contextForNewView: Context,
+ container: ViewGroup?
+ ): View {
+ return TextView(contextForNewView).apply {
+ bindShowRendering(initialRendering, initialViewEnvironment) { rendering, _ ->
+ text = rendering.text
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/compose/core-compose/src/main/AndroidManifest.xml b/compose/core-compose/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..4b26d2a703
--- /dev/null
+++ b/compose/core-compose/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeRendering.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeRendering.kt
new file mode 100644
index 0000000000..a90681cc4a
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeRendering.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.runtime.Composable
+import com.squareup.workflow.ui.compose.ComposeRendering.Companion.Factory
+import com.squareup.workflow.ui.compose.ComposeRendering.Companion.NoopRendering
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+
+/**
+ * A workflow rendering that renders itself using a [Composable] function.
+ *
+ * This is the rendering type of [ComposeWorkflow]. To stub out [ComposeWorkflow]s in `RenderTester`
+ * tests, use [NoopRendering].
+ *
+ * To use this type, make sure your `ViewRegistry` registers [Factory].
+ */
+class ComposeRendering internal constructor(
+ internal val render: @Composable (ViewEnvironment) -> Unit
+) {
+ companion object {
+ /**
+ * A [ViewFactory] that renders a [ComposeRendering].
+ */
+ val Factory: ViewFactory = composedViewFactory { rendering, environment ->
+ rendering.render(environment)
+ }
+
+ /**
+ * A [ComposeRendering] that doesn't do anything. Useful for unit testing.
+ */
+ val NoopRendering = ComposeRendering {}
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt
new file mode 100644
index 0000000000..95433884be
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeViewFactory.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2019 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+// See https://youtrack.jetbrains.com/issue/KT-31734
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ExperimentalComposeApi
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.platform.setContent
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.bindShowRendering
+import com.squareup.workflow.ui.compose.internal.ParentComposition
+import kotlin.reflect.KClass
+
+/**
+ * Creates a [ViewFactory] that uses a [Composable] function to display the rendering.
+ *
+ * Note that the function you pass in will not have any `MaterialTheme` applied, so views that rely
+ * on Material theme attributes must be explicitly wrapped with `MaterialTheme`.
+ *
+ * Simple usage:
+ *
+ * ```
+ * // Function references to @Composable functions aren't supported yet.
+ * val FooBinding = composedViewFactory { showFoo(it) }
+ *
+ * @Composable
+ * private fun showFoo(foo: FooRendering) {
+ * Text(foo.message)
+ * }
+ *
+ * …
+ *
+ * val viewRegistry = ViewRegistry(FooBinding, …)
+ * ```
+ *
+ * ## 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 [ViewFactory]s. Regular
+ * [ViewFactory]s and `LayoutRunner`s use
+ * [WorkflowViewStub][com.squareup.workflow.ui.WorkflowViewStub] to recursively show nested
+ * renderings using the [ViewRegistry][com.squareup.workflow.ui.ViewRegistry].
+ *
+ * View factories defined using this function 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 ambients 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
+ *
+ * Often all the [composedViewFactory] factories in an app need to share some context – for example,
+ * certain ambients need to be provided, such as `MaterialTheme`. To configure this shared context,
+ * call [withCompositionRoot] on your top-level [ViewEnvironment]. The first time a
+ * [composedViewFactory] is used to show a rendering, its [showRendering] function will be wrapped
+ * with the [CompositionRoot]. See the documentation on [CompositionRoot] for more information.
+ */
+inline fun composedViewFactory(
+ noinline showRendering: @Composable (
+ rendering: RenderingT,
+ environment: ViewEnvironment
+ ) -> Unit
+): ViewFactory = ComposeViewFactory(RenderingT::class, showRendering)
+
+@PublishedApi
+internal class ComposeViewFactory(
+ override val type: KClass,
+ internal val content: @Composable (RenderingT, ViewEnvironment) -> Unit
+) : ViewFactory {
+
+ @OptIn(ExperimentalComposeApi::class)
+ override fun buildView(
+ initialRendering: RenderingT,
+ initialViewEnvironment: ViewEnvironment,
+ contextForNewView: Context,
+ container: ViewGroup?
+ ): View {
+ // There is currently no way to automatically generate an Android View directly from a
+ // Composable function, so we need to use ViewGroup.setContent.
+ val parentComposition = initialViewEnvironment[ParentComposition].reference
+ val composeContainer = FrameLayout(contextForNewView)
+
+ if (parentComposition == null) {
+ // This composition will be the "root" – it must not be recomposed.
+
+ val state = mutableStateOf(Pair(initialRendering, initialViewEnvironment))
+ composeContainer.bindShowRendering(
+ initialRendering,
+ initialViewEnvironment
+ ) { rendering, environment ->
+ state.value = Pair(rendering, environment)
+ }
+
+ composeContainer.setContent {
+ val (rendering, environment) = state.value
+ content(rendering, environment)
+ }
+ } else {
+ // This composition will be a subcomposition of another composition, we must recompose it
+ // manually every time something changes. This is not documented anywhere, but according to
+ // Compose devs it is part of the contract of subcomposition.
+
+ // Update the state whenever a new rendering is emitted.
+ // This lambda will be executed synchronously before bindShowRendering returns.
+ composeContainer.bindShowRendering(
+ initialRendering,
+ initialViewEnvironment
+ ) { rendering, environment ->
+ // Entry point to the world of Compose.
+ composeContainer.setContent(parentComposition) {
+ content(rendering, environment)
+ }
+ }
+ }
+
+ return composeContainer
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeWorkflow.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeWorkflow.kt
new file mode 100644
index 0000000000..6d17a63a6c
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ComposeWorkflow.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.runtime.Composable
+import com.squareup.workflow.Sink
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.Workflow
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.compose.internal.ComposeWorkflowImpl
+
+/**
+ * A stateless [Workflow][com.squareup.workflow.Workflow] that [renders][render] itself as
+ * [Composable] function. Effectively defines an inline
+ * [composedViewFactory][com.squareup.workflow.ui.compose.composedViewFactory].
+ *
+ * This workflow does not have access to a [RenderContext][com.squareup.workflow.RenderContext]
+ * since render contexts are only valid during render passes, and this workflow's [render] method
+ * is invoked after the render pass, when view bindings are being shown.
+ *
+ * While this workflow is "stateless" in the usual workflow sense (it doesn't have a `StateT` type),
+ * since [render] is a Composable function, it can use all the usual Compose facilities for state
+ * management.
+ */
+abstract class ComposeWorkflow :
+ Workflow {
+
+ /**
+ * Renders [props] using Compose. This function will be called to update the UI whenever the
+ * [props] or [viewEnvironment] changes.
+ *
+ * @param props The data to render.
+ * @param outputSink A [Sink] that can be used from UI event handlers to send outputs to this
+ * workflow's parent.
+ * @param viewEnvironment The [ViewEnvironment] passed down through the `ViewBinding` pipeline.
+ */
+ @Composable abstract fun render(
+ props: PropsT,
+ outputSink: Sink,
+ viewEnvironment: ViewEnvironment
+ )
+
+ override fun asStatefulWorkflow(): StatefulWorkflow =
+ ComposeWorkflowImpl(this)
+}
+
+/**
+ * Returns a [ComposeWorkflow] that renders itself using the given [render] function.
+ */
+inline fun Workflow.Companion.composed(
+ crossinline render: @Composable (
+ props: PropsT,
+ outputSink: Sink,
+ environment: ViewEnvironment
+ ) -> Unit
+): ComposeWorkflow = object : ComposeWorkflow() {
+ @Composable override fun render(
+ props: PropsT,
+ outputSink: Sink,
+ viewEnvironment: ViewEnvironment
+ ) {
+ render(props, outputSink, viewEnvironment)
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt
new file mode 100644
index 0000000000..c1325e45ec
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/CompositionRoot.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose
+
+import androidx.annotation.VisibleForTesting
+import androidx.annotation.VisibleForTesting.PRIVATE
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Providers
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticAmbientOf
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.compose.internal.mapFactories
+
+/**
+ * Used by [wrapWithRootIfNecessary] to ensure the [CompositionRoot] is only applied once.
+ */
+private val HasViewFactoryRootBeenApplied = staticAmbientOf { false }
+
+/**
+ * A `@Composable` function that will be used to wrap the first (highest-level)
+ * [composedViewFactory] view factory in a composition. This can be used to setup any ambients that
+ * all [composedViewFactory] factories need access to, such as e.g. UI themes.
+ *
+ * This function will called once, to wrap the _highest-level_ [composedViewFactory] in the tree.
+ * However, ambients are propagated down to child [composedViewFactory] compositions, so any
+ * ambients provided here will be available in _all_ [composedViewFactory] compositions.
+ */
+typealias CompositionRoot = @Composable (content: @Composable () -> Unit) -> Unit
+
+/**
+ * Convenience function for applying a [CompositionRoot] to this [ViewEnvironment]'s [ViewRegistry].
+ * See [ViewRegistry.withCompositionRoot].
+ */
+fun ViewEnvironment.withCompositionRoot(root: CompositionRoot): ViewEnvironment =
+ this + (ViewRegistry to this[ViewRegistry].withCompositionRoot(root))
+
+/**
+ * Returns a [ViewRegistry] that ensures that any [composedViewFactory] factories registered in this
+ * registry will be wrapped exactly once with a [CompositionRoot] wrapper.
+ * See [CompositionRoot] for more information.
+ */
+fun ViewRegistry.withCompositionRoot(root: CompositionRoot): ViewRegistry =
+ mapFactories { factory ->
+ @Suppress("UNCHECKED_CAST", "SafeCastWithReturn")
+ factory as? ComposeViewFactory ?: return@mapFactories factory
+
+ @Suppress("UNCHECKED_CAST")
+ ComposeViewFactory(factory.type) { rendering, environment ->
+ wrapWithRootIfNecessary(root) {
+ factory.content(rendering, environment)
+ }
+ }
+ }
+
+/**
+ * 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 wrapWithRootIfNecessary(
+ root: CompositionRoot,
+ content: @Composable () -> Unit
+) {
+ if (HasViewFactoryRootBeenApplied.current) {
+ // The only way this ambient 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 ambient. Since the root
+ // wrapper will have already been applied, we can just compose content directly.
+ content()
+ } else {
+ // If the ambient is false, this is the first time this function has appeared in the composition
+ // so far. We provide a true value for the ambient for everything below us, so any recursive
+ // calls to this function will hit the if case above and not re-apply the wrapper.
+ Providers(HasViewFactoryRootBeenApplied provides true) {
+ val safeRoot: CompositionRoot = remember(root) { safeCompositionRoot(root) }
+ safeRoot(content)
+ }
+ }
+}
+
+/**
+ * [CompositionRoot] that asserts that the content method invokes its children parameter
+ * exactly once, and throws an [IllegalStateException] if not.
+ */
+internal fun safeCompositionRoot(delegate: CompositionRoot): CompositionRoot = { content ->
+ var childrenCalledCount = 0
+ delegate {
+ childrenCalledCount++
+ content()
+ }
+ check(childrenCalledCount == 1) {
+ "Expected ComposableDecorator to invoke children exactly once, " +
+ "but was invoked $childrenCalledCount times."
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/RenderAsState.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/RenderAsState.kt
new file mode 100644
index 0000000000..f94f08f8c6
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/RenderAsState.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("NOTHING_TO_INLINE")
+
+package com.squareup.workflow.ui.compose
+
+import androidx.annotation.VisibleForTesting
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLifecycleObserver
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.savedinstancestate.Saver
+import androidx.compose.runtime.savedinstancestate.SaverScope
+import androidx.compose.runtime.savedinstancestate.savedInstanceState
+import androidx.compose.ui.node.Ref
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.Workflow
+import com.squareup.workflow.diagnostic.WorkflowDiagnosticListener
+import com.squareup.workflow.launchWorkflowIn
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.consumeAsFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+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].
+ *
+ * The workflow runtime will be started when this function is first added to the composition, and
+ * cancelled when it is removed. 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.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @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 onOutput A function that will be executed whenever the root [Workflow] emits an output.
+ * @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
+ * this value changes while this function is in the composition, the runtime will be restarted.
+ */
+@Composable
+fun Workflow.renderAsState(
+ props: PropsT,
+ onOutput: (OutputT) -> Unit,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+): State = renderAsStateImpl(this, props, onOutput, diagnosticListener)
+
+/**
+ * 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].
+ *
+ * The workflow runtime will be started when this function is first added to the composition, and
+ * cancelled when it is removed. 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.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @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 onOutput A function that will be executed whenever the root [Workflow] emits an output.
+ * @param diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
+ * this value changes while this function is in the composition, the runtime will be restarted.
+ */
+@Composable
+inline fun Workflow.renderAsState(
+ noinline onOutput: (OutputT) -> Unit,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+): State = renderAsState(Unit, onOutput, diagnosticListener)
+
+/**
+ * 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].
+ *
+ * The workflow runtime will be started when this function is first added to the composition, and
+ * cancelled when it is removed. 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.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @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 diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
+ * this value changes while this function is in the composition, the runtime will be restarted.
+ */
+@Composable
+inline fun Workflow.renderAsState(
+ props: PropsT,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+): State = renderAsState(props, {}, diagnosticListener)
+
+/**
+ * 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].
+ *
+ * The workflow runtime will be started when this function is first added to the composition, and
+ * cancelled when it is removed. 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.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @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 diagnosticListener An optional [WorkflowDiagnosticListener] to start the runtime with. If
+ * this value changes while this function is in the composition, the runtime will be restarted.
+ */
+@Composable
+inline fun Workflow.renderAsState(
+ diagnosticListener: WorkflowDiagnosticListener? = null
+): State = renderAsState(Unit, {}, diagnosticListener)
+
+/**
+ * @param snapshotKey Allows tests to pass in a custom key to use to save/restore the snapshot from
+ * the [UiSavedStateRegistryAmbient]. If null, will use the default key based on source location.
+ */
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+@Composable internal fun renderAsStateImpl(
+ workflow: Workflow,
+ props: PropsT,
+ onOutput: (OutputT) -> Unit,
+ diagnosticListener: WorkflowDiagnosticListener?,
+ snapshotKey: String? = null
+): State {
+ // TODO Pass Dispatchers.Main.immediate and merge two scope vals when this bug is fixed:
+ // https://issuetracker.google.com/issues/165674304
+ val baseScope = rememberCoroutineScope()
+ val workflowScope = remember { baseScope + Dispatchers.Main.immediate }
+ val snapshotState = savedInstanceState(key = snapshotKey, saver = SnapshotSaver) { null }
+
+ val outputRef = remember { Ref<(OutputT) -> Unit>() }
+ outputRef.value = onOutput
+
+ // We can't use onActive/on(Pre)Commit because they won't run their callback until after this
+ // function returns, and we need to run this immediately so we get the rendering synchronously.
+ val state = remember(workflow, diagnosticListener) {
+ WorkflowState(workflowScope, workflow, props, outputRef, snapshotState, diagnosticListener)
+ }
+ state.setProps(props)
+
+ return state.rendering
+}
+
+@Suppress("EXPERIMENTAL_API_USAGE")
+private class WorkflowState(
+ private val workflowScope: CoroutineScope,
+ workflow: Workflow,
+ initialProps: PropsT,
+ private val outputRef: Ref<(OutputT) -> Unit>,
+ private val snapshotState: MutableState,
+ private val diagnosticListener: WorkflowDiagnosticListener?
+) : CompositionLifecycleObserver {
+
+ private val renderingState = mutableStateOf(null)
+
+ // This can be a StateFlow once coroutines is upgraded to 1.3.6.
+ private val propsChannel = Channel(capacity = Channel.CONFLATED)
+ .apply { offer(initialProps) }
+ val propsFlow = propsChannel.consumeAsFlow()
+ .distinctUntilChanged()
+
+ // The value is guaranteed to be set before returning, so this cast is fine.
+ @Suppress("UNCHECKED_CAST")
+ val rendering: State
+ get() = renderingState as State
+
+ init {
+ launchWorkflowIn(workflowScope, workflow, propsFlow, snapshotState.value) { session ->
+ session.diagnosticListener = diagnosticListener
+
+ session.outputs.onEach { outputRef.value!!.invoke(it) }
+ .launchIn(this)
+
+ session.renderingsAndSnapshots
+ .onEach { (rendering, snapshot) ->
+ renderingState.value = rendering
+ snapshotState.value = snapshot
+ }
+ .launchIn(this)
+ }
+ }
+
+ fun setProps(props: PropsT) {
+ propsChannel.offer(props)
+ }
+
+ override fun onEnter() {}
+
+ override fun onLeave() {
+ workflowScope.cancel()
+ }
+}
+
+private object SnapshotSaver : Saver {
+ override fun SaverScope.save(value: Snapshot?): ByteArray {
+ return value?.bytes?.toByteArray() ?: ByteArray(0)
+ }
+
+ override fun restore(value: ByteArray): Snapshot? {
+ return value.takeUnless { it.isEmpty() }
+ ?.let { bytes -> Snapshot.of(ByteString.of(*bytes)) }
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewEnvironments.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewEnvironments.kt
new file mode 100644
index 0000000000..1847655083
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/ViewEnvironments.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.compose.internal.WorkflowRendering
+
+/**
+ * Renders [rendering] into the composition using this [ViewEnvironment]'s
+ * [ViewRegistry][com.squareup.workflow.ui.ViewRegistry] to generate the view.
+ *
+ * This function fulfills a similar role as
+ * [WorkflowViewStub][com.squareup.workflow.ui.WorkflowViewStub], but is much more convenient to use
+ * from Composable functions.
+ *
+ * ## Example
+ *
+ * ```
+ * data class FramedRendering(
+ * val borderColor: Color,
+ * val child: Any
+ * )
+ *
+ * val FramedContainerViewFactory = composedViewFactory { rendering, environment ->
+ * Surface(border = Border(rendering.borderColor, 8.dp)) {
+ * WorkflowRendering(rendering.child, environment)
+ * }
+ * }
+ * ```
+ *
+ * @param rendering The workflow rendering to display. May be of any type for which a
+ * [ViewFactory][com.squareup.workflow.ui.ViewFactory] has been registered in this
+ * environment's [ViewRegistry].
+ * @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.
+ */
+@Composable fun WorkflowRendering(
+ rendering: Any,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier = Modifier
+) {
+ val viewRegistry = remember(viewEnvironment) { viewEnvironment[ViewRegistry] }
+ val renderingType = rendering::class
+ val viewFactory = remember(viewRegistry, renderingType) {
+ viewRegistry.getFactoryFor(renderingType)
+ }
+ WorkflowRendering(rendering, viewFactory, viewEnvironment, modifier)
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/WorkflowContainer.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/WorkflowContainer.kt
new file mode 100644
index 0000000000..22edc2c864
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/WorkflowContainer.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress(
+ "FunctionNaming",
+ "NOTHING_TO_INLINE"
+)
+
+package com.squareup.workflow.ui.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.Workflow
+import com.squareup.workflow.diagnostic.WorkflowDiagnosticListener
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.plus
+
+/**
+ * Render a [Workflow]'s renderings.
+ *
+ * When this function is first composed it will start a new runtime. This runtime will be restarted
+ * any time [workflow], [diagnosticListener], or the `CoroutineContext`
+ * changes. The runtime will be cancelled when this function stops composing.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @param workflow The [Workflow] to render.
+ * @param props The props to render the root workflow with. If this value changes between calls,
+ * the workflow runtime will re-render with the new props.
+ * @param onOutput A function that will be invoked any time the root workflow emits an output.
+ * @param viewEnvironment The [ViewEnvironment] used to display renderings.
+ * @param modifier The [Modifier] to apply to the root [ViewFactory].
+ * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime.
+ */
+@Composable fun WorkflowContainer(
+ workflow: Workflow,
+ props: PropsT,
+ onOutput: (OutputT) -> Unit,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier = Modifier,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+) {
+ // Ensure ComposeRendering is in the ViewRegistry.
+ val realEnvironment = remember(viewEnvironment) {
+ viewEnvironment.withFactory(ComposeRendering.Factory)
+ }
+
+ val rendering = workflow.renderAsState(props, onOutput, diagnosticListener)
+ WorkflowRendering(rendering.value, realEnvironment, modifier)
+}
+
+/**
+ * Render a [Workflow]'s renderings.
+ *
+ * When this function is first composed it will start a new runtime. This runtime will be restarted
+ * any time [workflow], [diagnosticListener], or the `CoroutineContext`
+ * changes. The runtime will be cancelled when this function stops composing.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @param workflow The [Workflow] to render.
+ * @param onOutput A function that will be invoked any time the root workflow emits an output.
+ * @param viewEnvironment The [ViewEnvironment] used to display renderings.
+ * @param modifier The [Modifier] to apply to the root [ViewFactory].
+ * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime.
+ */
+@Composable inline fun WorkflowContainer(
+ workflow: Workflow,
+ noinline onOutput: (OutputT) -> Unit,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier = Modifier,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+) {
+ WorkflowContainer(workflow, Unit, onOutput, viewEnvironment, modifier, diagnosticListener)
+}
+
+/**
+ * Render a [Workflow]'s renderings.
+ *
+ * When this function is first composed it will start a new runtime. This runtime will be restarted
+ * any time [workflow], [diagnosticListener], or the `CoroutineContext`
+ * changes. The runtime will be cancelled when this function stops composing.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @param workflow The [Workflow] to render.
+ * @param props The props to render the root workflow with. If this value changes between calls,
+ * the workflow runtime will re-render with the new props.
+ * @param viewEnvironment The [ViewEnvironment] used to display renderings.
+ * @param modifier The [Modifier] to apply to the root [ViewFactory].
+ * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime.
+ */
+@Composable inline fun WorkflowContainer(
+ workflow: Workflow,
+ props: PropsT,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier = Modifier,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+) {
+ WorkflowContainer(workflow, props, {}, viewEnvironment, modifier, diagnosticListener)
+}
+
+/**
+ * Render a [Workflow]'s renderings.
+ *
+ * When this function is first composed it will start a new runtime. This runtime will be restarted
+ * any time [workflow], [diagnosticListener], or the `CoroutineContext`
+ * changes. The runtime will be cancelled when this function stops composing.
+ *
+ * [Snapshot]s from the runtime will automatically be saved to the current
+ * [UiSavedStateRegistry][androidx.ui.savedinstancestate.UiSavedStateRegistry]. When the runtime is
+ * started, if a snapshot exists in the registry, it will be used to restore the workflows.
+ *
+ * @param workflow The [Workflow] to render.
+ * @param viewEnvironment The [ViewEnvironment] used to display renderings.
+ * @param modifier The [Modifier] to apply to the root [ViewFactory].
+ * @param diagnosticListener A [WorkflowDiagnosticListener] to configure on the runtime.
+ */
+@Composable inline fun WorkflowContainer(
+ workflow: Workflow,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier = Modifier,
+ diagnosticListener: WorkflowDiagnosticListener? = null
+) {
+ WorkflowContainer(workflow, Unit, {}, viewEnvironment, modifier, diagnosticListener)
+}
+
+private fun ViewEnvironment.withFactory(viewFactory: ViewFactory<*>): ViewEnvironment {
+ return this[ViewRegistry].let { registry ->
+ if (viewFactory.type !in registry.keys) {
+ this + (ViewRegistry to registry + viewFactory)
+ } else this
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ComposeWorkflowImpl.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ComposeWorkflowImpl.kt
new file mode 100644
index 0000000000..7e59a076a4
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ComposeWorkflowImpl.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose.internal
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.structuralEqualityPolicy
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Sink
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.action
+import com.squareup.workflow.contraMap
+import com.squareup.workflow.ui.compose.ComposeRendering
+import com.squareup.workflow.ui.compose.ComposeWorkflow
+import com.squareup.workflow.ui.compose.internal.ComposeWorkflowImpl.State
+
+internal class ComposeWorkflowImpl(
+ private val workflow: ComposeWorkflow
+) : StatefulWorkflow, OutputT, ComposeRendering>() {
+
+ // This doesn't need to be a @Model, it only gets set once, before the composable ever runs.
+ class SinkHolder(var sink: Sink? = null)
+
+ data class State(
+ val propsHolder: MutableState,
+ val sinkHolder: SinkHolder,
+ val rendering: ComposeRendering
+ )
+
+ override fun initialState(
+ props: PropsT,
+ snapshot: Snapshot?
+ ): State {
+ val propsHolder = mutableStateOf(props, policy = structuralEqualityPolicy())
+ val sinkHolder = SinkHolder()
+ return State(propsHolder, sinkHolder, ComposeRendering { environment ->
+ // The sink will get set on the first render pass, so it should never be null.
+ val sink = sinkHolder.sink!!
+ // Important: Use the props from the MutableState, _not_ the one passed into render.
+ workflow.render(propsHolder.value, sink, environment)
+ })
+ }
+
+ override fun onPropsChanged(
+ old: PropsT,
+ new: PropsT,
+ state: State
+ ): State {
+ state.propsHolder.value = new
+ return state
+ }
+
+ override fun render(
+ props: PropsT,
+ state: State,
+ context: RenderContext, OutputT>
+ ): ComposeRendering {
+ // The first render pass needs to cache the sink. The sink is reusable, so we can just pass the
+ // same one every time.
+ if (state.sinkHolder.sink == null) {
+ state.sinkHolder.sink = context.actionSink.contraMap(::forwardOutput)
+ }
+
+ // onPropsChanged will ensure the rendering is re-composed when the props changes.
+ return state.rendering
+ }
+
+ // Compiler bug doesn't let us call Snapshot.EMPTY.
+ override fun snapshotState(state: State): Snapshot = Snapshot.of("")
+
+ private fun forwardOutput(output: OutputT) = action { setOutput(output) }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt
new file mode 100644
index 0000000000..247ca15fc9
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ParentComposition.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.workflow.ui.compose.internal
+
+import androidx.compose.runtime.CompositionReference
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewEnvironmentKey
+
+/**
+ * Holds a [CompositionReference] that can be passed to [setContent] to create a composition that is
+ * a child of another composition. Subcompositions get ambients and other compose context from their
+ * parent, and propagate invalidations, which allows ambients provided around a [WorkflowRendering]
+ * call to be read by nested Compose-based view factories.
+ *
+ * When [WorkflowRendering] is called, it will store an instance of this class in the
+ * [ViewEnvironment]. `ComposeViewFactory` pulls the reference out of the environment and uses it to
+ * link its composition to the outer one.
+ */
+internal class ParentComposition(
+ var reference: CompositionReference? = null
+) {
+ companion object : ViewEnvironmentKey(ParentComposition::class) {
+ override val default: ParentComposition get() = ParentComposition()
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt
new file mode 100644
index 0000000000..00d15ff42e
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewFactories.kt
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose.internal
+
+import android.content.Context
+import android.view.View
+import android.view.ViewGroup.LayoutParams.MATCH_PARENT
+import android.widget.FrameLayout
+import androidx.compose.foundation.layout.Box
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.compositionReference
+import androidx.compose.runtime.onCommit
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.node.Ref
+import androidx.compose.ui.viewinterop.AndroidView
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.canShowRendering
+import com.squareup.workflow.ui.compose.ComposeViewFactory
+import com.squareup.workflow.ui.getRendering
+import com.squareup.workflow.ui.showRendering
+import kotlin.properties.Delegates.observable
+
+/**
+ * Renders [rendering] into the composition using [viewFactory].
+ *
+ * To display a nested rendering from a
+ * [Composable view binding][com.squareup.workflow.ui.compose.composedViewFactory], use the overload
+ * without a [ViewFactory] parameter.
+ *
+ * *Note: [rendering] must be the same type as this [ViewFactory], even though the type system does
+ * not enforce this constraint. This is due to a Compose compiler bug tracked
+ * [here](https://issuetracker.google.com/issues/156527332).
+ *
+ * @see com.squareup.workflow.ui.compose.WorkflowRendering
+ */
+@Composable internal fun WorkflowRendering(
+ rendering: RenderingT,
+ viewFactory: ViewFactory,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier = Modifier
+) {
+ Box(modifier = modifier) {
+ // "Fast" path: If the child binding is also a Composable, we don't need to go through the
+ // legacy view system and can just invoke the binding's composable function directly.
+ if (viewFactory is ComposeViewFactory) {
+ viewFactory.content(rendering, viewEnvironment)
+ return@Box
+ }
+
+ // "Slow" path: Create a legacy Android View to show the rendering, like WorkflowViewStub.
+ ViewFactoryAndroidView(viewFactory, rendering, viewEnvironment)
+ }
+}
+
+/**
+ * This is effectively the logic of [com.squareup.workflow.ui.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 nice 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 [ViewFactory], since
+ * we already have the correct one.
+ *
+ * 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 [viewEnvironment], and
+ * adds it to the composition.
+ *
+ * This function also passes a [ParentComposition] down through the [ViewEnvironment] so that if the
+ * child view further nests any `ComposableViewFactory`s, they will be correctly subcomposed.
+ */
+@Composable private fun ViewFactoryAndroidView(
+ viewFactory: ViewFactory,
+ rendering: R,
+ viewEnvironment: ViewEnvironment
+) {
+ // Plumb the current composition through the ViewEnvironment so any nested composable factories
+ // get access to any ambients currently in effect.
+ val parentComposition = remember { ParentComposition() }
+ parentComposition.reference = compositionReference()
+ val wrappedEnvironment = remember(viewEnvironment) {
+ viewEnvironment + (ParentComposition to parentComposition)
+ }
+
+ // We can't trigger subcompositions during the composition itself, we have to wait until
+ // the composition is committed. So instead of sending the update in the AndroidView update
+ // lambda, we just store the view here, and then send the update and view factory in an
+ // onPreCommit hook. See https://github.com/square/workflow-kotlin-compose/issues/67.
+ val hostViewRef = remember { Ref() }
+
+ AndroidView(::HostView) {
+ hostViewRef.value = it
+ }
+
+ onCommit {
+ hostViewRef.value?.let { hostView ->
+ hostView.viewFactory = viewFactory
+ hostView.update = Pair(rendering, wrappedEnvironment)
+ }
+ }
+}
+
+/**
+ * This is basically a clone of WorkflowViewStub, but it takes an explicit [ViewFactory] instead
+ * of looking one up itself, and doesn't do the replace-in-parent trick.
+ *
+ * It doesn't seem possible to create the view inside a Composable directly and use
+ * [androidx.ui.viewinterop.AndroidView]. I can't figure out exactly why it doesn't work, but I
+ * think it has something to do with getting into an incorrect state if a non-Composable view
+ * factory synchronously builds and binds a ComposableViewFactory in buildView. In that case, the
+ * second and subsequent compose passes will lose ambients from the parent composition. I've spent
+ * a bunch of time trying to debug compose internals and trying different approaches to figure out
+ * why that is, but nothing makes sense. All I know is that using a custom view like this seems to
+ * fix it.
+ *
+ * …Except in the case where the highest-level ComposableViewFactory isn't a subcomposition (i.e.
+ * the workflow is being ran with setContentWorkflow instead of WorkflowContainer). Or maybe it's
+ * only if the top-level ViewFactory is such a ComposableViewFactory, I haven't tested other legacy
+ * view factories between the root and the top-level CVF. In that case, there seems to be a race
+ * condition with measuring and second compose pass will throw an exception about an unmeasured
+ * node.
+ */
+private class HostView(context: Context) : FrameLayout(context) {
+
+ private var rerender = true
+ private var view: View? = null
+
+ var viewFactory by observable?>(null) { _, old, new ->
+ if (old != new) {
+ update()
+ }
+ }
+
+ var update by observable?>(null) { _, old, new ->
+ if (old != new) {
+ rerender = true
+ update()
+ }
+ }
+
+ init {
+ layoutParams = LayoutParams(MATCH_PARENT, MATCH_PARENT)
+ }
+
+ private fun update() {
+ if (viewFactory == null) return
+ val (rendering, viewEnvironment) = update ?: return
+
+ if (view?.canShowRendering(rendering) != true) {
+ // BuildView must call bindShowRendering, which will call showRendering.
+ @Suppress("UNCHECKED_CAST")
+ view = (viewFactory as ViewFactory)
+ .buildView(rendering, viewEnvironment, context, this)
+
+ check(view!!.getRendering() != null) {
+ "View.bindShowRendering should have been called for $this, typically by the " +
+ "${ViewFactory::class.java.name} that created it."
+ }
+ removeAllViews()
+ addView(view)
+ } else if (rerender) {
+ view!!.showRendering(rendering, viewEnvironment)
+ }
+
+ rerender = false
+ }
+}
diff --git a/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt
new file mode 100644
index 0000000000..8bec77e08f
--- /dev/null
+++ b/compose/core-compose/src/main/java/com/squareup/workflow/ui/compose/internal/ViewRegistries.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.workflow.ui.compose.internal
+
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.ViewRegistry
+import kotlin.reflect.KClass
+
+/**
+ * Applies [transform] to each [ViewFactory] in this registry. Transformations are applied lazily,
+ * at the time of lookup via [ViewRegistry.getFactoryFor].
+ */
+internal fun ViewRegistry.mapFactories(
+ transform: (ViewFactory<*>) -> ViewFactory<*>
+): ViewRegistry = object : ViewRegistry {
+ override val keys: Set> get() = this@mapFactories.keys
+
+ override fun getFactoryFor(
+ renderingType: KClass
+ ): ViewFactory {
+ val transformedFactory = transform(this@mapFactories.getFactoryFor(renderingType))
+ check(transformedFactory.type == renderingType) {
+ "Expected transform to return a ViewFactory that is compatible with $renderingType, " +
+ "but got one with type ${transformedFactory.type}"
+ }
+ @Suppress("UNCHECKED_CAST")
+ return transformedFactory as ViewFactory
+ }
+}
diff --git a/compose/detekt.yml b/compose/detekt.yml
new file mode 100644
index 0000000000..51dc868162
--- /dev/null
+++ b/compose/detekt.yml
@@ -0,0 +1,20 @@
+#
+# Copyright 2020 Square Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+naming:
+ MatchingDeclarationName:
+ active: false
+ FunctionNaming:
+ active: false
diff --git a/compose/gradle.properties b/compose/gradle.properties
new file mode 100644
index 0000000000..7b10a35508
--- /dev/null
+++ b/compose/gradle.properties
@@ -0,0 +1,40 @@
+#
+# Copyright 2017 Square Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+org.gradle.jvmargs='-Dfile.encoding=UTF-8'
+org.gradle.parallel=true
+android.useAndroidX=true
+# Required for ViewBinding.
+android.enableJetifier=true
+
+# Required to publish to Nexus (see https://github.com/gradle/gradle/issues/11308)
+systemProp.org.gradle.internal.publish.checksums.insecure=true
+
+GROUP=com.squareup.workflow
+VERSION_NAME=0.31.0-SNAPSHOT
+
+POM_DESCRIPTION=Reactive workflows
+
+POM_URL=https://github.com/square/workflow/
+POM_SCM_URL=https://github.com/square/workflow/
+POM_SCM_CONNECTION=scm:git:git://github.com/square/workflow.git
+POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/square/workflow.git
+
+POM_LICENCE_NAME=The Apache Software License, Version 2.0
+POM_LICENCE_URL=http://www.apache.org/licenses/LICENSE-2.0.txt
+POM_LICENCE_DIST=repo
+
+POM_DEVELOPER_ID=square
+POM_DEVELOPER_NAME=Square, Inc.
diff --git a/compose/gradle/wrapper/gradle-wrapper.jar b/compose/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000000..e708b1c023
Binary files /dev/null and b/compose/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/compose/gradle/wrapper/gradle-wrapper.properties b/compose/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..14e30f7416
--- /dev/null
+++ b/compose/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/compose/gradlew b/compose/gradlew
new file mode 100755
index 0000000000..4f906e0c81
--- /dev/null
+++ b/compose/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/compose/gradlew.bat b/compose/gradlew.bat
new file mode 100644
index 0000000000..ac1b06f938
--- /dev/null
+++ b/compose/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/compose/samples/build.gradle.kts b/compose/samples/build.gradle.kts
new file mode 100644
index 0000000000..d5f143cbe0
--- /dev/null
+++ b/compose/samples/build.gradle.kts
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+plugins {
+ id("com.android.application")
+ kotlin("android")
+}
+
+apply(from = rootProject.file(".buildscript/configure-android-defaults.gradle"))
+apply(from = rootProject.file(".buildscript/android-sample-app.gradle"))
+apply(from = rootProject.file(".buildscript/android-ui-tests.gradle"))
+
+android {
+ defaultConfig {
+ applicationId = "com.squareup.sample"
+ }
+}
+
+apply(from = rootProject.file(".buildscript/configure-compose.gradle"))
+
+dependencies {
+ implementation(project(":core-compose"))
+ implementation(Dependencies.AndroidX.appcompat)
+ implementation(Dependencies.Compose.layout)
+ implementation(Dependencies.Compose.material)
+ implementation(Dependencies.Compose.foundation)
+ implementation(Dependencies.Workflow.core)
+ implementation(Dependencies.Workflow.runtime)
+ implementation(Dependencies.Workflow.UI.coreAndroid)
+
+ debugImplementation(project(":compose-tooling"))
+}
diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposebinding/HelloBindingTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposebinding/HelloBindingTest.kt
new file mode 100644
index 0000000000..8359c3cc97
--- /dev/null
+++ b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposebinding/HelloBindingTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposebinding
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HelloBindingTest {
+
+ // Launches the activity.
+ @Rule @JvmField val composeRule = createAndroidComposeRule()
+
+ @Test fun togglesBetweenStates() {
+ composeRule.onNodeWithText("Hello")
+ .assertIsDisplayed()
+ .performClick()
+ composeRule.onNodeWithText("Goodbye")
+ .assertIsDisplayed()
+ .performClick()
+ composeRule.onNodeWithText("Hello")
+ .assertIsDisplayed()
+ }
+}
diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingTest.kt
new file mode 100644
index 0000000000..c3a5b3373a
--- /dev/null
+++ b/compose/samples/src/androidTest/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingTest.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposerendering
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HelloComposeRenderingTest {
+
+ // Launches the activity.
+ @Rule @JvmField val composeRule = createAndroidComposeRule()
+
+ @Test fun togglesBetweenStates() {
+ composeRule.onNodeWithText("Hello")
+ .assertIsDisplayed()
+ .performClick()
+ composeRule.onNodeWithText("Goodbye")
+ .assertIsDisplayed()
+ .performClick()
+ composeRule.onNodeWithText("Hello")
+ .assertIsDisplayed()
+ }
+}
diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/launcher/SampleLauncherTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/launcher/SampleLauncherTest.kt
new file mode 100644
index 0000000000..12f7e450c4
--- /dev/null
+++ b/compose/samples/src/androidTest/java/com/squareup/sample/launcher/SampleLauncherTest.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.launcher
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.espresso.Espresso.pressBack
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.squareup.sample.R
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class SampleLauncherTest {
+
+ @Rule @JvmField val composeRule = createAndroidComposeRule()
+
+ @Test fun allSamplesLaunch() {
+ val appName =
+ InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.app_name)
+ composeRule.onNodeWithText(appName).assertIsDisplayed()
+
+ samples.forEach { sample ->
+ try {
+ composeRule.onNodeWithText(sample.description, useUnmergedTree = true)
+ .performClick()
+ pressBack()
+ } catch (e: Throwable) {
+ throw AssertionError("Failed to launch sample ${sample.name}", e)
+ }
+ }
+ }
+}
diff --git a/compose/samples/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt b/compose/samples/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt
new file mode 100644
index 0000000000..c24dce9b29
--- /dev/null
+++ b/compose/samples/src/androidTest/java/com/squareup/sample/nestedrenderings/NestedRenderingsTest.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.nestedrenderings
+
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.SemanticsNodeInteractionCollection
+import androidx.compose.ui.test.assertCountEquals
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+private const val ADD_BUTTON_TEXT = "Add Child"
+
+@RunWith(AndroidJUnit4::class)
+class NestedRenderingsTest {
+
+ // Launches the activity.
+ @Rule @JvmField val composeRule = createAndroidComposeRule()
+
+ @Test fun childrenAreAddedAndRemoved() {
+ composeRule.onNodeWithText(ADD_BUTTON_TEXT)
+ .assertIsDisplayed()
+ .performClick()
+
+ composeRule.onAllNodesWithText(ADD_BUTTON_TEXT)
+ .assertCountEquals(2)
+ .forEach { it.performClick() }
+
+ composeRule.onAllNodesWithText(ADD_BUTTON_TEXT)
+ .assertCountEquals(4)
+
+ resetAll()
+ composeRule.onAllNodesWithText(ADD_BUTTON_TEXT).assertCountEquals(1)
+ }
+
+ /**
+ * We can't rely on the order of nodes returned by [onAllNodesWithText], and the contents of the
+ * collection will change as we remove nodes, so we have to double-loop over all reset buttons and
+ * click them all until there is only one left.
+ */
+ private fun resetAll() {
+ var foundNodes = Int.MAX_VALUE
+ while (foundNodes > 1) {
+ foundNodes = 0
+ composeRule.onAllNodesWithText("Reset").forEach {
+ try {
+ it.assertExists()
+ } catch (e: AssertionError) {
+ // No more reset buttons, we're done.
+ return@forEach
+ }
+ foundNodes++
+ it.performClick()
+ }
+ }
+ }
+
+ private fun SemanticsNodeInteractionCollection.forEach(
+ block: (SemanticsNodeInteraction) -> Unit
+ ) {
+ val count = fetchSemanticsNodes().size
+ for (i in 0 until count) block(get(i))
+ }
+}
diff --git a/compose/samples/src/main/AndroidManifest.xml b/compose/samples/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..881150216a
--- /dev/null
+++ b/compose/samples/src/main/AndroidManifest.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/App.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/App.kt
new file mode 100644
index 0000000000..8b54d4efd2
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/App.kt
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocompose
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.ui.tooling.preview.Preview
+import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.compose.WorkflowContainer
+
+private val viewRegistry = ViewRegistry(HelloBinding)
+private val viewEnvironment = ViewEnvironment(viewRegistry)
+
+@Composable fun App() {
+ MaterialTheme {
+ WorkflowContainer(
+ HelloWorkflow, viewEnvironment,
+ modifier = Modifier.border(
+ shape = RoundedCornerShape(10.dp),
+ width = 10.dp,
+ color = Color.Magenta
+ ),
+ diagnosticListener = SimpleLoggingDiagnosticListener()
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable private fun AppPreview() {
+ App()
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloBinding.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloBinding.kt
new file mode 100644
index 0000000000..7b7dd6b8d0
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloBinding.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocompose
+
+import androidx.compose.material.Text
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import com.squareup.sample.hellocompose.HelloWorkflow.Rendering
+import com.squareup.workflow.ui.compose.composedViewFactory
+
+val HelloBinding = composedViewFactory { rendering, _ ->
+ Text(
+ rendering.message,
+ modifier = Modifier
+ .clickable(onClick = rendering.onClick)
+ .fillMaxSize()
+ .wrapContentSize(Alignment.Center)
+ )
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloComposeActivity.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloComposeActivity.kt
new file mode 100644
index 0000000000..67614a2da9
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloComposeActivity.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocompose
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.ui.platform.setContent
+
+class HelloComposeActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ App()
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloWorkflow.kt
new file mode 100644
index 0000000000..46dfbfdec3
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocompose/HelloWorkflow.kt
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocompose
+
+import com.squareup.sample.hellocompose.HelloWorkflow.Rendering
+import com.squareup.sample.hellocompose.HelloWorkflow.State
+import com.squareup.sample.hellocompose.HelloWorkflow.State.Goodbye
+import com.squareup.sample.hellocompose.HelloWorkflow.State.Hello
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.action
+import com.squareup.workflow.parse
+
+object HelloWorkflow : StatefulWorkflow() {
+ enum class State {
+ Hello,
+ Goodbye;
+
+ fun theOtherState(): State = when (this) {
+ Hello -> Goodbye
+ Goodbye -> Hello
+ }
+ }
+
+ data class Rendering(
+ val message: String,
+ val onClick: () -> Unit
+ )
+
+ private val helloAction = action {
+ nextState = nextState.theOtherState()
+ }
+
+ override fun initialState(
+ props: Unit,
+ snapshot: Snapshot?
+ ): State = snapshot?.bytes?.parse { source -> if (source.readInt() == 1) Hello else Goodbye }
+ ?: Hello
+
+ override fun render(
+ props: Unit,
+ state: State,
+ context: RenderContext
+ ): Rendering = Rendering(
+ message = state.name,
+ onClick = { context.actionSink.send(helloAction) }
+ )
+
+ override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0)
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt
new file mode 100644
index 0000000000..9119e0b504
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBinding.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2019 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposebinding
+
+import androidx.compose.material.Text
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.ui.tooling.preview.Preview
+import com.squareup.sample.hellocomposebinding.HelloWorkflow.Rendering
+import com.squareup.workflow.ui.compose.composedViewFactory
+import com.squareup.workflow.ui.compose.tooling.preview
+
+val HelloBinding = composedViewFactory { rendering, _ ->
+ Text(
+ rendering.message,
+ modifier = Modifier.fillMaxSize()
+ .clickable(onClick = rendering.onClick)
+ .wrapContentSize(Alignment.Center)
+ )
+}
+
+@Preview(heightDp = 150, showBackground = true)
+@Composable fun DrawHelloRenderingPreview() {
+ HelloBinding.preview(Rendering("Hello!", onClick = {}))
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt
new file mode 100644
index 0000000000..798e7a5eb2
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloBindingActivity.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2019 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposebinding
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material.MaterialTheme
+import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.WorkflowRunner
+import com.squareup.workflow.ui.compose.withCompositionRoot
+import com.squareup.workflow.ui.setContentWorkflow
+
+private val viewRegistry = ViewRegistry(HelloBinding)
+private val containerHints = ViewEnvironment(viewRegistry).withCompositionRoot { content ->
+ MaterialTheme(content = content)
+}
+
+class HelloBindingActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentWorkflow(containerHints) {
+ WorkflowRunner.Config(
+ HelloWorkflow,
+ diagnosticListener = SimpleLoggingDiagnosticListener()
+ )
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloWorkflow.kt
new file mode 100644
index 0000000000..cafa517d24
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposebinding/HelloWorkflow.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2019 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposebinding
+
+import com.squareup.sample.hellocomposebinding.HelloWorkflow.Rendering
+import com.squareup.sample.hellocomposebinding.HelloWorkflow.State
+import com.squareup.sample.hellocomposebinding.HelloWorkflow.State.Goodbye
+import com.squareup.sample.hellocomposebinding.HelloWorkflow.State.Hello
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.action
+import com.squareup.workflow.parse
+
+object HelloWorkflow : StatefulWorkflow() {
+ enum class State {
+ Hello,
+ Goodbye;
+
+ fun theOtherState(): State = when (this) {
+ Hello -> Goodbye
+ Goodbye -> Hello
+ }
+ }
+
+ data class Rendering(
+ val message: String,
+ val onClick: () -> Unit
+ )
+
+ private val helloAction = action {
+ nextState = nextState.theOtherState()
+ }
+
+ override fun initialState(
+ props: Unit,
+ snapshot: Snapshot?
+ ): State = snapshot?.bytes?.parse { source -> if (source.readInt() == 1) Hello else Goodbye }
+ ?: Hello
+
+ override fun render(
+ props: Unit,
+ state: State,
+ context: RenderContext
+ ): Rendering {
+ return Rendering(
+ message = state.name,
+ onClick = { context.actionSink.send(helloAction) }
+ )
+ }
+
+ override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0)
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingActivity.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingActivity.kt
new file mode 100644
index 0000000000..5c6e08319d
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloComposeRenderingActivity.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposerendering
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import com.squareup.workflow.ui.compose.ComposeRendering
+import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.WorkflowRunner
+import com.squareup.workflow.ui.setContentWorkflow
+
+private val viewRegistry = ViewRegistry(ComposeRendering.Factory)
+
+class HelloComposeRenderingActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentWorkflow(viewRegistry) {
+ WorkflowRunner.Config(
+ HelloWorkflow,
+ diagnosticListener = SimpleLoggingDiagnosticListener()
+ )
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt
new file mode 100644
index 0000000000..8d7b794c73
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloRenderingWorkflow.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposerendering
+
+import androidx.compose.material.Text
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.ui.tooling.preview.Preview
+import com.squareup.sample.hellocomposerendering.HelloRenderingWorkflow.Toggle
+import com.squareup.workflow.Sink
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.compose.ComposeWorkflow
+import com.squareup.workflow.ui.compose.tooling.preview
+
+/**
+ * A [ComposeWorkflow] that is used by [HelloWorkflow] to render the screen.
+ *
+ * This workflow has type `Workflow`.
+ */
+object HelloRenderingWorkflow : ComposeWorkflow() {
+
+ object Toggle
+
+ @Composable override fun render(
+ props: String,
+ outputSink: Sink,
+ viewEnvironment: ViewEnvironment
+ ) {
+ MaterialTheme {
+ Text(
+ props,
+ modifier = Modifier
+ .clickable(onClick = { outputSink.send(Toggle) })
+ .fillMaxSize()
+ .wrapContentSize(Alignment.Center)
+ )
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable fun HelloRenderingWorkflowPreview() {
+ HelloRenderingWorkflow.preview(props = "hello")
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloWorkflow.kt
new file mode 100644
index 0000000000..8958af5891
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/hellocomposerendering/HelloWorkflow.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.hellocomposerendering
+
+import com.squareup.sample.hellocomposerendering.HelloWorkflow.State
+import com.squareup.sample.hellocomposerendering.HelloWorkflow.State.Goodbye
+import com.squareup.sample.hellocomposerendering.HelloWorkflow.State.Hello
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.action
+import com.squareup.workflow.parse
+import com.squareup.workflow.ui.compose.ComposeRendering
+
+object HelloWorkflow : StatefulWorkflow() {
+ enum class State {
+ Hello,
+ Goodbye;
+
+ fun theOtherState(): State = when (this) {
+ Hello -> Goodbye
+ Goodbye -> Hello
+ }
+ }
+
+ private val helloAction = action {
+ nextState = nextState.theOtherState()
+ }
+
+ override fun initialState(
+ props: Unit,
+ snapshot: Snapshot?
+ ): State = snapshot?.bytes?.parse { source -> if (source.readInt() == 1) Hello else Goodbye }
+ ?: Hello
+
+ override fun render(
+ props: Unit,
+ state: State,
+ context: RenderContext
+ ): ComposeRendering =
+ context.renderChild(HelloRenderingWorkflow, state.name) { helloAction }
+
+ override fun snapshotState(state: State): Snapshot = Snapshot.of(if (state == Hello) 1 else 0)
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherActivity.kt b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherActivity.kt
new file mode 100644
index 0000000000..5ad90430aa
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherActivity.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.launcher
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.ui.platform.setContent
+
+class SampleLauncherActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ SampleLauncherApp()
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherApp.kt b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherApp.kt
new file mode 100644
index 0000000000..6800aae5c9
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/launcher/SampleLauncherApp.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.launcher
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumnFor
+import androidx.compose.material.ListItem
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Scaffold
+import androidx.compose.material.Surface
+import androidx.compose.material.Text
+import androidx.compose.material.TopAppBar
+import androidx.compose.material.darkColors
+import androidx.compose.material.lightColors
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.drawLayer
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.gesture.rawPressStartGestureFilter
+import androidx.compose.ui.input.pointer.PointerEventPass.Initial
+import androidx.compose.ui.layout.LayoutCoordinates
+import androidx.compose.ui.layout.globalBounds
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.node.Ref
+import androidx.compose.ui.platform.ConfigurationAmbient
+import androidx.compose.ui.platform.ViewAmbient
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.app.ActivityOptionsCompat.makeScaleUpAnimation
+import androidx.core.content.ContextCompat.startActivity
+import androidx.ui.tooling.preview.Preview
+import com.squareup.sample.R.string
+
+@Composable fun SampleLauncherApp() {
+ MaterialTheme(colors = darkColors()) {
+ Scaffold(
+ topBar = {
+ TopAppBar(title = {
+ Text(stringResource(string.app_name))
+ })
+ }
+ ) {
+ LazyColumnFor(samples) { sample ->
+ SampleItem(sample)
+ }
+ }
+ }
+}
+
+@Preview @Composable private fun SampleLauncherAppPreview() {
+ SampleLauncherApp()
+}
+
+@Composable private fun SampleItem(sample: Sample) {
+ val rootView = ViewAmbient.current
+
+ /**
+ * [androidx.compose.ui.layout.LayoutCoordinates.globalBounds] corresponds to the coordinates in
+ * the root Android view hosting the composition.
+ */
+ val globalBounds = remember { Ref() }
+
+ ListItem(
+ text = { Text(sample.name) },
+ secondaryText = { Text(sample.description) },
+ singleLineSecondaryText = false,
+ // Animate the activities as scaling up from where the preview is drawn.
+ icon = { SamplePreview(sample) { globalBounds.value = it.globalBounds } },
+ modifier = Modifier.clickable { launchSample(sample, rootView, globalBounds.value) }
+ )
+}
+
+@Composable private fun SamplePreview(
+ sample: Sample,
+ onPreviewCoordinates: (LayoutCoordinates) -> Unit
+) {
+ val configuration = ConfigurationAmbient.current
+ val screenRatio = configuration.screenWidthDp.toFloat() / configuration.screenHeightDp.toFloat()
+ // 88dp is taken from ListItem implementation. This doesn't seem to be coming in via any
+ // constraints as of dev11.
+ val previewHeight = 88.dp - 16.dp
+ val scale = previewHeight / configuration.screenHeightDp.dp
+
+ // Force the previews to the scaled size, with the aspect ratio of the device.
+ // This is needed because the inner Box measures the previews at maximum size, so we have to clamp
+ // the measurements here otherwise the rest of the UI will think the previews are full-size even
+ // though they're graphically scaled down.
+ Box(
+ modifier = Modifier
+ .height(previewHeight)
+ .aspectRatio(screenRatio)
+ .onGloballyPositioned(onPreviewCoordinates)
+ ) {
+ // Preview the samples with a light theme, since that's what most of them use.
+ MaterialTheme(lightColors()) {
+ Surface {
+ Box(
+ modifier = Modifier
+ // Disable touch input, since this preview isn't meant to be interactive.
+ .rawPressStartGestureFilter(
+ enabled = true, executionPass = Initial, onPressStart = {}
+ )
+ // Measure/layout the child at full screen size, and then just scale the pixels
+ // down. This way all the text and other density-dependent things get scaled
+ // correctly too.
+ .height(configuration.screenHeightDp.dp)
+ .width(configuration.screenWidthDp.dp)
+ .drawLayer(scaleX = scale, scaleY = scale)
+ ) {
+ sample.preview()
+ }
+ }
+ }
+ }
+}
+
+private fun launchSample(
+ sample: Sample,
+ rootView: View,
+ sourceBounds: Rect?
+) {
+ val context = rootView.context
+ val intent = Intent(context, sample.activityClass.java)
+ val options: Bundle? = sourceBounds?.let {
+ makeScaleUpAnimation(
+ rootView,
+ it.left.toInt(),
+ it.top.toInt(),
+ it.width.toInt(),
+ it.height.toInt()
+ ).toBundle()
+ }
+ startActivity(context, intent, options)
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/launcher/Samples.kt b/compose/samples/src/main/java/com/squareup/sample/launcher/Samples.kt
new file mode 100644
index 0000000000..80f6617bc8
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/launcher/Samples.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.sample.launcher
+
+import androidx.activity.ComponentActivity
+import androidx.compose.runtime.Composable
+import com.squareup.sample.hellocompose.App
+import com.squareup.sample.hellocompose.HelloComposeActivity
+import com.squareup.sample.hellocomposebinding.DrawHelloRenderingPreview
+import com.squareup.sample.hellocomposebinding.HelloBindingActivity
+import com.squareup.sample.hellocomposerendering.HelloComposeRenderingActivity
+import com.squareup.sample.hellocomposerendering.HelloRenderingWorkflowPreview
+import com.squareup.sample.nestedrenderings.NestedRenderingsActivity
+import com.squareup.sample.nestedrenderings.RecursiveViewFactoryPreview
+import com.squareup.sample.textinput.TextInputActivity
+import com.squareup.sample.textinput.TextInputAppPreview
+import kotlin.reflect.KClass
+
+val samples = listOf(
+ Sample(
+ "Hello Compose Binding", HelloBindingActivity::class,
+ "Creates a ViewFactory using bindCompose."
+ ) { DrawHelloRenderingPreview() },
+ Sample(
+ "Hello Compose Rendering", HelloComposeRenderingActivity::class,
+ "Uses ComposeWorkflow to create a workflow that draws itself."
+ ) { HelloRenderingWorkflowPreview() },
+ Sample(
+ "Hello Compose", HelloComposeActivity::class,
+ "A pure Compose app that launches its root Workflow from inside Compose."
+ ) { App() },
+ Sample(
+ "Nested Renderings", NestedRenderingsActivity::class,
+ "Demonstrates recursive view factories using both Compose and legacy view factories."
+ ) { RecursiveViewFactoryPreview() },
+ Sample(
+ "Text Input", TextInputActivity::class,
+ "Demonstrates a workflow that drives a TextField."
+ ) { TextInputAppPreview() }
+)
+
+data class Sample(
+ val name: String,
+ val activityClass: KClass,
+ val description: String,
+ val preview: @Composable () -> Unit
+)
diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt
new file mode 100644
index 0000000000..e898a467b5
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/LegacyRunner.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.nestedrenderings
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.ui.tooling.preview.Preview
+import com.squareup.sample.databinding.LegacyViewBinding
+import com.squareup.sample.nestedrenderings.RecursiveWorkflow.LegacyRendering
+import com.squareup.workflow.ui.LayoutRunner
+import com.squareup.workflow.ui.LayoutRunner.Companion.bind
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewFactory
+import com.squareup.workflow.ui.compose.tooling.preview
+
+/**
+ * A [LayoutRunner] that renders [LegacyRendering]s using the legacy view framework.
+ */
+class LegacyRunner(private val binding: LegacyViewBinding) : LayoutRunner {
+
+ override fun showRendering(
+ rendering: LegacyRendering,
+ viewEnvironment: ViewEnvironment
+ ) {
+ binding.stub.update(rendering.rendering, viewEnvironment)
+ }
+
+ companion object : ViewFactory by bind(
+ LegacyViewBinding::inflate, ::LegacyRunner
+ )
+}
+
+@Preview(widthDp = 200, heightDp = 150, showBackground = true)
+@Composable private fun LegacyRunnerPreview() {
+ LegacyRunner.preview(
+ rendering = LegacyRendering("child"),
+ placeholderModifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt
new file mode 100644
index 0000000000..c624c2e500
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/NestedRenderingsActivity.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.nestedrenderings
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.runtime.Providers
+import androidx.compose.ui.graphics.Color
+import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.WorkflowRunner
+import com.squareup.workflow.ui.compose.withCompositionRoot
+import com.squareup.workflow.ui.setContentWorkflow
+
+private val viewRegistry = ViewRegistry(
+ RecursiveViewFactory,
+ LegacyRunner
+)
+
+private val viewEnvironment = ViewEnvironment(viewRegistry).withCompositionRoot { content ->
+ Providers(BackgroundColorAmbient provides Color.Green, children = content)
+}
+
+class NestedRenderingsActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentWorkflow(viewEnvironment) {
+ WorkflowRunner.Config(
+ RecursiveWorkflow,
+ diagnosticListener = SimpleLoggingDiagnosticListener()
+ )
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveViewFactory.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveViewFactory.kt
new file mode 100644
index 0000000000..e6a47c5ccc
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveViewFactory.kt
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry")
+
+package com.squareup.sample.nestedrenderings
+
+import androidx.compose.material.Text
+import androidx.compose.foundation.layout.Arrangement.SpaceEvenly
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayout
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.MainAxisAlignment
+import androidx.compose.foundation.layout.SizeMode.Expand
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.Button
+import androidx.compose.material.Card
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Providers
+import androidx.compose.runtime.ambientOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
+import androidx.compose.ui.res.dimensionResource
+import androidx.ui.tooling.preview.Preview
+import com.squareup.sample.R
+import com.squareup.sample.nestedrenderings.RecursiveWorkflow.Rendering
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.compose.WorkflowRendering
+import com.squareup.workflow.ui.compose.composedViewFactory
+import com.squareup.workflow.ui.compose.tooling.preview
+
+/**
+ * Ambient of [Color] to use as the background color for a [RecursiveViewFactory].
+ */
+val BackgroundColorAmbient = ambientOf { error("No background color specified") }
+
+/**
+ * A `ViewFactory` that renders [RecursiveWorkflow.Rendering]s.
+ */
+val RecursiveViewFactory = composedViewFactory { rendering, viewEnvironment ->
+ // Every child should be drawn with a slightly-darker background color.
+ val color = BackgroundColorAmbient.current
+ val childColor = remember(color) {
+ color.copy(alpha = .9f)
+ .compositeOver(Color.Black)
+ }
+
+ Card(backgroundColor = color) {
+ Column(
+ Modifier.padding(dimensionResource(R.dimen.recursive_padding))
+ .fillMaxSize(),
+ horizontalAlignment = CenterHorizontally
+ ) {
+ Providers(BackgroundColorAmbient provides childColor) {
+ Children(
+ rendering.children, viewEnvironment,
+ // Pass a weight so that the column fills all the space not occupied by the buttons.
+ modifier = Modifier.weight(1f, fill = true)
+ )
+ }
+ Buttons(
+ onAdd = rendering.onAddChildClicked,
+ onReset = rendering.onResetClicked
+ )
+ }
+ }
+}
+
+@Preview
+@Composable fun RecursiveViewFactoryPreview() {
+ Providers(BackgroundColorAmbient provides Color.Green) {
+ RecursiveViewFactory.preview(
+ Rendering(
+ children = listOf(
+ "foo",
+ Rendering(
+ children = listOf("bar"),
+ onAddChildClicked = {}, onResetClicked = {}
+ )
+ ), onAddChildClicked = {}, onResetClicked = {}
+ ),
+ placeholderModifier = Modifier.fillMaxSize()
+ )
+ }
+}
+
+@Composable private fun Children(
+ children: List,
+ viewEnvironment: ViewEnvironment,
+ modifier: Modifier
+) {
+ Column(
+ modifier = modifier,
+ verticalArrangement = SpaceEvenly,
+ horizontalAlignment = CenterHorizontally
+ ) {
+ children.forEach { childRendering ->
+ WorkflowRendering(
+ childRendering,
+ // Pass a weight so all children are partitioned evenly within the total column space.
+ // Without the weight, each child is the full size of the parent.
+ viewEnvironment,
+ modifier = Modifier.weight(1f, true)
+ .padding(dimensionResource(R.dimen.recursive_padding))
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalLayout::class)
+@Composable private fun Buttons(
+ onAdd: () -> Unit,
+ onReset: () -> Unit
+) {
+ // Use a FlowRow so the buttons will wrap when the parent is too narrow.
+ FlowRow(
+ mainAxisSize = Expand,
+ mainAxisAlignment = MainAxisAlignment.SpaceEvenly,
+ crossAxisSpacing = dimensionResource(R.dimen.recursive_padding)
+ ) {
+ Button(onClick = onAdd) {
+ Text("Add Child")
+ }
+ Button(onClick = onReset) {
+ Text("Reset")
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveWorkflow.kt
new file mode 100644
index 0000000000..adfa18b98b
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/nestedrenderings/RecursiveWorkflow.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.nestedrenderings
+
+import com.squareup.sample.nestedrenderings.RecursiveWorkflow.LegacyRendering
+import com.squareup.sample.nestedrenderings.RecursiveWorkflow.Rendering
+import com.squareup.sample.nestedrenderings.RecursiveWorkflow.State
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.action
+import com.squareup.workflow.renderChild
+
+/**
+ * A simple workflow that produces [Rendering]s of zero or more children.
+ * The rendering provides event handlers for adding children and resetting child count to zero.
+ *
+ * Every other (odd) rendering in the [Rendering.children] will be wrapped with a [LegacyRendering]
+ * to force it to go through the legacy view layer. This way this sample both demonstrates pass-
+ * through Composable renderings as well as adapting in both directions.
+ */
+object RecursiveWorkflow : StatefulWorkflow() {
+
+ data class State(val children: Int = 0)
+
+ /**
+ * A rendering from a [RecursiveWorkflow].
+ *
+ * @param children A list of renderings to display as children of this rendering.
+ * @param onAddChildClicked Adds a child to [children].
+ * @param onResetClicked Resets [children] to an empty list.
+ */
+ data class Rendering(
+ val children: List,
+ val onAddChildClicked: () -> Unit,
+ val onResetClicked: () -> Unit
+ )
+
+ /**
+ * Wrapper around a [Rendering] that will be implemented using a legacy view.
+ */
+ data class LegacyRendering(val rendering: Any)
+
+ override fun initialState(
+ props: Unit,
+ snapshot: Snapshot?
+ ): State = State()
+
+ override fun render(
+ props: Unit,
+ state: State,
+ context: RenderContext
+ ): Rendering {
+ return Rendering(
+ children = List(state.children) { i ->
+ val child = context.renderChild(RecursiveWorkflow, key = i.toString())
+ if (i % 2 == 0) child else LegacyRendering(child)
+ },
+ onAddChildClicked = { context.actionSink.send(addChild()) },
+ onResetClicked = { context.actionSink.send(reset()) }
+ )
+ }
+
+ override fun snapshotState(state: State): Snapshot = Snapshot.EMPTY
+
+ private fun addChild() = action {
+ nextState = nextState.copy(children = nextState.children + 1)
+ }
+
+ private fun reset() = action {
+ nextState = State()
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/App.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/App.kt
new file mode 100644
index 0000000000..5a0b21cbcd
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/textinput/App.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.textinput
+
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.ui.tooling.preview.Preview
+import com.squareup.workflow.diagnostic.SimpleLoggingDiagnosticListener
+import com.squareup.workflow.ui.ViewEnvironment
+import com.squareup.workflow.ui.ViewRegistry
+import com.squareup.workflow.ui.compose.WorkflowContainer
+
+private val viewRegistry = ViewRegistry(TextInputViewFactory)
+private val viewEnvironment = ViewEnvironment(viewRegistry)
+
+@Composable fun TextInputApp() {
+ MaterialTheme {
+ WorkflowContainer(
+ TextInputWorkflow, viewEnvironment,
+ diagnosticListener = SimpleLoggingDiagnosticListener()
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable internal fun TextInputAppPreview() {
+ TextInputApp()
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputActivity.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputActivity.kt
new file mode 100644
index 0000000000..34033c1155
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputActivity.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.textinput
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.ui.platform.setContent
+
+class TextInputActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ TextInputApp()
+ }
+ }
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputViewFactory.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputViewFactory.kt
new file mode 100644
index 0000000000..e1c10a1da7
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputViewFactory.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.textinput
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.material.Text
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material.Button
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.ExperimentalFocus
+import androidx.compose.ui.unit.dp
+import androidx.ui.tooling.preview.Preview
+import com.squareup.sample.textinput.TextInputWorkflow.Rendering
+import com.squareup.workflow.ui.compose.composedViewFactory
+import com.squareup.workflow.ui.compose.tooling.preview
+
+@OptIn(ExperimentalFocus::class)
+val TextInputViewFactory = composedViewFactory { rendering, _ ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .wrapContentSize()
+ .animateContentSize(clip = false),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(text = rendering.text)
+ OutlinedTextField(
+ label = {},
+ placeholder = { Text("Enter some text") },
+ value = rendering.text,
+ onValueChange = rendering.onTextChanged
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(onClick = rendering.onSwapText) {
+ Text("Swap")
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable private fun TextInputViewFactoryPreview() {
+ TextInputViewFactory.preview(Rendering(
+ text = "Hello world",
+ onTextChanged = {},
+ onSwapText = {}
+ ))
+}
diff --git a/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputWorkflow.kt b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputWorkflow.kt
new file mode 100644
index 0000000000..0468dfb360
--- /dev/null
+++ b/compose/samples/src/main/java/com/squareup/sample/textinput/TextInputWorkflow.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.squareup.sample.textinput
+
+import com.squareup.sample.textinput.TextInputWorkflow.Rendering
+import com.squareup.sample.textinput.TextInputWorkflow.State
+import com.squareup.workflow.RenderContext
+import com.squareup.workflow.Snapshot
+import com.squareup.workflow.StatefulWorkflow
+import com.squareup.workflow.action
+
+object TextInputWorkflow : StatefulWorkflow() {
+
+ data class State(
+ val textA: String = "",
+ val textB: String = "",
+ val showingTextA: Boolean = true
+ )
+
+ data class Rendering(
+ val text: String,
+ val onTextChanged: (String) -> Unit,
+ val onSwapText: () -> Unit
+ )
+
+ private val swapText = action {
+ nextState = nextState.copy(showingTextA = !nextState.showingTextA)
+ }
+
+ private fun changeText(text: String) = action {
+ nextState = if (nextState.showingTextA) {
+ nextState.copy(textA = text)
+ } else {
+ nextState.copy(textB = text)
+ }
+ }
+
+ override fun initialState(
+ props: Unit,
+ snapshot: Snapshot?
+ ): State = State()
+
+ override fun render(
+ props: Unit,
+ state: State,
+ context: RenderContext
+ ): Rendering = Rendering(
+ text = if (state.showingTextA) state.textA else state.textB,
+ onTextChanged = { context.actionSink.send(changeText(it)) },
+ onSwapText = { context.actionSink.send(swapText) }
+ )
+
+ override fun snapshotState(state: State): Snapshot = Snapshot.EMPTY
+}
diff --git a/compose/samples/src/main/res/layout/legacy_view.xml b/compose/samples/src/main/res/layout/legacy_view.xml
new file mode 100644
index 0000000000..e7afd62130
--- /dev/null
+++ b/compose/samples/src/main/res/layout/legacy_view.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/compose/samples/src/main/res/values/dimens.xml b/compose/samples/src/main/res/values/dimens.xml
new file mode 100644
index 0000000000..a5357738c4
--- /dev/null
+++ b/compose/samples/src/main/res/values/dimens.xml
@@ -0,0 +1,18 @@
+
+
+ 16dp
+
diff --git a/compose/samples/src/main/res/values/strings.xml b/compose/samples/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..187570afd0
--- /dev/null
+++ b/compose/samples/src/main/res/values/strings.xml
@@ -0,0 +1,18 @@
+
+
+ Workflow Compose Samples
+
diff --git a/compose/samples/src/main/res/values/styles.xml b/compose/samples/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..1c37bd6d5b
--- /dev/null
+++ b/compose/samples/src/main/res/values/styles.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/compose/settings.gradle.kts b/compose/settings.gradle.kts
new file mode 100644
index 0000000000..829e1a9302
--- /dev/null
+++ b/compose/settings.gradle.kts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2017 Square Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+rootProject.name = "workflow-compose"
+
+include(
+ ":compose-tooling",
+ ":core-compose",
+ ":samples"
+)
diff --git a/lint_docs.sh b/lint_docs.sh
index 5ce9d469cc..c4025e26e3 100755
--- a/lint_docs.sh
+++ b/lint_docs.sh
@@ -17,6 +17,7 @@ find . \
-not -name 'CHANGELOG.md' \
-not -path './.github/*' \
-not -path $TUTORIALS_DIR/'*' \
+ -not -path './compose/*' \
| xargs mdl --style $STYLE --ignore-front-matter \
find $TUTORIALS_DIR \