Skip to content

Create workflow-ui/core module. #1185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions artifacts.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,60 @@
"javaVersion": 8,
"publicationName": "maven"
},
{
"gradlePath": ":workflow-ui:core",
"group": "com.squareup.workflow1",
"artifactId": "workflow-ui-core-iosarm64",
"description": "Workflow UI Core",
"packaging": "klib",
"javaVersion": 8,
"publicationName": "iosArm64"
},
{
"gradlePath": ":workflow-ui:core",
"group": "com.squareup.workflow1",
"artifactId": "workflow-ui-core-iossimulatorarm64",
"description": "Workflow UI Core",
"packaging": "klib",
"javaVersion": 8,
"publicationName": "iosSimulatorArm64"
},
{
"gradlePath": ":workflow-ui:core",
"group": "com.squareup.workflow1",
"artifactId": "workflow-ui-core-iosx64",
"description": "Workflow UI Core",
"packaging": "klib",
"javaVersion": 8,
"publicationName": "iosX64"
},
{
"gradlePath": ":workflow-ui:core",
"group": "com.squareup.workflow1",
"artifactId": "workflow-ui-core-js",
"description": "Workflow UI Core",
"packaging": "klib",
"javaVersion": 8,
"publicationName": "js"
},
{
"gradlePath": ":workflow-ui:core",
"group": "com.squareup.workflow1",
"artifactId": "workflow-ui-core-jvm",
"description": "Workflow UI Core",
"packaging": "jar",
"javaVersion": 8,
"publicationName": "jvm"
},
{
"gradlePath": ":workflow-ui:core",
"group": "com.squareup.workflow1",
"artifactId": "workflow-ui-core",
"description": "Workflow UI Core",
"packaging": "jar",
"javaVersion": 8,
"publicationName": "kotlinMultiplatform"
},
{
"gradlePath": ":workflow-ui:core-android",
"group": "com.squareup.workflow1",
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ include(
":workflow-tracing",
":workflow-ui:compose",
":workflow-ui:compose-tooling",
":workflow-ui:core",
":workflow-ui:core-common",
":workflow-ui:core-android",
":workflow-ui:internal-testing-android",
Expand Down
277 changes: 277 additions & 0 deletions workflow-ui/core/api/core.api

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions workflow-ui/core/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import com.squareup.workflow1.buildsrc.iosWithSimulatorArm64

plugins {
id("kotlin-multiplatform")
id("published")
}

kotlin {
val targets = project.findProperty("workflow.targets") ?: "kmp"
if (targets == "kmp" || targets == "ios") {
iosWithSimulatorArm64(project)
}
if (targets == "kmp" || targets == "jvm") {
jvm { withJava() }
}
if (targets == "kmp" || targets == "js") {
js(IR) { browser() }
}
}

dependencies {
commonMainApi(libs.kotlin.jdk6)
commonMainApi(libs.kotlinx.coroutines.core)

commonTestImplementation(libs.kotlinx.coroutines.test.common)
commonTestImplementation(libs.kotlin.test.jdk)
}
9 changes: 9 additions & 0 deletions workflow-ui/core/dependencies/jsRuntimeClasspath.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
org.jetbrains.kotlin:kotlin-bom:1.9.10
org.jetbrains.kotlin:kotlin-dom-api-compat:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-js:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.10
org.jetbrains.kotlin:kotlinx-atomicfu-runtime:1.8.20
org.jetbrains.kotlinx:atomicfu-js:0.21.0
org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains:annotations:13.0
8 changes: 8 additions & 0 deletions workflow-ui/core/dependencies/jvmRuntimeClasspath.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
org.jetbrains.kotlin:kotlin-bom:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.10
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains:annotations:23.0.0
9 changes: 9 additions & 0 deletions workflow-ui/core/dependencies/runtimeClasspath.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
org.jetbrains.kotlin:kotlin-bom:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-common:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.10
org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.10
org.jetbrains.kotlin:kotlin-stdlib:1.9.10
org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3
org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3
org.jetbrains:annotations:23.0.0
3 changes: 3 additions & 0 deletions workflow-ui/core/gradle.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POM_ARTIFACT_ID=workflow-ui-core
POM_NAME=Workflow UI Core
POM_PACKAGING=jar
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.squareup.workflow1.ui

/**
* Normally returns true if [me] and [you] are instances of the same class.
* If that common class implements [Compatible], both instances must also
* have the same [Compatible.compatibilityKey].
*
* A convenient way to take control over the matching behavior of objects that
* don't implement [Compatible] is to wrap them with [NamedScreen].
*/
@WorkflowUiExperimentalApi
public fun compatible(
me: Any,
you: Any
): Boolean {
return when {
me::class != you::class -> false
me !is Compatible -> true
else -> me.compatibilityKey == (you as Compatible).compatibilityKey
}
}

/**
* Implemented by objects whose [compatibility][compatible] requires more nuance
* than just being of the same type.
*
* Renderings that don't implement this interface directly can be distinguished
* by wrapping them with [NamedScreen].
*/
@WorkflowUiExperimentalApi
public interface Compatible {
/**
* Instances of the same type are [compatible] iff they have the same [compatibilityKey].
*/
public val compatibilityKey: String

public companion object {
/**
* Calculates a suitable [Compatible.compatibilityKey] for a given [value], incorporating
* [name] if that is not blank. Includes the [compatibilityKey] for [value] if it
* implements [Compatible], to support recursion from wrapping.
*
* Style note: [name] is given more prominence than the key generate
*/
public fun keyFor(
value: Any,
name: String = ""
): String {
var key = (value as? Compatible)?.compatibilityKey
if (key == null) {
key = value::class.toString()
}

return name.takeIf { it.isNotEmpty() }?.let { "$name($key)" } ?: key
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.squareup.workflow1.ui

import com.squareup.workflow1.ui.ViewRegistry.Entry
import com.squareup.workflow1.ui.ViewRegistry.Key

/**
* A [ViewRegistry] that contains only other registries and delegates to their [getEntryFor]
* methods.
*
* Whenever any registries are combined using the [ViewRegistry] factory functions or `plus`
* operators, an instance of this class is returned. All registries' keys are checked at
* construction to ensure that no duplicate keys exist.
*
* The implementation of [getEntryFor] consists of a single layer of indirection – the responsible
* [ViewRegistry] is looked up in a map by key, and then that registry's [getEntryFor] is called.
*
* When multiple [CompositeViewRegistry]s are combined, they are flattened, so that there is never
* more than one layer of indirection. In other words, a [CompositeViewRegistry] will never contain
* a reference to another [CompositeViewRegistry].
*/
@WorkflowUiExperimentalApi
internal class CompositeViewRegistry private constructor(
private val registriesByKey: Map<Key<*, *>, ViewRegistry>
) : ViewRegistry {

constructor (vararg registries: ViewRegistry) : this(mergeRegistries(*registries))

override val keys: Set<Key<*, *>> get() = registriesByKey.keys

override fun <RenderingT : Any, FactoryT : Any> getEntryFor(
key: Key<RenderingT, FactoryT>
): Entry<RenderingT>? = registriesByKey[key]?.getEntryFor(key)

override fun toString(): String {
return "CompositeViewRegistry(${registriesByKey.values.toSet().map { it.toString() }})"
}

companion object {
private fun mergeRegistries(vararg registries: ViewRegistry): Map<Key<*, *>, ViewRegistry> {
val registriesByKey = mutableMapOf<Key<*, *>, ViewRegistry>()

fun putAllUnique(other: Map<Key<*, *>, ViewRegistry>) {
val duplicateKeys = registriesByKey.keys.intersect(other.keys)
require(duplicateKeys.isEmpty()) {
"Must not have duplicate entries: $duplicateKeys. Use merge to replace existing entries."
}
registriesByKey.putAll(other)
}

registries.forEach { registry ->
if (registry is CompositeViewRegistry) {
// Try to keep the composite registry as flat as possible.
putAllUnique(registry.registriesByKey)
} else {
putAllUnique(registry.keys.associateWith { registry })
}
}
return registriesByKey.toMap()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.squareup.workflow1.ui

import com.squareup.workflow1.ui.Compatible.Companion.keyFor

/**
* A rendering type comprised of a set of other renderings.
*
* Why two parameter types? The separate [BaseT] type allows implementations
* and sub-interfaces to constrain the types that [map] is allowed to
* transform [C] to. E.g., it allows `FooWrapper<S: Screen>` to declare
* that [map] is only able to transform `S` to other types of `Screen`.
*
* @param BaseT the invariant base type of the contents of such a container,
* usually [Screen] or [Overlay][com.squareup.workflow1.ui.navigation.Overlay].
* It is common for the [Container] itself to implement [BaseT], but that is
* not a requirement. E.g., [ScreenOverlay][com.squareup.workflow1.ui.navigation.ScreenOverlay]
* is an [Overlay][com.squareup.workflow1.ui.navigation.Overlay], but it
* wraps a [Screen].
*
* @param C the specific subtype of [BaseT] collected by this [Container].
*/
@WorkflowUiExperimentalApi
public interface Container<BaseT, out C : BaseT> {
public fun asSequence(): Sequence<C>

/**
* Returns a [Container] with the [transform]ed contents of the receiver.
* It is expected that an implementation will take advantage of covariance
* to declare its own type as the return type, rather than plain old [Container].
* This requirement is not enforced because recursive generics are a fussy nuisance.
*
* For example, suppose we want to create `LoggingScreen`, one that wraps any
* other screen to add some logging calls. Its implementation of this method
* would be expected to have a return type of `LoggingScreen` rather than `Container`:
*
* override fun <D : Screen> map(transform: (C) -> D): LoggingScreen<D> =
* LoggingScreen(transform(content))
*
* By requiring all [Container] types to implement [map], we ensure that their
* contents can be repackaged in interesting ways, e.g.:
*
* val childBackStackScreen = renderChild(childWorkflow) { ... }
* val loggingBackStackScreen = childBackStackScreen.map { LoggingScreen(it) }
*/
public fun <D : BaseT> map(transform: (C) -> D): Container<BaseT, D>
}

/**
* A [Container] rendering that wraps exactly one other rendering, its [content]. These are
* typically used to "add value" to the [content], e.g. an
* [EnvironmentScreen][com.squareup.workflow1.ui.EnvironmentScreen] that allows
* changes to be made to the [ViewEnvironment].
*
* Usually a [Wrapper] is [Compatible] only with others of the same type with
* [Compatible] [content]. In aid of that, this interface extends [Compatible] and
* provides a convenient default implementation of [compatibilityKey].
*/
@WorkflowUiExperimentalApi
public interface Wrapper<BaseT : Any, out C : BaseT> : Container<BaseT, C>, Compatible {
public val content: C

/**
* Default implementation makes this [Wrapper] compatible with others of the same type,
* and which wrap compatible [content].
*/
public override val compatibilityKey: String
get() = keyFor(content, this::class.simpleName ?: "Wrapper")

public override fun asSequence(): Sequence<C> = sequenceOf(content)

public override fun <D : BaseT> map(
transform: (C) -> D
): Wrapper<BaseT, D>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.squareup.workflow1.ui

/**
* Pairs a [content] rendering with a [environment] to support its display.
* Typically the rendering type (`RenderingT`) of the root of a UI workflow,
* but can be used at any point to modify the [ViewEnvironment] received from
* a parent view.
*
* UI kits are expected to provide handling for this class by default.
*/
@WorkflowUiExperimentalApi
public class EnvironmentScreen<out C : Screen>(
public override val content: C,
public val environment: ViewEnvironment = ViewEnvironment.EMPTY
) : Wrapper<Screen, C>, Screen {
override fun <D : Screen> map(transform: (C) -> D): EnvironmentScreen<D> =
EnvironmentScreen(transform(content), environment)
}

/**
* Returns an [EnvironmentScreen] derived from the receiver, whose
* [EnvironmentScreen.environment] includes [viewRegistry].
*
* If the receiver is an [EnvironmentScreen], uses
* [ViewRegistry.merge][com.squareup.workflow1.ui.merge] to preserve the [ViewRegistry]
* entries of both.
*/
@WorkflowUiExperimentalApi
public fun Screen.withRegistry(viewRegistry: ViewRegistry): EnvironmentScreen<*> {
return withEnvironment(ViewEnvironment.EMPTY + viewRegistry)
}

/**
* Returns an [EnvironmentScreen] derived from the receiver,
* whose [EnvironmentScreen.environment] includes the values in the given [environment].
*
* If the receiver is an [EnvironmentScreen], uses
* [ViewRegistry.merge][com.squareup.workflow1.ui.merge] to preserve the [ViewRegistry]
* entries of both.
*/
@WorkflowUiExperimentalApi
public fun Screen.withEnvironment(
environment: ViewEnvironment = ViewEnvironment.EMPTY
): EnvironmentScreen<*> {
return when (this) {
is EnvironmentScreen<*> -> {
if (environment.map.isEmpty()) {
this
} else {
EnvironmentScreen(content, this.environment + environment)
}
}
else -> EnvironmentScreen(this, environment)
}
}

/**
* Returns an [EnvironmentScreen] derived from the receiver,
* whose [EnvironmentScreen.environment] includes the given entry.
*/
@WorkflowUiExperimentalApi
public fun <T : Any> Screen.withEnvironment(
entry: Pair<ViewEnvironmentKey<T>, T>
): EnvironmentScreen<*> = withEnvironment(ViewEnvironment.EMPTY + entry)
Loading