diff --git a/README-templates.md b/README-templates.md new file mode 100644 index 0000000000..79f0de2210 --- /dev/null +++ b/README-templates.md @@ -0,0 +1,8 @@ +# File Templates + +`workflow-file-templates.zip` can be imported into Android Studio / IntelliJ IDEA to add a few Workflow-specific file templates, via _File > Manage IDE Settings > Import Settings…_. + +To update the templates: + +* edit them in the IDE (_Settings > Editor > File and Code Templates_) +* export them (_File > Manage IDE Settings > Import Settings…_), taking care to clear every checkbox except that for File Templates. diff --git a/fileTemplates/Stateful Workflow.kt b/fileTemplates/Stateful Workflow.kt deleted file mode 100644 index 6f7d54c9f2..0000000000 --- a/fileTemplates/Stateful Workflow.kt +++ /dev/null @@ -1,87 +0,0 @@ -#set ($prefix = $NAME.replace('Workflow', '') ) -## build props string -#if( $PROPS_TYPE_OPTIONAL == '') - #set ($props_type = $prefix + "Props") -#else - #set ($props_type = $PROPS_TYPE_OPTIONAL) -#end -## build state string -#if( $STATE_TYPE_OPTIONAL == '') - #set ($state_type = $prefix + "State") -#else - #set ($state_type = $STATE_TYPE_OPTIONAL) -#end -## build output string -#if( $OUTPUT_TYPE_OPTIONAL == '') - #set ($output_type = $prefix + "Output") -#else - #set ($output_type = $OUTPUT_TYPE_OPTIONAL) -#end -## build rendering string -#if( $RENDERING_TYPE_OPTIONAL == '') - #set ($rendering_type = $prefix + "Rendering") -#else - #set ($rendering_type = $RENDERING_TYPE_OPTIONAL) -#end -package $PACKAGE_NAME - -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow - -#if( $PROPS_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$props_type -#end -#if( $STATE_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$state_type -#end -#if( $OUTPUT_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$output_type -#end -#if( $RENDERING_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$rendering_type -#end - -#parse("File Header.java") -object $NAME : StatefulWorkflow<$props_type, $state_type, $output_type, $rendering_type>() { - - #if( $PROPS_TYPE_OPTIONAL == '') ## create if not supplied - data class $props_type( - // TODO add args - ) - #end - - #if( $STATE_TYPE_OPTIONAL == '') ## create if not supplied - data class $state_type( - // TODO add args - ) - #end - - #if( $OUTPUT_TYPE_OPTIONAL == '') ## create if not supplied - data class $output_type( - // TODO add args - ) - #end - - #if( $RENDERING_TYPE_OPTIONAL == '') ## create if not supplied - data class $rendering_type( - // TODO add args - ) - #end - - override fun initialState( - props: $props_type, - snapshot: Snapshot? - ): $state_type = TODO("Initialize state") - - override fun render( - renderProps: $props_type, - renderState: $state_type, - context: RenderContext - ): $rendering_type { - TODO("Render") - } - - override fun snapshotState(state: $state_type): Snapshot? = Snapshot.write { - TODO("Save state") - } -} diff --git a/fileTemplates/Stateless Workflow.kt b/fileTemplates/Stateless Workflow.kt deleted file mode 100644 index e103179e0e..0000000000 --- a/fileTemplates/Stateless Workflow.kt +++ /dev/null @@ -1,61 +0,0 @@ -#set ($prefix = $NAME.replace('Workflow', '') ) -## build props string -#if( $PROPS_TYPE_OPTIONAL == '') - #set ($props_type = $prefix + "Props") -#else - #set ($props_type = $PROPS_TYPE_OPTIONAL) -#end -## build output string -#if( $OUTPUT_TYPE_OPTIONAL == '') - #set ($output_type = $prefix + "Output") -#else - #set ($output_type = $OUTPUT_TYPE_OPTIONAL) -#end -## build rendering string -#if( $RENDERING_TYPE_OPTIONAL == '') - #set ($rendering_type = $prefix + "Rendering") -#else - #set ($rendering_type = $RENDERING_TYPE_OPTIONAL) -#end -package $PACKAGE_NAME - -import com.squareup.workflow1.StatelessWorkflow - -#if( $PROPS_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$props_type -#end -#if( $OUTPUT_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$output_type -#end -#if( $RENDERING_TYPE_OPTIONAL == '') ## import if we create below -import $PACKAGE_NAME.$NAME.$rendering_type -#end - -#parse("File Header.java") -object $NAME : StatelessWorkflow<$props_type, $output_type, $rendering_type>() { - - #if( $PROPS_TYPE_OPTIONAL == '') ## create if not supplied - data class $props_type( - // TODO add args - ) - #end - - #if( $OUTPUT_TYPE_OPTIONAL == '') ## create if not supplied - data class $output_type( - // TODO add args - ) - #end - - #if( $RENDERING_TYPE_OPTIONAL == '') ## create if not supplied - data class $rendering_type( - // TODO add args - ) - #end - - override fun render( - renderProps: $props_type, - context: RenderContext - ): $rendering_type { - TODO("Render") - } -} diff --git a/install-templates.sh b/install-templates.sh deleted file mode 100755 index 3c012c9aa6..0000000000 --- a/install-templates.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# Installs Workflow file templates for IntelliJ and Android Studio. - - -OS="$(uname -s)" -echo "Installing Workflow file templates on $OS system..." -ideaConfigPath="" -if [[ "$OS" == Linux ]]; then - ideaConfigPath="$HOME/.config" -elif [[ "$OS" == Darwin ]]; then - ideaConfigPath="$HOME/Library/Application Support" -fi -TEMPLATES="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/fileTemplates" -for i in "$ideaConfigPath"/Google/AndroidStudio* \ - "$ideaConfigPath"/JetBrains/IdeaIC* \ - "$ideaConfigPath"/JetBrains/IntelliJIdea* -do - echo $i - if [[ -d "$i" ]]; then - mkdir -p "$i/fileTemplates" - cp -frv "$TEMPLATES"/* "$i/fileTemplates" - fi -done - -echo "Done." -echo "" -echo "Restart IntelliJ and/or AndroidStudio." diff --git a/samples/tutorial/README.md b/samples/tutorial/README.md index 72305195ab..2bf2f3078c 100644 --- a/samples/tutorial/README.md +++ b/samples/tutorial/README.md @@ -1,29 +1,9 @@ # Tutorial -## Stale Docs Warning - -**This tutorial is tied to an older version of Workflow, and relies on API that has been deprecated or deleted.** -The general concepts are the same, and refactoring to the current API is straightforward, -so it is still worthwhile to work through the tutorial in its current state until we find time to update it. -(Track that work [here](https://github.com/square/workflow-kotlin/issues/905) -and [here](https://github.com/square/workflow-kotlin/issues/884).) - -Here's a summary of what has changed, and what replaces what: - -- Use of `ViewRegistry` is now optional, and rare. - Have your renderings implement `AndroidScreen` or `ComposeScreen` to avoid it. -- The API for binding a rendering to UI code has changed as follows, and can all - be avoided if you use `ComposeScreen`: - - `ViewFactory` is replaced by `ScreenViewFactory`. - -`LayoutRunner` is replaced by `ScreenViewRunner`. - - `LayoutRunner.bind` is replaced by `ScreenViewFactory.fromViewBinding`. -- `BackStackScreen` has been moved to package `com.squareup.workflow1.ui.navigation`. -- `EditText.updateText` and `EditText.setTextChangedListener` are replaced by `TextController` - ## Overview Oh hi! Looks like you want build some software with Workflows! It's a bit different from traditional -Android development, so let's go through building a simple little TODO app to get the basics down. +Android development, so let's go through building a simple little To-Do app to get the basics down. ## Layout @@ -33,7 +13,7 @@ To help with the setup, we have created a few helper modules: - `tutorial-views`: A set of 3 views for the 3 screens we will be building, `Welcome`, `TodoList`, and `TodoEdit`. -- `tutorial-base`: This is the starting point to build out the tutorial. It contains layouts that host the views from `TutorialViews` to see how they display. +- `tutorial-base`: This is the starting point to build out the tutorial. - `tutorial-final`: This is an example of the completed tutorial - could be used as a reference if you get stuck. diff --git a/samples/tutorial/Tutorial1.md b/samples/tutorial/Tutorial1.md index 8cc48e0d18..481beba472 100644 --- a/samples/tutorial/Tutorial1.md +++ b/samples/tutorial/Tutorial1.md @@ -2,54 +2,31 @@ _Let's get something on the screen..._ -## Stale Docs Warning - -**This tutorial is tied to an older version of Workflow, and relies on API that has been deprecated or deleted.** -The general concepts are the same, and refactoring to the current API is straightforward, -so it is still worthwhile to work through the tutorial in its current state until we find time to update it. -(Track that work [here](https://github.com/square/workflow-kotlin/issues/905) -and [here](https://github.com/square/workflow-kotlin/issues/884).) - -Here's a summary of what has changed, and what replaces what: - -- Use of `ViewRegistry` is now optional, and rare. - Have your renderings implement `AndroidScreen` or `ComposeScreen` to avoid it. -- The API for binding a rendering to UI code has changed as follows, and can all - be avoided if you use `ComposeScreen`: - - `ViewFactory` is replaced by `ScreenViewFactory`. - -`LayoutRunner` is replaced by `ScreenViewRunner`. - - `LayoutRunner.bind` is replaced by `ScreenViewFactory.fromViewBinding`. -- `BackStackScreen` has been moved to package `com.squareup.workflow1.ui.navigation`. -- `EditText.updateText` and `EditText.setTextChangedListener` are replaced by `TextController` - ## Setup To follow this tutorial, launch Android Studio and open this folder (`samples/tutorial`). The `tutorial-base` module will be our starting place to build from. -The welcome screen should look like: +Go ahead and launch `TutorialActivity`. You should see this welcome screen: -![Welcome](images/welcome.png) +![Welcome](im "An Android phone app with title text _Welcome!_, an EditText with prompt _Please enter your name_, and a Log In button") You can enter a name, but the login button won't do anything. ## First Workflow -Let's start by making a workflow and screen to back the welcome view. - -Start by creating a new workflow and screen by creating a new file with the [file templates](../../install-templates.sh), adding it to the `tutorial-base` module: - -___Note___ +_Before anything else, make sure you have installed the [file templates](../../README-templates.md)._ -_Windows OS users: Please add the scripts from [file templates folder](../../fileTemplates) directly to Android Studio or Intellij Idea._ +We'll start by making pair of `Workflow` and `Screen` classes to back the provided `welcome_view.xml` layout. -_Go to Settings --> Editor --> File and Code Templates --> Select Files Tab. Now click on the '+' icon and copy the the content of the Workflow file template to the editor and save it._ +First let's make `WelcomeWorkflow` from the _Stateful Workflow_ template, adding it to the `tutorial-base` module: -![New Workflow](images/new-workflow.png) -![Workflow Name](images/workflow-name.png) +![New Workflow menu item](images/new-workflow.png "Right click tutorial-base/kotlin+java/workflow.tutorial > New > Stateful Workflow") +![New Workflow window](images/workflow-name.png "File name: Welcome Workflow; Props type: Unit; State type: State; Output type: Output: Rendering type: WelcomeScreen") -The template does not create everything needed. Manually add objects for `State` and `Output`. We'll define `WelcomeScreen` in a moment: +The template does not create everything needed. +Manually add placeholder `object`s for `State` and `Output`. ```kotlin object WelcomeWorkflow : StatefulWorkflow() { @@ -63,10 +40,10 @@ object WelcomeWorkflow : StatefulWorkflow() ): State = TODO("Initialize state") override fun render( - props: Unit, - state: State, + renderProps: Unit, + renderState: State, context: RenderContext - ): WelcomeScreen { + ): Screen { TODO("Render") } @@ -76,86 +53,128 @@ object WelcomeWorkflow : StatefulWorkflow() } ``` -Use the `Layout Runner (ViewBinding)` template to create a second file. +Use the _Android Screen (view binding)_ template to create a second file. -![Layout Runner Name](images/layout-runner-name.png) +![New Screen window](images/screen-name.png "File name: WelcomeScreen; Name: WelcomeScreen; View Binding: WelcomeViewBinding") + +You will probably need to add an import for `WelcomeViewBinding` yourself, +and that Android view binding class might not exist until the first time you build. ```kotlin -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val binding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - TODO("Update ViewBinding from rendering") - } +import workflow.tutorial.views.databinding.WelcomeViewBinding - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) +data class WelcomeScreen( + // TODO: add properties needed to update WelcomeViewBinding +) : AndroidScreen { + + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +} + +private fun welcomeScreenRunner( + private val viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, environment: ViewEnvironment -> + TODO("Update viewBinding from screen") } ``` -### Screens, View Factories, and Layout Runners +### `Screen`, `ScreenViewFactory`, and `ScreenViewRunner` Let's start with what a "screen" is, and how it relates to views. -"Screen" is just the term we use to refer to a value type that represents the view model for a logical screen. Sometimes we'll even use the terms "screen" and "view model" interchangeably. It has no special type. Typically, a screen will be used as the rendering type for the workflow that is responsible for that screen. A screen is usually a data class, since that's the easiest way to make value type-like classes in Kotlin. +"Screen" is just the term we use to refer to a value type that represents the view model for a logical screen. +Screen types are identified by implementing the marker interface `Screen`. +Typically, a `Screen` class will be used as the rendering type for the `Workflow` that manages (presents) such screens. + +At Square we tend to use the terms "screen", "rendering" and "view model" interchangeably. +And note that we said "view model," not "Jetpack ViewModel." +A workflow rendering is a view model in the classic sense: +a struct-like bag of values and event handlers that provide just the information needed to create a view, +with no coupling to the platform specific concerns of any particular UI system. + +A `Screen` is usually a `data class`, since that's the easiest way to make value type-like classes in Kotlin. + +Workflow provides two Android-specific interfaces that extend `Screen`: +`AndroidScreen` for classic views, +and `ComposeScreen` for `@Composable` functions. +This is an old tutorial written by old people, so we'll stay with `AndroidScreen` here. + +Let's add a couple of fields to the `WelcomeScreen` that was created from the template, +to give it all the information it needs to serve as a view model: -For our welcome screen, we'll create a new class and define what it needs for a backing view model: ```kotlin +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.ViewEnvironment + +/** + * @param promptText message to show the user + * @param onLogInTapped Log In button event handler + */ data class WelcomeScreen( - /** The current name that has been entered. */ - val username: String, - /** Callback when the name changes in the UI. */ - val onUsernameChanged: (String) -> Unit, - /** Callback when the login button is tapped. */ - val onLoginTapped: () -> Unit -) + val promptText: String, + val onLogInTapped: (String) -> Unit +) : AndroidScreen { ``` -Then we need to create a `ViewFactory` that knows how to create an Android `View` to draw the actual screen. The easiest way to create a `ViewFactory` is to create a layout runner. A layout runner is a class that has a reference to the view and knows how to update the view given an instance of a screen. In a typical app, every screen will have a layout runner. Layout runners can also work with AndroidX `ViewBinding`s, which we'll use to define the `WelcomeLayoutRunner`. We have a pre-built `WelcomeViewBinding` that you can use. This binding will be autogenerated from layout files in `tutorials-views` when you first build the app. If Android Studio does not automatically find the file, you can manually import it `import workflow.tutorial.views.databinding.WelcomeViewBinding`. However if you would like to create and lay out the view yourself instead, feel free to do so! +Now we need to write the code that creates and updates a view based on this simple `WelcomeScreen` model. + +Workflow's support for classic Android `View`s requires a `ScreenViewFactory` to be registered +for each type of `Screen` it may be asked to display. +A `ScreenViewFactory` receives a `Screen` of a particular type and creates a `View` to display it, +an a `fun interface ScreenViewRunner` to update that view. + +Because it implements `AndroidScreen` our `WelcomeScreen` concisely satisfies this requirement (at compile time!) with this line that the template generated for us: ```kotlin -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val welcomeBinding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - // updateText and setTextChangedListener are helpers provided by the workflow library that take - // care of the complexity of correctly interacting with EditTexts in a declarative manner. - welcomeBinding.username.updateText(rendering.username) - welcomeBinding.username.setTextChangedListener { - rendering.onUsernameChanged(it.toString()) - } - welcomeBinding.login.setOnClickListener { rendering.onLoginTapped() } - } + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +``` - /** - * Define a [ViewFactory] that will inflate an instance of [WelcomeViewBinding] and an instance - * of [WelcomeLayoutRunner] when asked, then wire them up so that [showRendering] will be called - * whenever the workflow emits a new [WelcomeScreen]. - */ - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) +This line is declaring: to display a `WelcomeScreen` the workflow UI runtime should use the `viewFactory` provided here to inflate a `WelcomeViewBinding` +and update it as needed using a runner created by `welcomeScreenRunner()`. + +Let's replace the `TODO("Update viewBinding from rendering")` bit from the template with some real code: + +```kotlin +private fun welcomeScreenRunner( + viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, _ -> + viewBinding.prompt.text = screen.promptText + viewBinding.login.setOnClickListener { + screen.onLogInTapped(viewBinding.username.text.toString()) + } } ``` -The view is provided to the layout runner's constructor. `showRendering` is called immediately as part of the layout runner's initialization (see `ViewBindingViewFactory.buildView`), and anytime the backing screen is updated. Note that the `ViewFactory` is actually the layout runner's companion object – this is a convention that makes it easy to associate layout runners with their view factories, and refer to the factories later when we define the view registry. +To be clear: + +- `welcomeScreenRunner` is called once, when `WelcomeViewBinding` is inflated +- the `ScreenViewRunner` that is created lives as long as the view does +- that runner's update lambda (`(WelcomeScreen, ViewEnvironment) -> Unit`) is invoked + immediately, and again every time the UI needs to be refreshed + +> [!TIP] +> There is no requirement that you use an XML layout. +> Besides `ScreenViewFactory.fromViewBinding` we also provide `ScreenViewFactory.fromCode` +> (to simplify building a `View` completely by hand) and a few other varieties. +> +> Or feel free to give `ComposeScreen` a try instead of `AndroidScreen`. +> There is a file template for that too. +> Add `implementation deps.workflow.compose` to your `build.gradle` `dependencies` and see how it goes. -Any time the screen is updated, the `WelcomeLayoutRunner` will now update the `name`, and `login` fields on the `WelcomeViewBinding`. We can't quite run yet, as we still need to fill in the basics of our workflow. +We're not quite ready to run anything yet, as we still need to fill in the basics of our workflow. ### Workflows and Rendering Type -The core responsibility of a workflow is to provide a complete "rendering" every time the related state updates. Let's go into the `WelcomeWorkflow` now, and have it return a `WelcomeScreen` in the `render` method. +The core responsibility of a workflow is to provide a complete rendering / view model +every time the related application state updates. + +Let's go into the `WelcomeWorkflow` now, +and have it return a `WelcomeScreen` from the `render()` method. + +While you're here, opt out of persistence by making `snapshotState` return `null`. ```kotlin object WelcomeWorkflow : StatefulWorkflow() { @@ -167,20 +186,31 @@ object WelcomeWorkflow : StatefulWorkflow() renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.username, - onUsernameChanged = {}, - onLoginTapped = {} + promptText = "", + onLogInTapped = {} ) - // … + + + override fun snapshotState(state: State): Snapshot? = null } ``` -### Setting up the View Registry and Activity +> [!TIP] This tutorial doesn't cover persistence support. +> If you feel the need for it, +> the easiest way to get there is by using the [`@Parcelize` annotation](https://developer.android.com/kotlin/parcelize) on your state types. +> They will be saved and restored via the `savedStateHandler` of the JetPack `ViewModel` +> discussed in the next section. +> Most apps should be fine returning `null` here. -Now we have our `WelcomeWorkflow` rendering a `WelcomeScreen`, and have a layout runner that knows how to display with a `WelcomeScreen`. It's time to bind this all together and actually show it on the screen! +### Setting up the Activity -Since we're about to include functionality related to AndroidX `ViewModel`s, `build.gradle` should be updated with the following dependencies: +We have our `WelcomeWorkflow` rendering a `WelcomeScreen` +which declares a `ScreenViewFactory` that knows how to display it. +Now let's wire all this up to Android and show it on the screen! + +We're about to use functionality related to AndroidX `ViewModel`s, +so `build.gradle` should be updated with the following dependencies: ```groovy dependencies { @@ -191,59 +221,75 @@ dependencies { } ``` -We'll update the `TutorialActivity` to set its content using a `ViewRegistry` that points to our `LayoutRunner`'s `ViewFactory`: +We'll update `TutorialActivity.onCreate()` to kick off a Workflow runtime by calling `renderWorkflowIn`. +Actually, we will delegate that call to an AndroidX `ViewModel` and its CoroutineScope. +This ensures that our runtime will survive as new `Activity` instances are created for configuration changes. +And this is **the only spot in a workflow app that the Android / AndroidX lifecycle concerns intrude on developers**. ```kotlin -@file:OptIn(WorkflowUiExperimentalApi::class) -package workflow.tutorial -// ... - -// This doesn't look like much right now, but we'll add more layout runners shortly. -private val viewRegistry = ViewRegistry(WelcomeLayoutRunner) - class TutorialActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Use an AndroidX ViewModel to start and host an instance of the workflow runtime that runs - // the WelcomeWorkflow and sets the activity's content view using our view factories. val model: TutorialViewModel by viewModels() setContentView( - WorkflowLayout(this).apply { start(model.renderings, viewRegistry) } + WorkflowLayout(this).apply { + take(lifecycle, model.renderings) + } ) } -} -class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { - val renderings: StateFlow by lazy { - renderWorkflowIn( - workflow = WelcomeWorkflow, - scope = viewModelScope, - savedStateHandle = savedState - ) + class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { + @OptIn(WorkflowExperimentalRuntime::class) + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = RootNavigationWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = RuntimeConfigOptions.ALL + ) + } } } ``` -Now, we've created a `ViewRegistry` that consists of, so far, only our `WelcomeLayoutRunner`'s `ViewFactory`, and we're using it to set the content view of our activity. When the activity is started, it will start running the `WelcomeWorkflow`. +> [!NOTE] +> You'll see that we opt in to some "experimental" `runtimeConfig` options -- all of them, in fact. +> These are optimizations that are in production use at Square. +> They will not be labeled as experimental much longer, +> and will soon be enabled by default. +> In the meantime it is much easier to use them from the start than to turn them on down the road. + +When the activity is started, +it will start running the `WelcomeWorkflow` and display the Welcome Screen. +Give it a spin! ## Driving the UI from Workflow State -Right now, the workflow isn't handling any of the events from the UI. Let's update it to be responsible for the login username as well as the action when the login button is pressed. +Right now, the workflow isn't really doing anything. +Let's update it to take action when the Log In button is pressed. ### State -All workflows have a `State` type that represents the internal state of the workflow. This should be all of the data for which *this* workflow is _responsible_. It usually corresponds to the state for the UI. +Every workflow has a `StateT` parameter type that represents its internal state, +`WelcomeWorkflow.State` in our case. +This should be all of the data for which *this* workflow is _responsible_. +It usually corresponds to the state for the UI. + +Let's model the state that we want to track. +There isn't much, just a possible error prompt. -Let's model the first part of state that we want to track: the login `username`. Update the `State` to a `data class` and include a username property. We will also need to update `initialState` to give an initial value: +We we will also need to update `initialState` to set up…our initial state. +Let's add a `"Hello Workflow!"` message there +to give us some confidence that this thing is working. ```kotlin object WelcomeWorkflow : StatefulWorkflow() { data class State( - val username: String + val prompt: String ) // … @@ -251,43 +297,82 @@ object WelcomeWorkflow : StatefulWorkflow() override fun initialState( props: Unit, snapshot: Snapshot? - ): State = State(username = "") + ): State = State(prompt = "Hello Workflow!") + + override fun render( + renderProps: Unit, + renderState: State, + context: RenderContext + ): WelcomeScreen = WelcomeScreen( + promptText = renderState.prompt, + onLogInTapped = {} + ) + // … } ``` -Now that we have the state modeled, we'll send it to the UI every time a render pass happens. The text field will overwrite its value with whatever was provided. +When you run the app again it's not very exciting. +You should see our cute "Hello" message, but otherwise it still behaves the same as before: +you can type in the username field and nothing happens when you press the Log In button. +If you put a break point in `render()` you'll see that it is called only once. -If you run the app again, you'll see that it still behaves the same, letting you type into the username field. This is because we have only rendered the screen once. +Let's put some life into this thing. -You may have noticed that your workflow only has access to its `State` in a few functions, and even then in many cases it is read-only. This is intentional. Instead of allowing your Workflow to modify the state directly, the Workflow infrastructure manages the state for the various workflows that are running and triggers a re-render when appropriate. In order to update the workflow's internal state, we need to add an "Action", +You may have noticed that your workflow only has access to its `State` in a few functions, +and even then in most cases it is read-only. This is intentional. +Instead of allowing your Workflow to modify the state directly, +the Workflow infrastructure manages the state for the various workflows that are running +and triggers a re-render (and thus a UI update) when appropriate. +In order to update the workflow's internal state, we need to add an "action". ### Actions -Actions define how a workflow handles events received from the outside world, such as UI events (e.g. button presses), network requests, data stores, etc. Generally a `WorkflowAction` is a function which defines a particular state transition or side effect. Typically you define a `WorkflowAction` by writing a function that returns the actual action. This makes it easy to pass parameters to your actions. The action itself may not execute right away. +An action defines how a workflow handles an event received from the outside world, +such as UI events (e.g. button presses), network requests, data store reads, etc. +A `WorkflowAction` is a function which defines a particular state transition. -Add a function called `onUsernameChanged` to update our internal state: +An event handler in workflow is a function that enqueues a `WorkflowAction` instance +to be processed by the workflow runtime. +This makes it easy to pass parameters to your actions. +The enqueued action itself may not execute right away. + +Let's use this system to handle Login Button taps. + +Add a function called `updateName` to update our internal state: ```kotlin - private fun onUsernameChanged(username: String) = action { - state = state.copy(username = username) +import com.squareup.workflow1.action + + private fun updateName(name: String) = action("updateName") { + state = when { + name.isEmpty() -> state.copy(prompt = "name required to log in") + else -> state.copy(prompt = "logging in as \"$name\"…") + } } ``` -The `action` function is a shorthand for implementing the `WorkflowAction` class yourself. You could also write: +The `action` factory function is a shorthand for implementing the `WorkflowAction` class yourself, +intended to spare you writing a lot of boilerplate. +You could also write: ```kotlin - private fun onUsernameChanged(username: String) = + private fun updateName(name: String) = object : WorkflowAction() { override fun Updater.apply() { - state = state.copy(username = username) + state = when { + name.isEmpty() -> state.copy(prompt = "name required to log in") + else -> state.copy(prompt = "logging in as \"$name\"…") + } } + + override val debuggingName: String get() = "updateName" } ``` -We need to send this action back to the workflow any time the username changes. Update the `render` method to send it through a sink back to the workflow whenever the `onUsernameChanged` closure is called. The action sink is how UI events can trigger workflow updates. When an action is sent to the sink, the infrastructure will execute the action and trigger another render pass: +And let's make an `onLogInTapped` event handler that enques one of those `updateName` actions. ```kotlin object WelcomeWorkflow : StatefulWorkflow() { @@ -299,9 +384,10 @@ object WelcomeWorkflow : StatefulWorkflow() renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.username, - onUsernameChanged = { context.actionSink.send(onUsernameChanged(it)) }, - onLoginTapped = {} + promptText = renderState.prompt, + onLogInTapped = { name -> + context.actionSink.send(updateName(name)) + } ) // … @@ -310,32 +396,102 @@ object WelcomeWorkflow : StatefulWorkflow() ### The update loop -If we run the app again, it will still behave the same but we are now capturing the username changes in our workflow's state, as well as having the UI show the username based upon the workflow's internal state. +Now when we run the app you'll see that the `viewBinding.prompt` is updated +whenever the `viewBinding.login` button is clicked, +based on the value found in `viewBinding.username`. + +Here is what is happening each time the Log In button is press: +1) The UI calls `screen.onLogInTapped()`. + +2) That event handler lambda calls `context.actionSink.send(updateName(it))`, + which sends an action to be handled by the workflow runtime. + +3) The `apply` method on the action is called. The `Updater` receiver has a `state` property. + The `state` property is a `var`, so when it is updated in `apply`, it updates the actual state. + This is effectively the same as this method being written `fun apply(fromState: State): Pair` where it transforms the previous state into a new state. + +4) As an action was just handled and the state was changed, + our old rendering is invalid and a new one must be created — that is, a render pass is triggered. + +5) `render()` is called on the workflow. A new `WelcomeScreen` is returned with the updated `promptText` from the internal state. + +6) The anonymous `ScreenViewRunner` lambda in `WelcomeScreen.kt` is called + with the new `WelcomeScreen` instance. + It updates the text field with the new `promptText` value, and also updates the click listener on `viewBinding.logIn`. + +7) The workflow waits for the next `WorkflowAction` to be received, + and then the goes through the same update loop. + +> [!TIP] +> You may be rolling your eyes at the naïveté of this example, +> especially around its hands-off use of `EditText`. +> Obviously this is as meant to be as simple a first taste as possible, +> just enough to get the fundamentals across. +> +> If you are skeptical that this approach could be practical with +> more polished keystroke-by-keystroke updates from a `TextWatcher`, you are right — it isn't. +> +> For that very common and surprisingly complex situation the workflow library provides a helper object: +> `TextController`, which simplifies working with both the `EditText` `View` and the `TextField` composable, +> ensuring that UI refreshes don't stomp what the user is typing. +> We'll use it in [the third tutorial](Tutorial3.md) + +### Brevity and Stability + +Now you have seen all the fundamental links in the chain that handle an event in a workflow UI: +Event handler functions create action functions and send them to the runtime for execution via `RenderContext.actionSink`. + +That's a powerful working model, +but it's also a good bit of boilerplate to have to type for every event handler. +In real life we don't usually do so. + +Let's update `WelcomeWorkflow` one more time to tighten it up. +We'll use `RenderContext.eventHandler` to inline the `updateName` action. -To see this, change the action lambda to append an extra letter on the username received, eg: ```kotlin - private fun onUsernameChanged(username: String) = action { - state = state.copy(username = username + "a") - } + override fun render( + renderProps: Unit, + renderState: State, + context: RenderContext + ): WelcomeScreen = WelcomeScreen( + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + state = when { + name.isEmpty() -> state.copy(prompt = "name required to log in") + else -> state.copy(prompt = "logging in as \"$name\"…") + } + } + ) + + // private fun updateName() is now unused, delete it. ``` -Running the app again will have the username field suffixed with a letter 'a' on every keypress. We probably want to undo this change, but it demonstrates that the UI is being updated from the internal state. +Under the hood of that `eventHandler` all the same moving parts are still in play, +but workflow wrote the `action` and `context.actionSink.send()` calls for you. +And as you'll see in a later tutorial lesson, we have not sacrificed any testability by using this convenience. + +`eventHandler` has another benefit: stability. + +You will recall from the Update Loop description above that `render()` is called repeatedly, +on the order of once for every event. +That means that an anonymous event handler like `onClick = { context.actionSink.send(someAction) }` is created anew each time, with each new handler unequal to the previous one. +This doesn't play well with Compose's [stability optimizations](https://developer.android.com/develop/ui/compose/performance/stability), +and isn't very nice for unit testing either. -Here is what is happening on each keypress: -1) The UI calls `onUsernameChanged` whenever the contents of the text field changes. -2) The lambda calls `context.actionSink.send(onUsernameChanged(it))`, which sends an action to be handled by the workflow. -3) The `apply` method on the action is called. The `Updater` receiver has a `state` property. The `state` property is a `var`, so when it is updated in `apply`, it updates the actual state. - - This is effectively the same as this method being written `fun apply(fromState: State): Pair` where it transforms the previous state into a new state. -4) As an action was just handled, the workflow must now be re-rendered so the `Screen` (and from it, the UI) can be updated. - - `render` is called on the workflow. A new screen is returned with the updated `username` from the internal state. -5) The layout runner is provided the new screen with the call to `fun showRendering(rendering: WelcomeScreen, viewEnvironment: ViewEnvironment)`. - - This layout runner updates the text field with the received username value, and also updates the callbacks for when the username changes or login is pressed. -6) The workflow waits for the next `WorkflowAction` to be received, and then the goes through the same update loop. +You'll notice that `eventHandler` has a required `name: String` parameter. +If on repeated calls to `render()` you make repeated calls to `eventHandler` with the same name, +the same object will be returned each time. +That means that a rendering's `data class` implementation of `equals` has a hope of returning `true` +if the new instance "looks" the same as that from the previous `render()` call, +and Compose has all the information it needs to decide whether or not recomposition is necessary. ## Summary -In this tutorial, we covered creating a Screen, `LayoutRunner`, Workflow, and binding them together in an Activity and ViewModel with `ViewRegistry` and `renderWorkflowIn`. We also covered the Workflow being responsible for the state of the UI instead of the `LayoutRunner` or `View` being responsible. +In this tutorial, we covered creating a `Screen`, `ScreenViewFactory`, and `Workflow`, +and running them in an `Activity` with `renderWorkflowIn`. +We also covered the workflow being responsible for the state of the UI, +and how to use `eventHandler` to do so concisely and efficiently. -Next, we will create a second screen and workflow, and then use composition to navigate between them. +Next, we will create a second `Screen` and `Workflow`, and then use composition to navigate between them. [Tutorial 2](Tutorial2.md) diff --git a/samples/tutorial/Tutorial2.md b/samples/tutorial/Tutorial2.md index 3cd23782cf..af914ba06f 100644 --- a/samples/tutorial/Tutorial2.md +++ b/samples/tutorial/Tutorial2.md @@ -2,26 +2,6 @@ _Multiple Screens and Navigation_ -## Stale Docs Warning - -**This tutorial is tied to an older version of Workflow, and relies on API that has been deprecated or deleted.** -The general concepts are the same, and refactoring to the current API is straightforward, -so it is still worthwhile to work through the tutorial in its current state until we find time to update it. -(Track that work [here](https://github.com/square/workflow-kotlin/issues/905) -and [here](https://github.com/square/workflow-kotlin/issues/884).) - -Here's a summary of what has changed, and what replaces what: - -- Use of `ViewRegistry` is now optional, and rare. - Have your renderings implement `AndroidScreen` or `ComposeScreen` to avoid it. -- The API for binding a rendering to UI code has changed as follows, and can all - be avoided if you use `ComposeScreen`: - - `ViewFactory` is replaced by `ScreenViewFactory`. - -`LayoutRunner` is replaced by `ScreenViewRunner`. - - `LayoutRunner.bind` is replaced by `ScreenViewFactory.fromViewBinding`. -- `BackStackScreen` has been moved to package `com.squareup.workflow1.ui.navigation`. -- `EditText.updateText` and `EditText.setTextChangedListener` are replaced by `TextController` - ## Setup To follow this tutorial, launch Android Studio and open this folder (`samples/tutorial`). @@ -32,51 +12,36 @@ Start from the implementation of `tutorial-1-complete` if you're skipping ahead. Let's add a second screen and workflow so we have somewhere to land after we log in. Our next screen will be a list of "todo" items, as todo apps are the best apps. -Create a new screen/`LayoutRunner` pair called `TodoList`: - -Add the provided `TodoListViewBinding` from `tutorial-views` as a subview to the newly created layout runner: +Create a new screen called `TodoListScreen`, using the provided `TodoListViewBinding` from `tutorial-views`. Note the extra setup of an adapter for the `todoList` `RecyclerView`. ```kotlin -/** - * This should contain all data to display in the UI. - * - * It should also contain callbacks for any UI events, for example: - * `val onButtonTapped: () -> Unit`. - */ data class TodoListScreen( - val username: String, - val todoTitles: List, - val onTodoSelected: (Int) -> Unit, - val onBack: () -> Unit -) - -class TodoListLayoutRunner( - /** From `todo_list_view.xml`. */ - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { + val TBD: String = "" +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoListViewBinding::inflate, ::todoListScreenRunner) +} - private val adapter = TodoListAdapter() +private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding +): ScreenViewRunner { + // This outer scope is run only once, right after the view is inflated. + val adapter = TodoListAdapter() - init { - todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) - todoListBinding.todoList.adapter = adapter - } + todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) + todoListBinding.todoList.adapter = adapter - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. } - - companion object : ViewFactory by bind( - TodoListViewBinding::inflate, ::TodoListLayoutRunner - ) } ``` -And then create the corresponding workflow called "TodoList". +And then create the corresponding `TodoListWorkflow`. -Modify the rendering to return a `TodoListScreen`. Modify `State` data class to contain a placeholder parameter, to make the compiler happy. We can leave everything else as the default for now: +Modify `render()` to return a `TodoListScreen`. +Modify `State` data class to contain a placeholder parameter, to make the compiler happy. +We can leave everything else as the default for now: ```kotlin object TodoListWorkflow : StatefulWorkflow() { @@ -93,15 +58,10 @@ object TodoListWorkflow : StatefulWorkflow renderState: State, context: RenderContext ): TodoListScreen { - return TodoListScreen( - username = "", - todoTitles = emptyList(), - onTodoSelected = {}, - onBack = {} - ) + return TodoListScreen() } - override fun snapshotState(state: State): Snapshot? = null + override fun snapshotState(state: State) = null } ``` @@ -109,20 +69,8 @@ object TodoListWorkflow : StatefulWorkflow For now, let's just show this new screen instead of the login screen/workflow. Update the activity to show the `TodoListWorkflow`: -- Add `TodoListLayoutRunner` to the `viewRegistry` -- Update `TutorialViewModel` to use `Any` as its `RenderingT` type, and `TodoListWorkflow` as its `workflow` argument. - -```kotlin -// TutorialActivity.kt - -private val viewRegistry = ViewRegistry( - WelcomeLayoutRunner, - TodoListLayoutRunner -) -``` - ```kotlin - val renderings: StateFlow by lazy { + val renderings: Flow by lazy { renderWorkflowIn( workflow = TodoListWorkflow, scope = viewModelScope, @@ -131,13 +79,14 @@ private val viewRegistry = ViewRegistry( } ``` -Run the app again, and now the empty todo list (table view) will be shown: +Run the app again, and now the empty todo list will be shown: -![Empty Todo List](images/empty-todolist.png) +![Empty Todo List](images/empty-todolist.png "Android phone app with a nearly blank screen: static text reading _What do you have to do?_ and a + button") ## Populating the Todo List -The empty list is rather boring, so let's fill it in with some sample data for now. Update the `State` type to include a list of todo model objects and change `initialState` to include a default one: +The empty list is rather boring, so let's fill it in with some sample data for now. +Update the `State` type to include a list of todo model objects and change `initialState` to include a default one: ```kotlin object TodoListWorkflow : StatefulWorkflow() { @@ -168,39 +117,30 @@ object TodoListWorkflow : StatefulWorkflow } ``` -Add a `todoTitles` property to the `TodoScreen`, and fill in `showRendering` to update the `TodoListViewBinding` to change what it shows anytime the screen updates: +Add a `todoTitles` property to the `TodoListScreen`, +and fill in the `ScreenViewRunner` to update the `TodoListViewBinding` +to change what it shows anytime the screen updates: ```kotlin data class TodoListScreen( val todoTitles: List -) - -class TodoListLayoutRunner( - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { - - private val adapter = TodoListAdapter() - - init { - todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) - todoListBinding.todoList.adapter = adapter - } +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoListViewBinding::inflate, ::todoListScreenRunner) +} - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { - todoListBinding.root.backPressedHandler = rendering.onBack +private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding +): ScreenViewRunner { + val adapter = TodoListAdapter() - with(todoListBinding.todoListWelcome) { - text = resources.getString(R.string.todo_list_welcome, rendering.username) - } + todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) + todoListBinding.todoList.adapter = adapter + return ScreenViewRunner { screen: TodoListScreen, _ -> adapter.todoList = rendering.todoTitles adapter.notifyDataSetChanged() - } - - // … + } } ``` @@ -209,17 +149,17 @@ Finally, update `render` for `TodoListWorkflow` to send the titles of the todo m ```kotlin object TodoListWorkflow : StatefulWorkflow() { + // … + override fun render( renderProps: Unit, renderState: State, context: RenderContext ): TodoListScreen { val titles = renderState.todos.map { it.title } + return TodoListScreen( - username = "", todoTitles = titles, - onTodoSelected = {}, - onBack = {} ) } @@ -229,20 +169,27 @@ object TodoListWorkflow : StatefulWorkflow Run the app again, and now there should be a single visible item in the list: -![Todo list hard coded](images/tut2-todolist-example.png) +![Todo list hard coded](images/tut2-todolist-example.png "The same Android phone app as above, now showing _Take the cat for a walk_ beneath the _What do you have to do?_ title") ## Composition and Navigation -Now that there are two different screens, we can make our first workflow showing composition with a single parent and two child workflows. Our `WelcomeWorkflow` and `TodoListWorkflow` will be the leaf nodes with a new workflow as the root. +Now that there are two different screens, +we can make our first workflow showing composition with a single parent and two child workflows. +Our `WelcomeWorkflow` and `TodoListWorkflow` will be the leaf nodes +with a new workflow as the root. ### Root Workflow -Create a new workflow called `Root` with the templates. +Create a new `RootNavigationWorkflow` with the templates. +This time, set the rendering type to `com.squareup.workflow1.ui.Screen`, +workflow's general interface for view model types. +This will allow us to eventually render multiple screens each of a different type. -We'll start with the `RootWorkflow` returning a rendering only showing the `WelcomeScreen` via the `WelcomeWorkflow`. Update the `Rendering` type to `Any` and `render()` to return `Any`. This will allow us to eventually render multiple screens each of a different type. Also update `render()` to have the `RootWorkflow` defer to a child workflow: +We'll start, though, with it returning a rendering only showing the `WelcomeScreen` +by delegating to the `WelcomeWorkflow`. ```kotlin -object RootWorkflow : StatefulWorkflow() { +object RootNavigationWorkflow : StatefulWorkflow() { override fun initialState( props: Unit, @@ -253,9 +200,9 @@ object RootWorkflow : StatefulWorkflow() { renderProps: Unit, renderState: Unit, context: RenderContext - ): Any { + ): Screen { // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will start a child workflow session with state, if one is not already running. + // infrastructure will start a child workflow session if one is not already running. val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> } return welcomeScreen @@ -269,7 +216,9 @@ However, this won't compile immediately, and the compiler will provide a less th ![missing-map-output](images/missing-map-output.png) -Anytime a child workflow is run, the parent needs a way of converting the child's `OutputT` into a `WorkflowAction` the parent can handle. The `WelcomeWorkflow`'s output type is currently a simple object: `object Output`. +Any time a child workflow is run, the parent needs a way of converting +the child's `OutputT` into a `WorkflowAction` the parent can handle. +But `WelcomeWorkflow`'s output type is currently a simple object: `object Output`. For now, delete the `Output` on `WelcomeWorkflow` and replace it with `Nothing`: @@ -279,123 +228,133 @@ object WelcomeWorkflow : StatefulWorkflow() } ``` -Remove the lambda at the end of `context.renderChild(WelcomeWorkflow)` -The override for using `renderChild` on a child Workflow with `Nothing` as its output type does not have a lambda parameter +Remove the lambda at the end of `context.renderChild(WelcomeWorkflow)`. +The override for using `renderChild` on a child Workflow with `Nothing` as its output type does not have a lambda parameter: ```kotlin override fun render( renderProps: Unit, renderState: Unit, context: RenderContext - ): Any { + ): Screen { // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will start a child workflow session with state, if one is not already running. + // infrastructure will start a child workflow session if one is not already running. val welcomeScreen = context.renderChild(WelcomeWorkflow) return welcomeScreen } ``` -Update the `TutorialActivity` to start at the `RootWorkflow` and we'll see the welcome screen again: +Update the `TutorialActivity` to start at the new `RootNavigationWorkflow`. + +At the same time, add a `reportNavigation()` call when creating the `renderings` `Flow`. ```kotlin - val renderings: StateFlow by lazy { + val renderings: Flow by lazy { renderWorkflowIn( - workflow = RootWorkflow, + workflow = RootNavigationWorkflow, scope = viewModelScope, savedStateHandle = savedState ) - } + }.reportNavigation() +``` + +Now when you run the app we'll see the welcome screen again. +If you look at `logcat` you'll see a line saying as much: + +```kotlin +navigate WelcomeScreen(promptText=, onLogInTapped=Function1) ``` +Of course that's the same screen that we saw before. +But this time we're seeing it because `RootNavigationWorkflow` chose to run it. + ### Navigating between Workflows -Now that there is a root workflow, it can be updated to navigate between the `Welcome` and `TodoList` workflows. +Now that there is a root workflow, +it can be updated to navigate between the `Welcome` and `TodoList` workflows. -Start by defining the state that needs to be tracked at the root - specifically which screen we're showing, and the actions to login and logout: +Start by defining the state that needs to be tracked at the root — +specifically which screen we're showing, and the actions to log in and log out: ```kotlin -object RootWorkflow : StatefulWorkflow() { +object RootNavigationWorkflow : StatefulWorkflow() { - sealed class State { - object Welcome : State() - data class Todo(val username: String) : State() + sealed interface State { + object ShowingWelcome : State + data class ShowingTodo(val username: String) : State } override fun initialState( props: Unit, snapshot: Snapshot? - ): State = Welcome + ): State = ShowingWelcome // … - private fun login(username: String) = action { - state = Todo(username) + private fun logIn(username: String) = action("logIn") { + state = ShowingTodo(username) } - private fun logout() = action { - state = Welcome + private val logOut = action("logOut") { + state = ShowingWelcome } } ``` -The root workflow is now modeling our states and actions. Soon we will be able to navigate between the welcome and todo list screens. +The root workflow is now modeling our states and actions. +Soon we will be able to navigate between the welcome and todo list screens. ### Workflow Output -Workflows can only communicate with each other through their "properties" as inputs and "outputs" as actions. When a child workflow emits an output, the parent workflow will receive it and map it into an action they can handle. +Workflows can only communicate with each other through their "properties" as inputs and "outputs" as actions. +When a child workflow emits an output, +the parent workflow will receive it and map it into an action the parent can handle. -Our welcome workflow has a login button that doesn't do anything, and we'll now handle it and let our parent know that we've "logged in" so it can navigate to another screen. +Our welcome workflow has a Log In button that doesn't do much. +Let's update it to let our parent know that we have "logged in" +and are ready to navigate to another screen. -Add an action for `onLogin` and change our `OutputT` type from `Output` to a new `data class LoggedIn` to be able to message our parent: +Change our `OutputT` type from `Output` to a new `data class LoggedIn` +to be able to signal our parent: ```kotlin - object WelcomeWorkflow : StatefulWorkflow() { data class LoggedIn(val username: String) // … - - private fun onUsernameChanged(username: String) = action { - state = state.copy(username = username) - } - - private fun onLogin() = action { - setOutput(LoggedIn(state.username)) - } } ``` -And fire the `onLogin` action any time the login button is pressed: +And post a `LoggedIn` output any time the Log In button is pressed +(provided we receive a useful `name`): ```kotlin - override fun render( - renderProps: Unit, - renderState: State, - context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.username, - onUsernameChanged = { context.actionSink.send(onUsernameChanged(it)) }, - onLoginTapped = { - // Whenever the login button is tapped, emit the onLogin action. - context.actionSink.send(onLogin()) + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + if (name.isEmpty()) { + state = state.copy(prompt = "name required to log in") + } else { + setOutput(LoggedIn(name)) } + } ) ``` -Finally, map the output event from `WelcomeWorkflow` in `RootWorkflow` to the `LoggedIn` action: +Finally, map the output event from `WelcomeWorkflow` in `RootNavigationWorkflow` to the `LoggedIn` action: ```kotlin override fun render( renderProps: Unit, renderState: Unit, context: RenderContext - ): Any { + ): Screen { // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will create a child workflow with state if one is not already running. + // infrastructure will start a child workflow session if one is not already running. val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> - // When WelcomeWorkflow emits LoggedIn, turn it into our login action. - login(output.username) + // When WelcomeWorkflow emits its LoggedIn output, map that to our logIn action. + logIn(output.username) } return welcomeScreen } @@ -403,9 +362,12 @@ Finally, map the output event from `WelcomeWorkflow` in `RootWorkflow` to the `L ### Showing a different workflow from state -Now we are handling the `LoggedIn` output of `WelcomeWorkflow`, and updating the state to show the `Todo` screen. However, we still need to update our render method to defer to a different workflow. +Now we are handling the `LoggedIn` output of `WelcomeWorkflow`, +and updating the state to show the Todo screen. +However, we still need to update our render method to delegate to a different workflow +when we're in the `ShowingTodo` state. -We'll update the `render` method to show either the `WelcomeWorkflow` or `TodoListWorkflow` depending on the state of `RootWorkflow` +We'll update the `RootNavigationWorkflow` `render` method to show either the `WelcomeWorkflow` or `TodoListWorkflow` depending on the `renderState`. Temporarily define the `OutputT` of `TodoListWorkflow` as `Nothing` (we can only go forward!): @@ -413,10 +375,10 @@ Temporarily define the `OutputT` of `TodoListWorkflow` as `Nothing` (we can only object TodoListWorkflow : StatefulWorkflow() { ``` -And update the `render` method of the `RootWorkflow`: +And update the `render` method of `RootNavigationWorkflow`: ```kotlin -object RootWorkflow : StatefulWorkflow() { +object RootNavigationWorkflow : StatefulWorkflow() { // … @@ -424,10 +386,10 @@ object RootWorkflow : StatefulWorkflow() { renderProps: Unit, renderState: State, context: RenderContext - ): Any { + ): Screen { when (renderState) { - // When the state is Welcome, defer to the WelcomeWorkflow. - is Welcome -> { + // When the state is ShowingWelcome, delegate to the WelcomeWorkflow. + is ShowingWelcome -> { // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the // infrastructure will create a child workflow with state if one is not already running. val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> @@ -437,11 +399,9 @@ object RootWorkflow : StatefulWorkflow() { return welcomeScreen } - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoScreen = context.renderChild(TodoListWorkflow, Unit) { - TODO() // we'll handle output of TodoListWorkflow later - } + // When the state is ShowingTodo, delegate to the TodoListWorkflow. + is ShowingTodo -> { + val todoScreen = context.renderChild(TodoListWorkflow, Unit) return todoScreen } } @@ -451,24 +411,59 @@ object RootWorkflow : StatefulWorkflow() { } ``` -This works, but with no animation between the two screens it's pretty unsatisfying. We'll fix that by using a different "container" to provide the missing transition animation. +This works, but with no animation between the two screens it's pretty unsatisfying. +We'll fix that by using a different "container" to provide the missing transition animation. ### Workflow Props -So far we are gathering a username from the welcome screen, and there's a place on the todo list screen to display the username, but we're not passing the username from the welcome screen to the list screen. We'll fix this by passing the username down from the `RootWorkflow` to the `TodoListWorkflow` via "props". - -Every workflow has a `PropsT` type that allows parents to send information to their children. It's the first parameter in the type parameter list. If a workflow doesn't need any data from its parent, it can use `Unit` as its props type. When rendering a child, a valid props value must always be passed to `renderChild`. The child workflow has access to the props from its parent in a few places: - -- `initialState` – the first time the parent renders the child, the props value it provides is passed to this function. -- `onPropsChanged` – this function is only called when the parent passes a different props value to `renderChild` than it did from the last time it called `renderChild`. This method gets both the old and the new props, and can return an updated state to reflect those changes. -- `render` – gets the props the parent passed to `renderChild`. The first time this workflow is rendered, this props will be the same as the value that was passed to `initialState`. -- `WorkflowAction.apply` – when an action is applied, the `Updater` receiver has a reference to the last props used to render the workflow that the action is being applied to. In terms of sequencing, actions always happen _between_ renders. - -A workflow's props is similar to its state in a sense: any time the workflow is "alive", it has a current state and a current props. The _state_ is readable and writable, and owned by the workflow – the workflow changes its own state. The _props_ are read-only, and owned by the workflow's parent. The child can't change its props, it can only observe the props its parent decided to pass down. - -Note that unlike output, which is how a child sends events to its parent, props does not represent events. In fact, another way to think of props is another kind of state – the "public" part of its state, if you will. - -Inside `TodoListWorkflow` create a new `ListProps` class and update TodoListWorkflow to use these props. Also update the method signature of `initialProps` and `render`: +So far we are gathering a username from the welcome screen, +and there's a place on the todo list screen to display the username, +but we're not passing the username from the welcome screen to the list screen. +We'll fix this by passing the username down from the `RootNavigationWorkflow` to the `TodoListWorkflow` via "props". + +Every workflow has a `PropsT` parameter type +that allows parents to send information to their children. +It's the first parameter in the type parameter list. +If a workflow doesn't need any data from its parent, +it can use `Unit` as its props type. +When rendering a child, a valid props value must always be passed to `renderChild`. +The child workflow has access to the props from its parent in a few places: + +- `initialState` – the first time the parent renders the child, + the props value it provides is passed to this function. + +- `onPropsChanged` – this function is only called when the parent passes a props value + to `renderChild` that is unequal to the one provided on the previous `renderChild` call. + This method gets both the old and the new props, + and can return an updated state to reflect those changes. + +- `render` – gets the props the parent passed to `renderChild` by the parent. + The first time this workflow is rendered, + this props will be the same as the value that was passed to `initialState`. + +- `WorkflowAction.apply` – when an action is applied, + the `Updater` receiver has a reference to the last props used to render the workflow + that the action is being applied to. + In terms of sequencing, actions always happen _between_ renders. + +Note that unlike output, which is how a child sends events to its parent, +props does not represent events. +In fact, another way to think of props is another kind of state – the "public" part of its state, if you will. + +A workflow's props is similar to its state in this sense: +any time the workflow is "alive", it has a current state and a current props. +The _state_ is readable and writable, and owned by the workflow itself — +the workflow changes its own state. +The _props_ are read-only, and owned by the workflow's parent. +The child can't change its props, it can only observe the props its parent decided to pass down. + +So you can think of a workflow has having a composite state, `PropsT` + `StateT`, +with `PropsT` being the public portion provided by the parent, +and `StateT` modeling the child's private concerns. + +Let's update `TodoListWorkflow` with a new `ListProps` +and use it to receive a `username` string. +We will also need to update `TodoListScreen` to display it. ```kotlin object TodoListWorkflow : StatefulWorkflow() { @@ -489,14 +484,34 @@ object TodoListWorkflow : StatefulWorkflow, +) : AndroidScreen { + +// … + + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + with(todoListBinding.todoListWelcome) { + text = resources.getString(R.string.todo_list_welcome, screen.username) + } + + adapter.todoList = screen.todoTitles + } +} +``` + +Now update `RootNavigationWorkflow` to pass `ListProps` when starting `TodoListWorkflow`: ```kotlin -object RootWorkflow : StatefulWorkflow() { +object RootNavigationWorkflow : StatefulWorkflow() { // … @@ -504,14 +519,16 @@ object RootWorkflow : StatefulWorkflow() { renderProps: Unit, renderState: State, context: RenderContext - ): Any { + ): Screen { when (renderState) { // … - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoScreen - = context.renderChild(TodoListWorkflow, ListProps(username = renderState.username)) { + // When the state is ShowingTodo, defer to the TodoListWorkflow. + is ShowingTodo -> { + val todoScreen = context.renderChild( + child = TodoListWorkflow, + props = ListProps(username = renderState.username) + ) { TODO() // we'll handle output of TodoListWorkflow later } return todoScreen @@ -522,102 +539,69 @@ object RootWorkflow : StatefulWorkflow() { } ``` -### Back Stack and "Containers" - -We want to animate changes between our screens. Because we want all of our navigation state to be declarative, we need to use the [`BackStackScreen`](https://square.github.io/workflow/kotlin/api/gfmCollector/workflow/com.squareup.workflow1.ui.backstack/-back-stack-screen/) to do this: - -```kotlin -class BackStackScreen( - bottom: StackedT, - rest: List -) { - // … - - val frames: List = listOf(bottom) + rest - - // … -} -``` - -The `BackStackScreen` contains a list of all screens in the back stack that are specified on each render pass. `BackStackScreen` is part of the `workflow-ui-container-android` artifact. Update `build.gradle` to include this dependency: +### Back Stack -```groovy -dependencies { - // ... - implementation deps.workflow.container_android - implementation deps.workflow.core_android -} -``` - -Update the `RootWorkflow` to return a `BackStackScreen` with a list of back stack items: +We want to animate changes between our screens. +We can use the `BackStackScreen` to do this in a declarative fashion. +Update the `RootNavigationWorkflow` to use `BackStackScreen<*>` as its `RenderT` type: ```kotlin -object RootWorkflow : StatefulWorkflow>() { +object RootNavigationWorkflow : StatefulWorkflow>() { // … - @OptIn(WorkflowUiExperimentalApi::class) override fun render( renderProps: Unit, renderState: State, context: RenderContext - ): BackStackScreen { - - // Our list of back stack items. Will always include the "WelcomeScreen". - val backstackScreens = mutableListOf() - - // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will create a child workflow with state if one is not already running. - val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> - // When WelcomeWorkflow emits LoggedIn, turn it into our login action. - login(output.username) + ): BackStackScreen<*> { + // We always render the welcomeScreen regardless of the current state. + // It's either showing or else we may want to pop back to it. + val welcomeScreen = context.renderChild(WelcomeWorkflow) { loggedIn -> + // When WelcomeWorkflow emits LoggedIn, enqueue our log in action. + logIn(loggedIn.username) } - backstackScreens += welcomeScreen - when (renderState) { - // When the state is Welcome, defer to the WelcomeWorkflow. - is Welcome -> { - // We always add the welcome screen to the backstack, so this is a no op. + return when (renderState) { + is ShowingWelcome -> { + BackStackScreen(welcomeScreen) } - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoListScreen = context.renderChild(TodoListWorkflow, props = ListProps(renderState.username)) { - // When receiving a Back output, treat it as a logout action. - logout() - } - backstackScreens.add(todoListScreen) + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoNavigationWorkflow, + props = TodoProps(renderState.username), + handler = { + // When TodoNavigationWorkflow emits Back, enqueue our log out action. + logOut + } + ) + listOf(welcomeScreen, todoBackStack).toBackStackScreen() } } - - // Finally, return the BackStackScreen with a list of BackStackScreen.Items - return backstackScreens.toBackStackScreen() } - - // … -} ``` -We also need to add `BackStackContainer` to the view registry to actually wire up the transition animations: - -```kotlin -// TutorialActivity.kt +We also need to update the output type of `TodoListWorkflow`. +So far it has output `Nothing`. +Define a `BackPressed` object and use it as `TodoListWorkflow`'s output type. +Update `render()` to create an `eventHandler` function to post the new output event. -private val viewRegistry = ViewRegistry( - BackStackContainer, - WelcomeLayoutRunner, - TodoListLayoutRunner -) -``` +At the same time, update TodoListScreen with an `onBack` event handler, +and use workflow's handy `View.setBackHandler` function to respond to Android back press events. -We also need to update the output type of `TodoListWorkflow`. So far it has output `Nothing`. Define a `Back` class, update `TodoListWorkflow`'s output type, then return this output inside `onBack`: +> [!NOTE] `View.setBackHandler` is implemented via +> [OnBackPressedCallback](https://developer.android.com/reference/androidx/activity/OnBackPressedCallback) +> and so plays nicely with the +> [OnBackPressedDispatcher](https://developer.android.com/reference/androidx/activity/OnBackPressedDispatcher), Compose's [BackHandler](https://foso.github.io/Jetpack-Compose-Playground/activity/backhandler/) +> and Android's [predictive back gesture](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture). ```kotlin -object TodoListWorkflow : StatefulWorkflow() { +object TodoListWorkflow : StatefulWorkflow() { // ... - object Back + object BackPressed // ... @@ -630,20 +614,100 @@ object TodoListWorkflow : StatefulWorkflow, + val onBackPressed: () -> Unit, +) : AndroidScreen { + + // … + + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + todoListBinding.root.setBackHandler(screen.onBackPressed) + + with(todoListBinding.todoListWelcome) { + text = resources.getString(R.string.todo_list_welcome, screen.username) + } + + adapter.todoList = screen.todoTitles + adapter.notifyDataSetChanged() } +} ``` ![Welcome to Todo List](images/welcome-to-todolist.gif) Neat! We can now log in and log out, and show the username entered as our title! -Next, we will add our Todo Editing screen. +```shell +navigate WelcomeScreen(promptText=, onLogInTapped=Function1) +navigate TodoListScreen(username=David, todoTitles=[Take the cat for a walk], onRowPressed=Function1, onBackPressed=Function0, onAddPressed=Function0) +navigate WelcomeScreen(promptText=, onLogInTapped=Function1) +``` + +> [!TIP] Note the logging above remains useful +> even though we are now wrapping our leaf screens in a `BackStackScreen`. +> The default `onNavigate` function used by `Flow<*>.reportNavigation()` +> can drill through the stock `Unwrappable` interface implemented by `BackStackScreen` +> and other wrapper rendering types. +> You can write your own wrapper types in the same manner +> to ensure they are similarly introspectable (handy for both logging and testing), +> and to control how they are logged by tools like `onNavigate()`. + +### On Navigation + +Let's take a moment to discuss just what that `BackStackScreen` is doing, +and workflow's philosophy on navigation in general. + +`BackStackScreen` is one of workflow's standard navigation containers, +and it's also `Screen` — just another "view model" value type. +There is no `BackStackWorkflow`, there are no `push`, `pop` or `goto` methods, +there is no `NavigationBackbone`, or anything like that. + +What you just implemented here in `RootNavigationWorkflow` is all that there is +on the back stack front, because we believe it is all that any app needs +for push / pop navigation. + +Our experience with capital "N" Navigation Frameworks (including the first one that we wrote) +has been that their model is not ours, +and that as our apps scale to not-even-all-that-big +we find ourselves needing to engineer around the framework instead of just writing our features. + +So our idiom for back stack management is to write workflows just like this one, which: + +- keeps track of what children are currently in play and to which it may return, and +- collects their `Screen` renderings in a widget (`BackStackScreen`) which is: + - good at recognizing if the stack of screens it's managing just grew or shrank and + - playing a push or a pop animation accordingly while also + - taking care to save and restore view system state of pushed and popped screens + +Workflow's `BackStackScreen` and the view code that expresses it are our only reusable back stack bits. +Your navigation will be pretty bespoke pretty quickly, +and is more likely than not to be strait-jacketed by anything more opinionated on the presenter side. + +That's really the tl;dr: of the Workflow library: + +- Workflows are state-machine style presenters that are good at recursion +- Navigation is just another presenter concern like any other + +You may be wondering at this point "but how does this scale?" +We'll show you how in the next tutorial, when we add our Todo Editing screen. + +> [!TIP] +> `BackStackScreen` isn't the only navigation UI primitive that workflow provides, +> (though so far it is the only one covered by this tutorial, sorry sorry sorry.) +> If you look in [the `navigation` package](https://github.com/square/workflow-kotlin/blob/main/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/) you will also see: +> +> - The `Overlay` marker interface, implemented by renderings that model things like Android `Dialog` windows +> - `ScreenOverlay` for modeling an `Overlay` whose content comes from a `Screen` +> - `BodyAndOverlaysScreen`, a class that arranges `Overlay` instances in layers over a body `Screen`. +> - And `the [AndroidOverlay](https://github.com/square/workflow-kotlin/blob/main/workflow-ui/core-android/src/main/java/com/squareup/workflow1/ui/navigation/AndroidOverlay.kt)` interface that simplifies implementing `ScreenOverlay` with Android's `AppCompatDialog` class. [Tutorial 3](Tutorial3.md) diff --git a/samples/tutorial/Tutorial3.md b/samples/tutorial/Tutorial3.md index 52e8dfd325..3a0236a519 100644 --- a/samples/tutorial/Tutorial3.md +++ b/samples/tutorial/Tutorial3.md @@ -1,24 +1,6 @@ # Step 3 -## Stale Docs Warning - -**This tutorial is tied to an older version of Workflow, and relies on API that has been deprecated or deleted.** -The general concepts are the same, and refactoring to the current API is straightforward, -so it is still worthwhile to work through the tutorial in its current state until we find time to update it. -(Track that work [here](https://github.com/square/workflow-kotlin/issues/905) -and [here](https://github.com/square/workflow-kotlin/issues/884).) - -Here's a summary of what has changed, and what replaces what: - -- Use of `ViewRegistry` is now optional, and rare. - Have your renderings implement `AndroidScreen` or `ComposeScreen` to avoid it. -- The API for binding a rendering to UI code has changed as follows, and can all - be avoided if you use `ComposeScreen`: - - `ViewFactory` is replaced by `ScreenViewFactory`. - -`LayoutRunner` is replaced by `ScreenViewRunner`. - - `LayoutRunner.bind` is replaced by `ScreenViewFactory.fromViewBinding`. -- `BackStackScreen` has been moved to package `com.squareup.workflow1.ui.navigation`. -- `EditText.updateText` and `EditText.setTextChangedListener` are replaced by `TextController` +_State throughout a tree of workflows_ ## Setup @@ -32,17 +14,29 @@ Now that a user can "log in" to their todo list, we want to add the ability to e ### State ownership -In the workflow framework, data flows _down_ the tree as properties set by parents on their child workflows, and comes _up_ as output events to their parents (as in the traditional computer science sense that trees that grow downward). +In the workflow framework, +data flows _down_ the tree as properties (`PropsT`) set by parents on their child workflows, +and comes _up_ as output events (`OutputT`) to their parents +(as in the traditional computer science sense that trees that grow downward). -What this means is that state should be created as far down the tree as possible, to limit the scope of state to be as small as possible. Additionally, there should be only one "owner" of the state in the tree - if it's passed farther down the tree, it should be a copy or read-only version of it - so there is no shared mutable state in multiple workflows. +What this means is that state should be created as far down the tree as possible, +to limit the scope of state to be as small as possible. +Additionally, there should be only one "owner" of the state in the tree — +if it's passed farther down the tree, +it should be a copy or read-only version of it — +so there is no shared mutable state in multiple workflows. -When a child workflow has a copy of the state from its parent, it should change it by emitting an _output_ back to the parent, requesting that it be changed. The child will then receive an updated copy of the data from the parent - keeping ownership at a single level of the tree. +When a child workflow has a copy of the state from its parent, +it should change it by emitting an _output_ event back to the parent, +requesting that it be changed. +The child will then receive an updated copy of the data from the parent - +keeping ownership at a single level of the tree. This is all a bit abstract, so let's make it more concrete by adding an edit todo item workflow. -### Create an todo edit workflow and screen +### Create an edit todo workflow and screen -Using the templates, create a `TodoEditWorkflow` and `TodoEditLayoutRunner`. +Using the templates, create a `TodoEditWorkflow` and `TodoEditScreen`. #### TodoEditScreen @@ -51,194 +45,142 @@ and layout runner. ```kotlin data class TodoEditScreen( - // The `TodoEditScreen` is empty to start. We'll add the contents later on. -) -``` - -```kotlin -class TodoEditLayoutRunner( - private val binding: TodoEditViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: TodoEditScreen, - viewEnvironment: ViewEnvironment - ) { - // TODO - } + // TODO: add properties needed to update TodoEditViewBinding +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoEditViewBinding::inflate, ::todoEditScreenRunner) +} - companion object : ViewFactory by bind( - TodoEditViewBinding::inflate, ::TodoEditLayoutRunner - ) +private fun todoEditScreenRunner( + binding: TodoEditViewBinding +) = ScreenViewRunner { screen: TodoEditScreen, _ -> + // TODO } ``` -This view isn't particularly useful without the data to present it, so update the `TodoEditScreen` to add the properties we need and the callbacks: +This view isn't particularly useful without the data to present it. +Update the `TodoEditScreen` to add the needed properties, including event handler callbacks; +and update the view with the data you've added to the screen: ```kotlin data class TodoEditScreen( /** The title of this todo item. */ - val title: String, + val title: TextController, /** The contents, or "note" of the todo. */ - val note: String, - - /** Callbacks for when the title or note changes. */ - val onTitleChanged: (String) -> Unit, - val onNoteChanged: (String) -> Unit, - - val discardChanges: () -> Unit, - val saveChanges: () -> Unit -) -``` + val note: TextController, -Then update the view with the data from the screen: - -```kotlin -class TodoEditLayoutRunner( - private val binding: TodoEditViewBinding -) : LayoutRunner { + val onBackPressed: () -> Unit, + val onSavePressed: () -> Unit +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoEditViewBinding::inflate, ::todoEditScreenRunner) +} - override fun showRendering( - rendering: TodoEditScreen, - viewEnvironment: ViewEnvironment - ) { - binding.root.backPressedHandler = rendering.discardChanges - binding.save.setOnClickListener { rendering.saveChanges() } - binding.todoTitle.updateText(rendering.title) - binding.todoTitle.setTextChangedListener { rendering.onTitleChanged(it.toString()) } - binding.todoNote.updateText(rendering.note) - binding.todoNote.setTextChangedListener { rendering.onNoteChanged(it.toString()) } - } +private fun todoEditScreenRunner( + binding: TodoEditViewBinding +) = ScreenViewRunner { screen: TodoEditScreen, _ -> + binding.root.setBackHandler(screen.onBackPressed) + binding.save.setOnClickListener { screen.onSavePressed() } - // … + screen.title.control(binding.todoTitle) + screen.note.control(binding.todoNote) } ``` -#### TodoEditWorkflow +Note in particular the use of `TextController` for `title` and `note`, +where you might have been expecting `String`. +`binding.todoTitle` and `binding.todoNote` are both `EditText` instances, +and those things are pretty unwieldy, +especially when you try to drive them from a UDF system. +`TextController` and its `control(EditText)` extension ease that pain. + +> [!TIP] +> There is also a `TextController.asMutableTextFieldValueState()` function +> for use with Compose's `BasicTextField` and the like. +> Even with Compose it is tricky to cope with editable text in a declarative way. +> +> It is a truth universally acknowledged, +> that a graphical user interface system in possesion of a good text editing facility, +> must be in want of a decent way to drive it from feature code. -Now that we have our screen and layout runner, let's create the `TodoEditWorkflow` to emit this screen as the rendering. +#### TodoEditWorkflow -The `TodoEditWorkflow` needs an initial Todo item passed into it from its parent. It will make a copy of it in its internal state (because `TodoModel` is a value type) — this can be the "scratch pad" for edits. This allows changes to be made and still be able to discard the changes if the user does not want to save them. +Now that we have our `Screen`, let's create the `TodoEditWorkflow` to emit it as a rendering. -Additionally, we will (finally) use the `onPropsChanged` method. If the edit workflow's parent provides an updated `todo`, it will invalidate the `todo` in `State`, and replace it with the one provided from the props. +The `TodoEditWorkflow` needs an initial todo item passed into it from its parent, via `Props`. +It will copy the values from that model to `TextController` fields in its private `State` class, — +which will serve as the "scratch pad" for edits. +This approach allows us to easily discard changes that the user does not choose to save. ```kotlin -object TodoEditWorkflow : StatefulWorkflow() { +object TodoEditWorkflow : StatefulWorkflow() { - data class EditProps( - /** The "Todo" passed from our parent. */ + /** @param initialTodo The model passed from our parent to be edited. */ + data class Props( val initialTodo: TodoModel ) + /** + * In-flight edits to be applied to the [TodoModel] originally provided + * by the parent workflow. + */ data class State( - /** The workflow's copy of the Todo item. Changes are local to this workflow. */ - val todo: TodoModel - ) + val editedTitle: TextController, + val editedNote: TextController + ) { + /** Transform this edited [State] back to a [TodoModel]. */ + fun toModel(): TodoModel = TodoModel(editedTitle.textValue, editedNote.textValue) + + companion object { + /** Create a [State] suitable for editing the given [model]. */ + fun forModel(model: TodoModel): State = State( + editedTitle = TextController(model.title), + editedNote = TextController(model.note) + ) + } + } - sealed class Output { + sealed interface Output { } override fun initialState( - props: EditProps, + props: Props, snapshot: Snapshot? - ): State = State(props.initialTodo) - - override fun onPropsChanged( - old: EditProps, - new: EditProps, - state: State - ): State { - // The `Todo` from our parent changed. Update our internal copy so we are starting from the same - // item. The "correct" behavior depends on the business logic - would we only want to update if - // the users hasn't changed the todo from the initial one? Or is it ok to delete whatever edits - // were in progress if the state from the parent changes? - if (old.initialTodo != new.initialTodo) { - return state.copy(todo = new.initialTodo) - } - return state - } + ): State = State.forModel(props.initialTodo) // … -} ``` -Next, define the actions this workflow will handle - specifically the title and note changing from the UI: +And let's update the `render` method to return a `TodoEditScreen`, +including event handlers for the Save and Back buttons. +We'll add two `Output` types to report these events to our parent. ```kotlin object TodoEditWorkflow : StatefulWorkflow() { // … - private fun onTitleChanged(title: String) = action { - state = state.withTitle(title) + sealed interface Output { + object DiscardChanges : Output + data class SaveChanges(val todo: TodoModel) : Output } - private fun onNoteChanged(note: String) = action { - state = state.withNote(note) - } - - private fun onDiscard() = action { - // Emit the Discard output when the discard action is received. - setOutput(Discard) - } - - private fun onSave() = action { - // Emit the Save output with the current todo state when the save action is received. - setOutput(Save(state.todo)) - } - - private fun State.withTitle(title: String) = copy(todo = todo.copy(title = title)) - private fun State.withNote(note: String) = copy(todo = todo.copy(note = note)) -``` - -Finally, update the `render` method to return a `TodoEditScreen`…: - -```kotlin -object TodoEditWorkflow : StatefulWorkflow() { - // … override fun render( - renderProps: EditProps, + renderProps: Props, renderState: State, context: RenderContext - ): TodoEditScreen { - return TodoEditScreen( - title = renderState.todo.title, - note = renderState.todo.note, - onTitleChanged = { context.actionSink.send(onTitleChanged(it)) }, - onNoteChanged = { context.actionSink.send(onNoteChanged(it)) }, - saveChanges = { context.actionSink.send(onSave()) }, - discardChanges = { context.actionSink.send(onDiscard()) } - ) - } - - // … -} -``` - -…and update the `ViewRegistry` in `TutorialActivity` with the matching `LayoutRunner`: - -```kotlin -private val viewRegistry = ViewRegistry( - BackStackContainer, - WelcomeLayoutRunner, - TodoListLayoutRunner, - TodoEditLayoutRunner -) -``` - -Now the workflow provides a backing for the UI to edit a todo item, but it doesn't support saving and discarding changes. Add two `Output`s and actions for these cases: - -```kotlin -object TodoEditWorkflow : StatefulWorkflow() { - - // … - - sealed class Output { - object Discard : Output() - data class Save(val todo: TodoModel) : Output() - } + ): TodoEditScreen = TodoEditScreen( + title = renderState.editedTitle, + note = renderState.editedNote, + onSavePressed = context.eventHandler("onSavePressed") { + setOutput(SaveChanges(state.toModel())) + }, + onBackPressed = context.eventHandler("onBackPressed") { + setOutput(DiscardChanges) + } + ) // … } @@ -248,14 +190,24 @@ object TodoEditWorkflow : StatefulWorkflow` so that it can return a list of screens, instead of just `TodoListScreen`. The parent workflow needs to know about the list, so it can pull the screens out and add them to its backstack. +The `TodoListWorkflow` will now occasionally need to render two screens instead of just the one. +Its parent workflow `RootNavigationWorkflow` will add the one or two screens +to the backstack it already constructs. +We need to update `TodoListWorkflow`'s rendering type to `List` +so that it can return a list of screens, +instead of just `TodoListScreen`. +The parent workflow needs to know about the list, +so it can pull the screens out and add them to its backstack. -We'll change the rendering type from `TodoListScreen` to `List`. We'll put the existing `todoListScreen` into a list. We can easily add to this list later. Also, we can now handle `onTodoSelected` to call a new method called `selectTodo`: +We'll change the rendering type from `TodoListScreen` to `List`. +We'll put the existing `todoListScreen` into a list. +We can easily add to this list later. ```kotlin -object TodoListWorkflow : StatefulWorkflow>() { +object TodoListWorkflow : StatefulWorkflow>() { // … @@ -263,28 +215,27 @@ object TodoListWorkflow : StatefulWorkflow>() renderProps: ListProps, renderState: State, context: RenderContext - ): List { + ): List { val titles = renderState.todos.map { it.title } - val todoListScreen = TodoListScreen( + return listOf( + TodoListScreen( username = renderProps.username, todoTitles = titles, - onTodoSelected = { context.actionSink.send(selectTodo(it)) }, - onBack = { context.actionSink.send(onBack()) } + onBackPressed = { context.actionSink.send(onBack()) } + ) ) - - return listOf(todoListScreen) } - private fun selectTodo(index: Int) = action { - TODO() - } + // … } ``` -Now that `TodoListWorkflow` renders a `List` we need to update its parent `RootWorkflow` to accept this list: +Now that `TodoListWorkflow` renders a `List` +we need to update the parent `RootNavigationWorkflow` to include this list +in the `BackStackScreen` it renders: ```kotlin -object RootWorkflow : StatefulWorkflow>() { +object RootNavigationWorkflow : StatefulWorkflow>() { // ... @@ -292,18 +243,23 @@ object RootWorkflow : StatefulWorkflow { - // .. - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoListScreens = context.renderChild(TodoListWorkflow, ListProps(renderState.username)) { - logout - } - backstackScreens.addAll(todoListScreens) + ): BackStackScreen<*> { + // … + + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoListWorkflow, + props = ListProps(renderState.username), + handler = { + // When TodoNavigationWorkflow emits Back, enqueue our log out action. + logOut + } + ) + (listOf(welcomeScreen) + todoBackStack).toBackStackScreen() } } - // ... + // … } ``` @@ -312,28 +268,30 @@ Run the app again to validate it still behaves the same. ### Adding the edit workflow as a child to the `TodoListWorkflow` -Now that the `TodoListWorkflow`'s rendering is a list of screens, it can be updated to show the edit workflow when a `Todo` item is tapped. +Now that the `TodoListWorkflow`'s rendering is a list of screens, +it can be updated to show the edit workflow when a `Todo` item is tapped. -Modify the state to represent if the list is being viewed, or an item is being edited by adding a new `Step` class: +Modify the state to represent if the list is being viewed, +or an item is being edited, by adding a new `Step` type: ```kotlin -object TodoListWorkflow : StatefulWorkflow>() { +object TodoListWorkflow : StatefulWorkflow>() { - data class ListProps(val username: String) + // … data class State( val todos: List, val step: Step ) { - sealed class Step { + sealed interface Step { /** Showing the list of items. */ - object List : Step() + object ShowList : Step /** - * Editing a single item. The state holds the index so it can be updated when a save action is - * received. + * Editing a single item. The state holds the index + * so it can be updated when a save action is received. */ - data class Edit(val index: Int) : Step() + data class EditItem(val index: Int) : Step } } @@ -341,24 +299,64 @@ object TodoListWorkflow : StatefulWorkflow>() } ``` -Modify `selectTodo` to transition to this new `Edit` step when the user selects a todo +Let's add an `onRowPressed` event handler parameter to `TodoListScreen` +and wire it up to the `adapter`: ```kotlin -object TodoListWorkflow : StatefulWorkflow>() { +data class TodoListScreen( + val username: String, + val todoTitles: List, + val onRowPressed: (Int) -> Unit, + val onBackPressed: () -> Unit, +) : AndroidScreen { // … - private fun selectTodo(index: Int) = action { - // When a todo item is selected, edit it. - state = state.copy(step = Step.Edit(index)) +private fun todoListScreenRunner( + // … + + adapter.todoList = screen.todoTitles + adapter.onTodoSelected = screen.onRowPressed + adapter.notifyDataSetChanged() + } +} +``` + +And create an `eventHandler` function to fill it in `TodoListWorkflow.render()`: + +```kotlin +object TodoListWorkflow : StatefulWorkflow>() { + + // … + + override fun render( + renderProps: ListProps, + renderState: State, + context: RenderContext + ): List { + val titles = renderState.todos.map { it.title } + return listOf( + TodoListScreen( + username = renderProps.username, + todoTitles = titles, + onBackPressed = context.eventHandler("onBackPressed") { setOutput(BackPressed) }, + onRowPressed = context.eventHandler("onRowPressed") { index -> + // When a todo item is selected, edit it. + state = state.copy(step = Step.EditItem(index)) + } + ) + ) } + + // … + } ``` -Modify `intialState` to start TodoListWorkflow in `Step.List`: +Modify `intialState` to start TodoListWorkflow in `Step.ShowList`: ```kotlin -object TodoListWorkflow : StatefulWorkflow>() { +object TodoListWorkflow : StatefulWorkflow>() { // … @@ -366,47 +364,48 @@ object TodoListWorkflow : StatefulWorkflow>() props: ListProps, snapshot: Snapshot? ) = State( - todos = listOf( - TodoModel( - title = "Take the cat for a walk", - note = "Cats really need their outside sunshine time. Don't forget to walk " + - "Charlie. Hamilton is less excited about the prospect." - ) - ), - step = Step.List - )} + todos = listOf( + TodoModel( + title = "Take the cat for a walk", + note = "Cats really need their outside sunshine time. Don't forget to walk " + + "Charlie. Hamilton is less excited about the prospect." + ) + ), + step = Step.ShowList + ) ``` -Add actions for saving or discarding the changes: +Add actions for saving or discarding changes: ```kotlin -object TodoListWorkflow : StatefulWorkflow>() { +object TodoListWorkflow : StatefulWorkflow>() { // … - private fun discardChanges() = action { - // When a discard action is received, return to the list. - state = state.copy(step = Step.List) + private fun discardChanges() = action("discardChanges") { + // Discard changes by simply returning to the list. + state = state.copy(step = Step.ShowList) } private fun saveChanges( todo: TodoModel, index: Int - ) = action { - // When changes are saved, update the state of that todo item and return to the list. + ) = action("saveChanges") { + // To save changes update the state of the item at index and return to the list. state = state.copy( - todos = state.todos.toMutableList().also { it[index] = todo }, - step = Step.List + todos = state.todos.toMutableList().also { it[index] = todo }, + step = Step.ShowList ) } } ``` -Update the `render` method to defer to the `TodoEditWorkflow` when editing and handle output from `TodoEditWorkflow`: +Update the `render` method to defer to the `TodoEditWorkflow` when editing, +and to handle the output events it posts: ```kotlin -object TodoListWorkflow : StatefulWorkflow>() { +object TodoListWorkflow : StatefulWorkflow>() { // … @@ -414,32 +413,38 @@ object TodoListWorkflow : StatefulWorkflow>() renderProps: ListProps, renderState: State, context: RenderContext - ): List { + ): List { val titles = renderState.todos.map { it.title } val todoListScreen = TodoListScreen( - username = renderProps.username, - todoTitles = titles, - onTodoSelected = { context.actionSink.send(selectTodo(it)) }, - onBack = { context.actionSink.send(onBack()) } + username = renderProps.username, + todoTitles = titles, + onBackPressed = context.eventHandler("onBackPressed") { setOutput(BackPressed) }, + onRowPressed = context.eventHandler("onRowPressed") { index -> + // When a todo item is selected, edit it. + state = state.copy(step = Step.EditItem(index)) + } ) return when (val step = renderState.step) { // On the "list" step, return just the list screen. - Step.List -> listOf(todoListScreen) - is Step.Edit -> { - // On the "edit" step, return both the list and edit screens. + Step.ShowList -> listOf(todoListScreen) + + // On the "edit" step, return both the list and edit screens. + is Step.EditItem -> { val todoEditScreen = context.renderChild( - TodoEditWorkflow, - props = EditProps(renderState.todos[step.index]) + TodoEditWorkflow, + props = TodoEditWorkflow.Props(renderState.todos[step.index]) ) { output -> when (output) { // Send the discardChanges action when the discard output is received. - Discard -> discardChanges() + DiscardChanges -> discardChanges() + // Send the saveChanges action when the save output is received. - is Save -> saveChanges(output.todo, step.index) + is SaveChanges -> saveChanges(output.todo, step.index) } } - return listOf(todoListScreen, todoEditScreen) + + listOf(todoListScreen, todoEditScreen) } } } @@ -448,28 +453,6 @@ object TodoListWorkflow : StatefulWorkflow>() } ``` -We have our workflows organized and working together. The last step is to update `TodoListLayoutRunner` to connect the adapter's click listener with the rendering's `onTodoSelected` function: - -```kotlin -class TodoListLayoutRunner( - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { - - // ... - - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { - // ... - - adapter.todoList = rendering.todoTitles - adapter.onTodoSelected = rendering.onTodoSelected - adapter.notifyDataSetChanged() - } -} -``` - Now we have a (nearly) fully formed app! Try it out and see how the data flows between the different workflows: ![Edit-flow](images/full-edit-flow.gif) @@ -478,8 +461,8 @@ Now we have a (nearly) fully formed app! Try it out and see how the data flows b What we just built demonstrates how state should be handled in a tree of workflows: * The `TodoListWorkflow` is responsible for the state of all the todo items. -* When an item is edited, the `TodoEditWorkflow` makes a _copy_ of it for its local state. The updates happen from the UI events (changing the title or note). Depending on if the user wants to save (hikes are fun!) or discard the changes (taking the cat for a swim is likely a bad idea), it emits an output of `Discard` or `Save`. -* When a `Save` output is emitted, it includes the updated todo model. The parent (`TodoListWorkflow`) updates its internal state for that one item. The child never knows the index of the item being edited, it only has the minimum state of the specific item. This lets the parent be able to safely update its array of todos without being concerned about index-out-of-bounds errors. +* When an item is edited, the `TodoEditWorkflow` makes a _copy_ of it for its local state. The updates happen from the UI events (changing the title or note). Depending on if the user wants to save (hikes are fun!) or discard the changes (taking the cat for a swim is likely a bad idea), it emits an output of `DiscardChanges` or `SaveChanges`. +* When a `SaveChanges` output is emitted, it includes the updated todo model. The parent (`TodoListWorkflow`) updates its internal state for that one item. The child never knows the index of the item being edited, it only has the minimum state of the specific item. This lets the parent be able to safely update its array of todos without being concerned about index-out-of-bounds errors. If so desired, the `TodoListWorkflow` could have additional checks for saving the changes. For instance, if the todo list was something fetched from a server, it may decide to discard any changes if the list was updated remotely, etc. diff --git a/samples/tutorial/Tutorial4.md b/samples/tutorial/Tutorial4.md index 496bcd1e0f..088baa565c 100644 --- a/samples/tutorial/Tutorial4.md +++ b/samples/tutorial/Tutorial4.md @@ -2,26 +2,6 @@ _Refactoring and rebalancing a tree of Workflows_ -## Stale Docs Warning - -**This tutorial is tied to an older version of Workflow, and relies on API that has been deprecated or deleted.** -The general concepts are the same, and refactoring to the current API is straightforward, -so it is still worthwhile to work through the tutorial in its current state until we find time to update it. -(Track that work [here](https://github.com/square/workflow-kotlin/issues/905) -and [here](https://github.com/square/workflow-kotlin/issues/884).) - -Here's a summary of what has changed, and what replaces what: - -- Use of `ViewRegistry` is now optional, and rare. - Have your renderings implement `AndroidScreen` or `ComposeScreen` to avoid it. -- The API for binding a rendering to UI code has changed as follows, and can all - be avoided if you use `ComposeScreen`: - - `ViewFactory` is replaced by `ScreenViewFactory`. - - `LayoutRunner` is replaced by `ScreenViewRunner`. - - `LayoutRunner.bind` is replaced by `ScreenViewFactory.fromViewBinding`. -- `BackStackScreen` has been moved to package `com.squareup.workflow1.ui.navigation`. -- `EditText.updateText` and `EditText.setTextChangedListener` are replaced by `TextController` - ## Setup To follow this tutorial, launch Android Studio and open this folder (`samples/tutorial`). @@ -30,28 +10,34 @@ Start from the implementation of `tutorial-3-complete` if you're skipping ahead. ## Refactoring a workflow by splitting it into a parent and child -The `TodoListWorkflow` has started to grow and has multiple concerns it's handling — specifically all of the `TodoListScreen` behavior, as well as the actions that can come from the `TodoEditWorkflow`. +The `TodoListWorkflow` has started to grow and has multiple concerns it's handling — +specifically all of the `TodoListScreen` behavior, +as well as the actions that can come from the `TodoEditWorkflow`. -When a single workflow seems to be doing too many things, a common pattern is to extract some of its responsibility into a parent. +When a single workflow seems to be doing too many things, +a common pattern is to extract some of its responsibility into a parent. ### TodoWorkflow -Create a new workflow called `Todo` that will be responsible for both the `TodoListWorkflow` and the `TodoEditWorkflow`. +Create a new workflow called `TodoNavigationWorkflow` that will be responsible for +managing both the `TodoListWorkflow` and the `TodoEditWorkflow`. ```kotlin -object TodoWorkflow : StatefulWorkflow>() { +object TodoNavigationWorkflow : StatefulWorkflow>() { // … } ``` -#### Moving logic from the TodoListWorkflow to the TodoWorkflow +#### Moving logic from the TodoListWorkflow to the TodoNavigationWorkflow -Move the `ListState` state, input, and outputs from the `TodoListWorkflow` up to the `TodoWorkflow`. It will be owner the list of todo items, and the `TodoListWorkflow` will simply show whatever is passed into its input: +Move the state, input, and outputs from the `TodoListWorkflow` up to the new `TodoNavigationWorkflow`. +It will be the owner the list of todo items, +and the `TodoListWorkflow` will simply show whatever is passed into its input: ```kotlin -object TodoWorkflow : StatefulWorkflow>() { +object TodoNavigationWorkflow : StatefulWorkflow>() { data class TodoProps(val username: String) @@ -91,7 +77,9 @@ object TodoWorkflow : StatefulWorkflow>() { } ``` -Define the output events from the `TodoListWorkflow` to include back and a new `SelectTodo` output. Also, We no longer need to maintain the todo list in the `State` and we can remove it: +Define the output events from the `TodoListWorkflow` to include back and a new `SelectTodo` output. +Change its `RenderT` type to `TodoListScreen`. +Also, the todo list will now be provided by our parent via `ListProps`, so we can move it there from `State`. ```kotlin object TodoListWorkflow : StatefulWorkflow() { @@ -103,9 +91,9 @@ object TodoListWorkflow : StatefulWorkflow() { // … - private fun onBack() = action { - // When an onBack action is received, emit a Back output. - setOutput(Back) - } - - private fun selectTodo(index: Int) = action { - // Tell our parent that a todo item was selected. - setOutput(SelectTodo(index)) + override fun render( + renderProps: ListProps, + renderState: State, + context: RenderContext + ): TodoListScreen { + val titles = renderProps.todos.map { it.title } + return TodoListScreen( + username = renderProps.username, + todoTitles = titles, + onBackPressed = context.eventHandler("onBackPressed") { setOutput(BackPressed) }, + onRowPressed = context.eventHandler("onRowPressed") { index -> + // Tell our parent that a todo item was selected. + setOutput(TodoSelected(index)) + }, + onBackPressed = { context.actionSink.send(reportBackPress) }, + ) } } ``` -Move the editing actions from the `TodoListWorkflow` to the `TodoWorkflow`. We won't be able to call these methods until we can respond to output from the `TodoEditWorkflow`, but doing this now helps clean up the `TodoListWorkflow`: +Move the editing actions from the `TodoListWorkflow` to the `TodoNavigationWorkflow`. +We won't be able to call these methods until we can respond to output from the `TodoEditWorkflow`, +but doing this now helps clean up the `TodoListWorkflow`: ```kotlin -object TodoWorkflow : StatefulWorkflow>() { +object TodoNavigationWorkflow : StatefulWorkflow>() { // … - private fun discardChanges() = action { - // When a discard action is received, return to the list. + private fun discardChanges() = action("discardChanges") { + // To discard edits, just return to the list. state = state.copy(step = Step.List) } private fun saveChanges( todo: TodoModel, index: Int - ) = action { + ) = action("saveChanges") { // When changes are saved, update the state of that todo item and return to the list. state = state.copy( - todos = state.todos.toMutableList().also { it[index] = todo }, - step = Step.List - ) - } -} -``` - -Because `TodoListWorkflow.State` has no properties anymore, it can't be a `data` class, so we need to change it to an `object`. Since the only reason to have a custom type for state is to define the data we want to store, we don't need a custom type anymore so we can just use `Unit`. You might ask why we need a state at all now. We will discuss that in the next section. For now `Unit` will get us moving forward. - -```kotlin -object TodoListWorkflow : StatefulWorkflow() { - - override fun initialState( - props: ListProps, - snapshot: Snapshot? - ) = Unit - - override fun render( - renderProps: ListProps, - renderState: Unit, - context: RenderContext - ): TodoListScreen { - // … - } - - override fun snapshotState(state: Unit): Snapshot? = null -} -``` - -Update the `render` method to only return the `TodoListScreen`: - -```kotlin -object TodoListWorkflow : StatefulWorkflow() { - - // … - - override fun render( - renderProps: ListProps, - renderState: Unit, - context: RenderContext - ): TodoListScreen { - val titles = renderProps.todos.map { it.title } - return TodoListScreen( - username = renderProps.username, - todoTitles = titles, - onTodoSelected = { context.actionSink.send(selectTodo(it)) }, - onBack = { context.actionSink.send(onBack()) } + todos = state.todos.toMutableList().also { it[index] = todo }, + step = Step.List ) } - - // … - } ``` -Without any state data, the `initialState` and `snapshotState` functions have no purpose anymore. It would be nice if we didn't have to write them. +Without any state data, the `initialState` and `snapshotState` functions have no purpose anymore. +It would be nice if we didn't have to write them. ### StatelessWorkflow -Until now, all of our workflows have been subclasses of `StatefulWorkflow`. When a workflow doesn't have any state data, it can implement `StatelessWorkflow` instead. This class only has the render method, the render method has no `state` parameter, and the class only has three type parameters: props, output, and rendering. +Until now, all of our workflows have been subclasses of `StatefulWorkflow`. +When a workflow doesn't have any state data, +it can implement `StatelessWorkflow` instead. +This class only has the render method (with no `renderState` parameter), +and the class only has three type parameters: `PropsT`, `OutputT`, and `RenderT`. ```kotlin object TodoListWorkflow : StatelessWorkflow() { @@ -241,10 +198,11 @@ object TodoListWorkflow : StatelessWorkflow() } ``` -Now that we've simplified the `TodoListWorkflow`, let's render it and handle its output in the `TodoWorkflow`: +Now that we've simplified the `TodoListWorkflow`, +let's render it and handle its output in the `TodoNavigationWorkflow`: ```kotlin -object TodoWorkflow : StatefulWorkflow>() { +object TodoNavigationWorkflow : StatefulWorkflow>() { // … @@ -252,39 +210,39 @@ object TodoWorkflow : StatefulWorkflow>() { renderProps: TodoProps, renderState: State, context: RenderContext - ): List { + ): List { val todoListScreen = context.renderChild( - TodoListWorkflow, - props = ListProps( - username = renderProps.username, - todos = renderState.todos - ) + TodoListWorkflow, + props = ListProps( + username = renderProps.name, + todos = renderState.todos + ) ) { output -> when (output) { - Output.Back -> onBack() - is SelectTodo -> editTodo(output.index) + Output.BackPressed -> goBack() + is TodoSelected -> editTodo(output.index) } } return listOf(todoListScreen) } - private fun onBack() = action { - // When an onBack action is received, emit a Back output. + private fun requestExit() = action("requestExit") { setOutput(Back) } - private fun editTodo(index: Int) = action { - // When a todo item is selected, edit it. + private fun editTodo(index: Int) = action("editTodo") { state = state.copy(step = Step.Edit(index)) } } ``` -So far `RootWorkflow` is still deferring to the `TodoListWorkflow`. Update the `RootWorkflow` to defer to the `TodoWorkflow` for rendering the `Todo` state. This will get us back into a state where we can build again (albeit without editing support): +So far `RootNavigationWorkflow` is still delegating to the `TodoListWorkflow`. +Update it to delegate to the `TodoNavigationWorkflow` for rendering the `ShowingTodo` state. +This will get us back into a state where we can build again (albeit without editing support): ```kotlin -object RootWorkflow : StatefulWorkflow>() { +object RootWorkflow : StatefulWorkflow>() { // … @@ -292,17 +250,20 @@ object RootWorkflow : StatefulWorkflow { + ): BackStackScreen<*> { // … - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoListScreens = context.renderChild(TodoWorkflow, TodoProps(state.username)) { - // When receiving a Back output, treat it as a logout action. - logout() - } - backstackScreens.addAll(todoListScreens) + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoNavigationWorkflow, + props = TodoProps(renderState.username), + handler = { + // When TodoNavigationWorkflow emits Back, enqueue our log out action. + logOut + } + ) + (listOf(welcomeScreen) + todoBackStack).toBackStackScreen() } } @@ -314,12 +275,15 @@ object RootWorkflow : StatefulWorkflow>() { +object TodoNavigationWorkflow : StatefulWorkflow>() { // … @@ -327,34 +291,34 @@ object TodoWorkflow : StatefulWorkflow>() { renderProps: TodoProps, renderState: State, context: RenderContext - ): List { + ): List { val todoListScreen = context.renderChild( - TodoListWorkflow, - props = ListProps( - username = renderProps.username, - todos = renderState.todos - ) + TodoListWorkflow, + props = ListProps( + username = renderProps.name, + todos = renderState.todos + ) ) { output -> when (output) { - Output.Back -> onBack() - is SelectTodo -> editTodo(output.index) + Output.BackPressed -> goBack() + is TodoSelected -> editTodo(output.index) + AddPressed -> createTodo() } } return when (val step = renderState.step) { // On the "list" step, return just the list screen. Step.List -> listOf(todoListScreen) + is Step.Edit -> { // On the "edit" step, return both the list and edit screens. val todoEditScreen = context.renderChild( - TodoEditWorkflow, - EditProps(renderState.todos[step.index]) + TodoEditWorkflow, + Props(renderState.todos[step.index]) ) { output -> when (output) { - // Send the discardChanges action when the discard output is received. - Discard -> discardChanges() - // Send the saveChanges action when the save output is received. - is Save -> saveChanges(output.todo, step.index) + DiscardChanges -> discardChanges() + is SaveChanges -> saveChanges(output.todo, step.index) } } return listOf(todoListScreen, todoEditScreen) @@ -368,10 +332,113 @@ object TodoWorkflow : StatefulWorkflow>() { That's it! There is now a workflow for both of our current steps of the Todo flow. -## Conclusion +## Adding adding + +Let's take advantage of our new, cleaner structure by adding one more feature — +it's finally time to make that Add button do something. +Let's work from the bottom up, from `TodoListScreen` through `TodoListWorkflow` to `TodoNavigationWorkflow`. + +First give `TodoListScreen` an `onAddPressed` event handler field, +and bind it to `todoListBinding.add`: + +```kotlin +data class TodoListScreen( + val username: String, + val todoTitles: List, + val onRowPressed: (Int) -> Unit, + val onBackPressed: () -> Unit, + val onAddPressed: () -> Unit +) : AndroidScreen { + + // … -Is the code better after this refactor? It's debatable - having the logic in the `TodoListWorkflow` was probably ok for the scope of what the app is doing. However, if more screens are added to this flow it would be much easier to reason about, as there would be a single touchpoint controlling where we are within the subflow of viewing and editing todo items. + private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding + ): ScreenViewRunner { + // … + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + todoListBinding.root.setBackHandler(screen.onBackPressed) + todoListBinding.add.setOnClickListener { screen.onAddPressed() } + // … +``` + +Now give `TodoListWorkflow` another `Output` event and post it: + +```kotlin +object TodoListWorkflow : StatelessWorkflow() { -Additionally, now the `TodoList` and `TodoEdit` workflows are completely decoupled - there is no longer a requirement that the `TodoEdit` workflow is displayed after the list. For instance, we could change the list to have "viewing" or "editing" modes, where tapping on an item might only allow it to be viewed, but another mode would allow editing. + // … + + sealed interface Output { + object BackPressed : Output + data class TodoSelected(val index: Int) : Output + object AddPressed : Output + } + + override fun render( + renderProps: ListProps, + context: RenderContext + ): TodoListScreen { + // … + + onAddPressed = context.eventHandler("onAddPressed") { setOutput(AddPressed) } + ) + } +} +``` + +And make `TodoNavigationWorkflow` honor the new `AddPressed` output, +like the compiler is asking you to. + +```kotlin +object TodoNavigationWorkflow : StatefulWorkflow>() { + + // … + + override fun render( + renderProps: TodoProps, + renderState: State, + context: RenderContext + ): List { + val todoListScreen = context.renderChild( + TodoListWorkflow, + props = ListProps( + username = renderProps.name, + todos = renderState.todos + ) + ) { output -> + when (output) { + Output.BackPressed -> goBack() + is TodoSelected -> editTodo(output.index) + AddPressed -> createTodo() + } + } + + // … + } + + private fun createTodo() = action("createTodo") { + // Append a new todo model to the end of the list. + state = state.copy( + todos = state.todos + TodoModel( + title = "New Todo", + note = "" + ) + ) + } +``` + +## Conclusion -It comes down to the individual judgement of the developer to decide how a tree of workflows should be shaped - this was intended to provide two examples of how this _could_ be structured, but not specify how it _should_. +Is the code better after this refactor? +It's debatable — having the logic in the `TodoListWorkflow` was probably ok for the scope of what the app is doing. +We have had a lot of success honoring this pattern of keeping leaf concerns (individual screens) +separate from navigation concerns right at the outset. +When more screens are added to this flow it will be much easier to reason about, +as there would be a single touchpoint controlling where we are within the subflow of viewing and editing todo items. + +Additionally, now the `TodoList` and `TodoEdit` workflows are completely decoupled - +there is no longer a requirement that the `TodoEdit` workflow is displayed after the list. +For instance, we could change the list to have "viewing" or "editing" modes, +where tapping on an item might only allow it to be viewed, but another mode would allow editing. diff --git a/samples/tutorial/Tutorial5.md b/samples/tutorial/Tutorial5.md index e8da14eec7..07272742cb 100644 --- a/samples/tutorial/Tutorial5.md +++ b/samples/tutorial/Tutorial5.md @@ -2,26 +2,6 @@ _Unit and Integration Testing Workflows_ -## Stale Docs Warning - -**This tutorial is tied to an older version of Workflow, and relies on API that has been deprecated or deleted.** -The general concepts are the same, and refactoring to the current API is straightforward, -so it is still worthwhile to work through the tutorial in its current state until we find time to update it. -(Track that work [here](https://github.com/square/workflow-kotlin/issues/905) -and [here](https://github.com/square/workflow-kotlin/issues/884).) - -Here's a summary of what has changed, and what replaces what: - -- Use of `ViewRegistry` is now optional, and rare. - Have your renderings implement `AndroidScreen` or `ComposeScreen` to avoid it. -- The API for binding a rendering to UI code has changed as follows, and can all - be avoided if you use `ComposeScreen`: - - `ViewFactory` is replaced by `ScreenViewFactory`. - - `LayoutRunner` is replaced by `ScreenViewRunner`. - - `LayoutRunner.bind` is replaced by `ScreenViewFactory.fromViewBinding`. -- `BackStackScreen` has been moved to package `com.squareup.workflow1.ui.navigation`. -- `EditText.updateText` and `EditText.setTextChangedListener` are replaced by `TextController` - ## Setup To follow this tutorial, launch Android Studio and open this folder (`samples/tutorial`). @@ -30,36 +10,41 @@ Start from the implementation of `tutorial-4-complete` if you're skipping ahead. ## Testing -`Workflow`s being easily testable was a design requirement. It is essential to building scalable, reliable software. - -The `workflow-testing` library is provided to allow easy unit and integration testing. For this tutorial, we'll use the `kotlin-test` library to define tests and assertions, but feel free to use your favorite testing or assertion library instead – `workflow-testing` doesn't care. +`Workflow`s being easily testable was a design requirement. +It is essential to building scalable, reliable software. -## Unit Tests: `WorkflowAction`s +The `workflow-testing` library is provided to allow easy unit and integration testing. +For this tutorial, we'll use the `kotlin-test` library to define tests and assertions, +but feel free to use your favorite testing or assertion library instead – `workflow-testing` doesn't care. +(In-house we're very partial to [Truth](https://truth.dev/).) -A `WorkflowAction`'s `apply` function is effectively a reducer. Given a current state and action, it returns a new state (and optionally an output). Because an `apply` function should almost always be a "pure" function, it is a great candidate for unit testing. +## Unit Tests: `testRender()` and `RenderTester` -The `applyTo` extension function is provided to facilitate writing unit tests against actions. +Most of the interesting logic in a `Workflow` implementation will be in its `render()` method, +and so those are also the focus of most of workflow unit testing. +The `testRender` extension on `Workflow` provides an easy way to test the rendering of a workflow. +It returns a `RenderTester` with a fluid API for describing test cases. -### applyTo +```kotlin +workflow.testRender(props = Props()) + .render { rendering -> + assertEquals("expected text on rendering", rendering.text) + } +``` -The `WorkflowAction` class has a single method, `apply`. This method is designed to be convenient to _implement_, but it's a bit awkward to call since it takes a special receiver. To make it easy to test `WorkflowAction`s, there is an extension method on `WorkflowAction` called `applyTo` that takes a current state and returns the new state and optional output: +It also provides a means to test that lambdas passed to screens cause the correct actions and state changes: ```kotlin -val (newState: State, output: WorkflowOutput?) = TestedWorkflow.someAction() - .applyTo( - props = Props(…), - state = State(…) - ) - -if (output != null) { - // The action set an output. -} else { - // The action did not call setOutput. -} +workflow.testRender(props = Props()) + .render { rendering -> + assertEquals("expected text on rendering", rendering.text) + rendering.updateText("updated") + } + .verifyActionResult { newState, output -> + assertEquals(State(text = "updated"), newState) + } ``` -You can use this function to test that your actions perform the correct state transitions and emit the correct outputs. - ### WelcomeWorkflow Tests Start by creating a new unit test directory structure and file: `src/test/java/workflow/tutorial/WelcomeWorkflowTest`. @@ -83,196 +68,151 @@ dependencies { } ``` -We need to open up access to `onUsernameChanged` and `onLogin` for testing. Let's change the access level on these methods to `internal`. - -```kotlin -object WelcomeWorkflow : StatefulWorkflow() { - - // ... - - internal fun onNameChanged(name: String) = action { - state = state.copy(username = name) - } - - internal fun onLogin() = action { - setOutput(LoggedIn(state.username)) - } -} -``` - -For the `WelcomeWorkflow`, we will start by testing that the `username` property is updated on the state every time a `onUsernameChanged` action is received: +We will start by testing that the action run on a successful log in +posts the given user name as output. ```kotlin class WelcomeWorkflowTest { - @Test fun `username updates`() { - val startState = WelcomeWorkflow.State("") - val action = WelcomeWorkflow.onUsernameChanged("myName") - val (state, output) = action.applyTo(state = startState, props = Unit) - - // No output is expected when the name changes. - assertNull(output) - - // The name has been updated from the action. - assertEquals("myName", state.username) + @Test fun `successful log in`() { + WelcomeWorkflow + .testRender(props = Unit) + // Simulate a log in button tap. + .render { screen -> + screen.onLogInTapped("Ada") + } + // Validate that LoggedIn was sent. + .verifyActionResult { _, output -> + assertEquals(LoggedIn("Ada"), output?.value) + } } } ``` -The `OutputT` of an action can also be tested. Next, we'll add a test for the `onLogin` action. - -```kotlin - @Test fun `login works`() { - val startState = WelcomeWorkflow.State("myName") - val action = WelcomeWorkflow.onLogin() - val (_, output) = action.applyTo(state = startState, props = Unit) - - // Now a LoggedIn output should be emitted when the onLogin action was received. - assertEquals(LoggedIn("myName"), output?.value) - } -``` - -We have now validated that an output is emitted when the `onLogin` action is received. However, while writing this test, it probably doesn't make sense to allow someone to log in without providing a username. Let's update the test to ensure that login is only allowed when there is a username: - -```kotlin - @Test fun `login does nothing when name is empty`() { - val startState = WelcomeWorkflow.State("") - val action = WelcomeWorkflow.onLogin() - val (state, output) = action.applyTo(state = startState, props = Unit) - - // Since the name is empty, onLogin will not emit an output. - assertNull(output) - // The name is empty, as was specified in the initial state. - assertEquals("", state.username) - } -``` - -The test will now fail, as a `onLogin` action will still cause `LoggedIn` output when the name is blank. Update the `WelcomeWorkflow` logic to reflect the new behavior we want: +We have now validated that an output is emitted on log in. +However, while writing this test, +it probably doesn't make sense to allow someone to log in without providing a username. +Let's add a test to ensure that login is only allowed when there is a username: ```kotlin -object WelcomeWorkflow : StatefulWorkflow() { - - // … - - internal fun onLogin() = action { - // Don't log in if the name isn't filled in. - if (state.username.isNotEmpty()) { - setOutput(LoggedIn(state.username)) - } + @Test fun `failed log in`() { + WelcomeWorkflow.testRender(props = Unit) + .render { screen -> + // Simulate a log in button tap with an empty name. + screen.onLogInTapped("") + } + .verifyActionResult { _, output -> + // No output will be emitted, as the name is empty. + assertNull(output) + } + .testNextRender() + .render { screen -> + // There is an error prompt. + assertEquals("name required to log in", screen.promptText) + } } - - // … - -} ``` -Run the test again and ensure that it passes. Additionally, run the app to see that it also reflects the updated behavior. - -### TodoListWorkflow - -We won't write tests for the actions in this workflow, since they don't contain any interesting logic. +Run the test again and ensure that it passes. ### TodoEditWorkflow -The `TodoEditWorkflow` has a bit more complexity since it holds a local copy of the todo to be edited. Start by adding tests for the actions: +The `TodoEditWorkflow` invites two tests, one for each of its possible output values. ```kotlin class TodoEditWorkflowTest { - - // Start with a todo of "Title" "Note" - private val startState = State(todo = TodoModel(title = "Title", note = "Note")) - - @Test fun `title is updated`() { - // These will be ignored by the action. - val props = EditProps(TodoModel(title = "", note = "")) - - // Update the title to "Updated Title" - val (newState, output) = TodoEditWorkflow.onTitleChanged("Updated Title") - .applyTo(props, startState) - - assertNull(output) - assertEquals(TodoModel(title = "Updated Title", note = "Note"), newState.todo) - } - - @Test fun `note is updated`() { - // These will be ignored by the action. - val props = EditProps(TodoModel(title = "", note = "")) - - // Update the note to "Updated Note" - val (newState, output) = TodoEditWorkflow.onNoteChanged("Updated Note") - .applyTo(props, startState) - - assertNull(output) - assertEquals(TodoModel(title = "Title", note = "Updated Note"), newState.todo) - } - @Test fun `save emits model`() { - val props = EditProps(TodoModel(title = "Title", note = "Note")) + // Start with a todo of "Title" "Note" + val props = Props(TodoModel(title = "Title", note = "Note")) - val (_, output) = TodoEditWorkflow.onSave() - .applyTo(props, startState) - - assertEquals(Save(TodoModel(title = "Title", note = "Note")), output?.value) + TodoEditWorkflow.testRender(props) + .render { screen -> + screen.title.textValue = "New title" + screen.note.textValue = "New note" + screen.onSavePressed() + }.verifyActionResult { _, output -> + val expected = SaveChanges(TodoModel(title = "New title", note = "New note")) + assertEquals(expected, output?.value) + } } -} -``` -The `TodoEditWorkflow` also uses the `onPropsChanged` method to update the internal state if its parent provides it with a different `todo`. Validate that this works as expected: + @Test fun `back press discards`() { + val props = Props(TodoModel(title = "Title", note = "Note")) -```kotlin - @Test fun `changed props updated local state`() { - val initialProps = EditProps(initialTodo = TodoModel(title = "Title", note = "Note")) - var state = TodoEditWorkflow.initialState(initialProps, null) - - // The initial state is a copy of the provided todo: - assertEquals("Title", state.todo.title) - assertEquals("Note", state.todo.note) - - // Create a new internal state, simulating the change from actions: - state = State(TodoModel(title = "Updated Title", note = "Note")) - - // Update the workflow properties with the same value. The state should not be updated: - state = TodoEditWorkflow.onPropsChanged(initialProps, initialProps, state) - assertEquals("Updated Title", state.todo.title) - assertEquals("Note", state.todo.note) - - // The parent provided different properties. The internal state should be updated with the - // newly-provided properties. - val updatedProps = EditProps(initialTodo = TodoModel(title = "New Title", note = "New Note")) - state = TodoEditWorkflow.onPropsChanged(initialProps, updatedProps, state) - assertEquals("New Title", state.todo.title) - assertEquals("New Note", state.todo.note) + TodoEditWorkflow.testRender(props) + .render { screen -> + screen.onBackPressed() + }.verifyActionResult { _, output -> + assertSame(DiscardChanges, output?.value) + } } +} ``` -## Unit Tests: Rendering +Add tests against the `render` method of the `TodoListWorkflow` as desired. + +> [!TIP] +> +> It is also possible and practical to write unit tests of `WorkflowAction` objects themselves. +> A `WorkflowAction`'s `apply` function is effectively a reducer. +> Given a current state and action, it returns a new state (and optionally an output). +> Because an `apply` function should almost always be a "pure" function, +> it is a great candidate for unit testing. +> +> The `WorkflowAction` class has a single method, `apply`. +> This method is designed to be convenient to _implement_, +> but it's a bit awkward to call since it takes a special receiver. +> To make it easy to test `WorkflowAction`s, +> there is an extension method on `WorkflowAction` called `applyTo` +> that takes a current state and returns the new state and optional output: +> +> ```kotlin +> val (newState: State, output: WorkflowOutput?) = TestedWorkflow.someAction() +> .applyTo( +> props = Props(…), +> state = State(…) +> ) +> +> if (output != null) { +> // The action set an output. +> } else { +> // The action did not call setOutput. +> } +> ``` +> +> You can use this function to test that your actions perform the correct state transitions +> and emit the correct outputs. +> So why don't we emphasize this technique in the tutorial? +> Because we don't tend to write this kind of of test much ourselves, +> even though we expected them to be our mainstay when we created the workflow library. +> We find that we lean heavily on inline `eventHandler` calls in our `render()` methods, +> and as a result most of our tests are built around `testRender()`. -Testing actions is very useful for validating all of the state transitions of a workflow, but it is also beneficial to verify the logic in `render`. Since the `render` method uses a private implementation of a `RenderContext`, there is a `RenderTester` to facilitate testing. +## Composition Testing -### RenderTester +We've demonstrated how to test leaf workflows for their actions and renderings. +However, the power of workflow is the ability to compose a tree of workflows. +The `RenderTester` provides tools to test workflows with children. -The `testRender` extension on `Workflow` provides an easy way to test the rendering of a workflow. It returns a `RenderTester` with a fluid API for describing test cases. +`RenderTester.expectWorkflow()` allows us to describe a child workflow +that is expected to be rendered in the next render pass. +It is given the type of child, an optional key, and the fake rendering to return. +It can also provide an optional output, and even a function to validate the props passed by the parent: ```kotlin -workflow.testRender(props = Props()) - .render { rendering -> - assertEquals("expected text on rendering", rendering.text) - } +// Type parameters are omitted for demonstration. +fun RenderTester.expectWorkflow( + workflowType: KClass>, + rendering: ChildRenderingT, + key: String = "", + crossinline assertProps: (props: ChildPropsT) -> Unit = {}, + output: WorkflowOutput? = null, + description: String = "" +) ``` -It also provides a means to test that lambdas passed to screens cause the correct actions and state changes: +The full API allows for declaring expected workers and (child) workflows, +as well as verification of resulting state and output: -```kotlin -workflow.testRender(props = Props()) - .render { rendering -> - assertEquals("expected text on rendering", rendering.text) - rendering.updateText("updated") - } - .verifyActionResult { newState, output -> - assertEquals(State(text = "updated"), newState) - } -``` - -The full API allows for declaring expected workers and (child) workflows, as well as verification of resulting state and output: ```kotlin workflow .testRender( @@ -297,312 +237,283 @@ workflow } ``` -### WelcomeWorkflow +The child's rendering _must_ be specified when declaring an expected workflow +since the parent's call to `renderChild` _must_ return a value of the appropriate rendering type, +and the workflow library can't know how to create those instances of your own types. -Add tests for the rendering of the `WelcomeWorkflow`: +> [!NOTE] Under `testRender` all children are mocked +> +> We consider tests built around `testRender` to be unit tests (as opposed to integration tests) +> because they do not actually run any child workflows or workers. +> Each call to `expectWorkflow` and is a declaration that a child of a certain type should be invoked, +> and a provider of whatever output (if any) a mock instance of it should provide. +> These tests are meant to focus on the subject workflow itself, +> not the full suite of all of its dependencies. +> +> See [Integration Testing](#integration-testing) below for a discussion of how to test a complete workflow tree. -```kotlin -class WelcomeWorkflowTest { +### RootNavigationWorkflow Tests - // … +The `RootNavigationWorkflow` is responsible for the entire state of our app. - @Test fun `rendering initial`() { - // Use the initial state provided by the welcome workflow. - WelcomeWorkflow.testRender(props = Unit) - .render { screen -> - assertEquals("", screen.username) - - // Simulate tapping the log in button. No output will be emitted, as the name is empty. - screen.onLoginTapped() - } - .verifyActionResult { _, output -> - assertNull(output) - } - } +First we can test the `ShowingWelcome` state on its own: - @Test fun `rendering name change`() { - // Use the initial state provided by the welcome workflow. - WelcomeWorkflow.testRender(props = Unit) - // Next, simulate the name updating, expecting the state to be changed to reflect the - // updated name. - .render { screen -> - screen.onUsernameChanged("Ada") - } - .verifyActionResult { state, _ -> - // https://github.com/square/workflow-kotlin/issues/230 - assertEquals("Ada", (state as WelcomeWorkflow.State).username) - } - } +```kotlin +class RootNavigationWorkflowTest { - @Test fun `rendering login`() { - // Start with a name already entered. - WelcomeWorkflow - .testRender( - initialState = WelcomeWorkflow.State(name = "Ada"), - props = Unit + @Test fun `welcome rendering`() { + RootNavigationWorkflow + // Start in the ShowingWelcome state + .testRender(initialState = ShowingWelcome, props = Unit) + // The `WelcomeWorkflow` is expected to be started in this render. + .expectWorkflow( + workflowType = WelcomeWorkflow::class, + rendering = WelcomeScreen( + promptText = "Well hello there!", + onLogInTapped = {} + ) ) - // Simulate a log in button tap. - .render { screen -> - screen.onLoginTapped() + // Now, validate that there is a single item in the BackStackScreen, + // which is our welcome screen. + .render { rendering -> + val frames = rendering.frames + assertEquals(1, frames.size) + + val welcomeScreen = frames[0] as WelcomeScreen + assertEquals("Well hello there!", welcomeScreen.promptText) } - // Finally, validate that LoggedIn was sent. + // Assert that no action was produced during this render, + // meaning our state remains unchanged .verifyActionResult { _, output -> - assertEquals(LoggedIn("Ada"), output?.value) + assertNull(output) } } } ``` -Add tests against the `render` methods of the `TodoEdit` and `TodoList` workflows as desired. - -## Composition Testing - -We've demonstrated how to test leaf workflows for their actions and renderings. However, the power of workflow is the ability to compose a tree of workflows. The `RenderTester` provides tools to test workflows with children. - -`RenderTester.expectWorkflow()` allows us to describe a child workflow that is expected to be rendered in the next render pass. It is given the type of child, an optional key, and the fake rendering to return. It can also provide an optional output, and even a function to validate the props passed by the parent: - -```kotlin -// Type parameters are omitted for demonstration. -fun RenderTester.expectWorkflow( - workflowType: KClass>, - rendering: ChildRenderingT, - key: String = "", - crossinline assertProps: (props: ChildPropsT) -> Unit = {}, - output: WorkflowOutput? = null, - description: String = "" -) -``` - -The child's rendering _must_ be specified when declaring an expected workflow since the parent's call to `renderChild` _must_ return a value of the appropriate rendering type, and the workflow library can't know how to create those instances of your own types. - -### RootWorkflow Tests - -The `RootWorkflow` is responsible for the entire state of our app. We can skip testing the actions explicitly, as that will be handled by testing the rendering. - -First we can test the `Welcome` state on its own: - -```kotlin -class RootWorkflowTest { - - @Test fun `welcome rendering`() { - RootWorkflow - // Start in the Welcome state - .testRender(initialState = Welcome, props = Unit) - // The `WelcomeWorkflow` is expected to be started in this render. - .expectWorkflow( - workflowType = WelcomeWorkflow::class, - rendering = WelcomeScreen( - username = "Ada", - onUsernameChanged = {}, - onLoginTapped = {} - ) - ) - // Now, validate that there is a single item in the BackStackScreen, which is our welcome - // screen. - .render { rendering -> - val backstack = rendering.frames - assertEquals(1, backstack.size) - - val welcomeScreen = backstack[0] as WelcomeScreen - assertEquals("Ada", welcomeScreen.username) - } - // Assert that no action was produced during this render, meaning our state remains unchanged - .verifyActionResult { _, output -> - assertNull(output) - } - } -} -``` - -Now, we can also test the transition from the `Welcome` state to the `Todo` state: +We can also test the transition from the `Welcome` state to the `Todo` state: ```kotlin @Test fun `login event`() { - RootWorkflow - // Start in the Welcome state - .testRender(initialState = Welcome, props = Unit) - // The WelcomeWorkflow is expected to be started in this render. - .expectWorkflow( - workflowType = WelcomeWorkflow::class, - rendering = WelcomeScreen( - username = "Ada", - onUsernameChanged = {}, - onLoginTapped = {} - ), - // Simulate the WelcomeWorkflow sending an output of LoggedIn as if the "log in" button - // was tapped. - output = WorkflowOutput(LoggedIn(username = "Ada")) - ) - // Now, validate that there is a single item in the BackStackScreen, which is our welcome - // screen (prior to the output). - .render { rendering -> - val backstack = rendering.frames - assertEquals(1, backstack.size) - - val welcomeScreen = backstack[0] as WelcomeScreen - assertEquals("Ada", welcomeScreen.username) - } - // Assert that the state transitioned to Todo. - .verifyActionResult { newState, _ -> - assertEquals(Todo(username = "Ada"), newState) - } + RootNavigationWorkflow + // Start in the Welcome state + .testRender(initialState = ShowingWelcome, props = Unit) + // The WelcomeWorkflow is expected to be started in this render. + .expectWorkflow( + workflowType = WelcomeWorkflow::class, + rendering = WelcomeScreen( + promptText = "yo", + onLogInTapped = {} + ), + // Simulate the WelcomeWorkflow sending an output of LoggedIn + // as if the "log in" button was tapped. + output = WorkflowOutput(LoggedIn(username = "Ada")) + ) + // Now, validate that there is a single item in the BackStackScreen, + // which is our welcome screen (prior to the output). + .render { rendering -> + val backstack = rendering.frames + assertEquals(1, backstack.size) + + val welcomeScreen = backstack[0] as WelcomeScreen + } + // Assert that the state transitioned to Todo. + .verifyActionResult { newState, _ -> + assertEquals(ShowingTodo(username = "Ada"), newState) + } } ``` -By simulating the output from the `WelcomeWorkflow`, we were able to drive the `RootWorkflow` forward. This was much more of an integration test than a "pure" unit test, but we have now validated the same behavior we see by testing the app by hand. +By simulating the output from the `WelcomeWorkflow`, +we were able to drive the `RootNavigationWorkflow` forward. -### TodoWorkflow Render Tests +### TodoNavigationWorkflow Render Tests -Now add tests for the `TodoWorkflow`, so that we have relatively full coverage. These are two examples, of selecting and saving a todo to validate the transitions between screens, as well as updating the state in the parent: +Now add tests for the `TodoNavigationWorkflow`, +so that we have relatively full navigation coverage. +These are two examples, of selecting and saving a todo to validate the transitions between screens, +as well as updating the state in the parent: ```kotlin -class TodoWorkflowTest { +class TodoNavigationWorkflowTest { @Test fun `selecting todo`() { val todos = listOf(TodoModel(title = "Title", note = "Note")) - TodoWorkflow - .testRender( - props = TodoProps(username = "Ada"), - // Start from the list step to validate selecting a todo. - initialState = State( - todos = todos, - step = List - ) + TodoNavigationWorkflow + .testRender( + props = TodoProps(name = "Ada"), + // Start from the list step to validate selecting a todo. + initialState = State( + todos = todos, + step = List ) - // We only expect the TodoListWorkflow to be rendered. - .expectWorkflow( - workflowType = TodoListWorkflow::class, - rendering = TodoListScreen( - username = "", - todoTitles = listOf("Title"), - onTodoSelected = {}, - onBack = {} - ), - // Simulate selecting the first todo. - output = WorkflowOutput(SelectTodo(index = 0)) + ) + // We only expect the TodoListWorkflow to be rendered. + .expectWorkflow( + workflowType = TodoListWorkflow::class, + rendering = TodoListScreen( + username = "", + todoTitles = listOf("Title"), + onRowPressed = {}, + onBackPressed = {}, + onAddPressed = {} + ), + // Simulate selecting the first todo. + output = WorkflowOutput(TodoSelected(index = 0)) + ) + .render { backstack -> + // Just validate that there is one item in the back stack. + // Additional validation could be done on the screens returned, if desired. + assertEquals(1, backstack.size) + } + // Assert that the state was updated after the render pass with the output from the + // TodoListWorkflow. + .verifyActionResult { newState, _ -> + assertEqualState( + State( + todos = listOf(TodoModel(title = "Title", note = "Note")), + step = Edit(0) + ), newState ) - .render { rendering -> - // Just validate that there is one item in the back stack. - // Additional validation could be done on the screens returned, if desired. - assertEquals(1, rendering.size) - } - // Assert that the state was updated after the render pass with the output from the - // TodoListWorkflow. - .verifyActionResult { newState, _ -> - assertEquals( - State( - todos = listOf(TodoModel(title = "Title", note = "Note")), - step = Edit(0) - ), - newState - ) - } + } } @Test fun `saving todo`() { val todos = listOf(TodoModel(title = "Title", note = "Note")) - TodoWorkflow - .testRender( - props = TodoProps(username = "Ada"), - // Start from the edit step so we can simulate saving. - initialState = State( - todos = todos, - step = Edit(index = 0) - ) + TodoNavigationWorkflow + .testRender( + props = TodoProps(name = "Ada"), + // Start from the edit step so we can simulate saving. + initialState = State( + todos = todos, + step = Edit(index = 0) ) - // We always expect the TodoListWorkflow to be rendered. - .expectWorkflow( - workflowType = TodoListWorkflow::class, - rendering = TodoListScreen( - username = "", - todoTitles = listOf("Title"), - onTodoSelected = {}, - onBack = {} - ) + ) + // We always expect the TodoListWorkflow to be rendered. + .expectWorkflow( + workflowType = TodoListWorkflow::class, + rendering = TodoListScreen( + username = "", + todoTitles = listOf("Title"), + onRowPressed = {}, + onBackPressed = {}, + onAddPressed = {} ) - // Expect the TodoEditWorkflow to be rendered as well (as we're on the edit step). - .expectWorkflow( - workflowType = TodoEditWorkflow::class, - rendering = TodoEditScreen( - title = "Title", - note = "Note", - onTitleChanged = {}, - onNoteChanged = {}, - discardChanges = {}, - saveChanges = {} - ), - // Simulate it emitting an output of `.save` to update the state. - output = WorkflowOutput( - Save( - TodoModel( - title = "Updated Title", - note = "Updated Note" - ) - ) + ) + // Expect the TodoEditWorkflow to be rendered as well (as we're on the edit step). + .expectWorkflow( + workflowType = TodoEditWorkflow::class, + rendering = TodoEditScreen( + title = TextController("Title"), + note = TextController("Note"), + onBackPressed = {}, + onSavePressed = {} + ), + // Simulate it emitting an output of `.save` to update the state. + output = WorkflowOutput( + SaveChanges( + TodoModel( + title = "Updated Title", + note = "Updated Note" ) - ) - .render { rendering -> - // Just validate that there are two items in the back stack. - // Additional validation could be done on the screens returned, if desired. - assertEquals(2, rendering.size) - } - // Validate that the state was updated after the render pass with the output from the - // TodoEditWorkflow. - .verifyActionResult { newState, _ -> - assertEquals( - State( - todos = listOf(TodoModel(title = "Updated Title", note = "Updated Note")), - step = List - ), - newState ) - } + ) + ) + .render { rendering -> + // Just validate that there are two items in the back stack. + // Additional validation could be done on the screens returned, if desired. + assertEquals(2, rendering.size) + } + // Validate that the state was updated after the render pass with the output from the + // TodoEditWorkflow. + .verifyActionResult { newState, _ -> + assertEqualState( + State( + todos = listOf(TodoModel(title = "Updated Title", note = "Updated Note")), + step = List + ), + newState + ) + } + } + + private fun assertEqualState(expected: State, actual: State) { + assertEquals(expected.todos.size, actual.todos.size) + expected.todos.forEachIndexed { index, _ -> + assertEquals( + expected.todos[index].title, + actual.todos[index].title, + "todos[$index].title" + ) + assertEquals( + expected.todos[index].note, + actual.todos[index].note, + "todos[$index].note" + ) + } + assertEquals(expected.step, actual.step) } } ``` ## Integration Testing -The `RenderTester` allows easy "mocking" of child workflows and workers. However, this means that we are not exercising the full infrastructure (even though we could get a fairly high confidence from the tests). Sometimes, it may be worth putting together integration tests that test a full tree of Workflows. This lets us test integration with the non-workflow world as well, such as external reactive data sources that your workflows might be observing via Workers. +The `RenderTester` allows easy "mocking" of child workflows and workers. +However, this means that we are not exercising the full infrastructure +(even though we could get a fairly high confidence from the tests). +ometimes, it may be worth putting together integration tests that test a full tree of Workflows. +This lets us test integration with the non-workflow world as well, +such as external reactive data sources that your workflows might be observing via Workers. -Add another test to `RootWorkflowTests`. We will use another test helper that spins up a real instance of the workflow runtime, the same runtime that `renderWorkflowIn` uses. +> [!TIP] +> +> Integration tests can also be a fast, unflakey alternative to Espresso tests — +> especially when combined with [Paparazzi](https://github.com/cashapp/paparazzi) snapshot tests. + +Add another test to `RootNavigationWorkflowTests`. +We will use another test helper that spins up a real instance of the workflow runtime, +the same runtime that `renderWorkflowIn` uses. ### WorkflowTester -When you create an Android app using Workflow, you will probably use `renderWorkflowIn`, which starts a runtime to host your workflows in an androidx ViewModel. Under the hood, this method is an overload of lower-level `renderWorkflowIn` function that runs the workflow runtime in a coroutine and exposes a `StateFlow` of renderings. When writing integration tests for workflows, you can use this core function directly (maybe with a library like [Turbine](https://github.com/cashapp/turbine)), or you can use `workflow-testing`'s `WorkflowTester`. The `WorkflowTester` starts a workflow and lets you request renderings and outputs manually so you can write tests that interact with the runtime from the outside. +When you create an Android app using Workflow, +you will probably use `renderWorkflowIn`, +which starts a runtime to host your workflows in an androidx ViewModel. +Under the hood,this method is an overload of lower-level `renderWorkflowIn` function +that runs the workflow runtime in a coroutine and exposes a `StateFlow` of renderings. +When writing integration tests for workflows, +you can use this core function directly (maybe with a library like [Turbine](https://github.com/cashapp/turbine)), +or you can use `workflow-testing`'s `WorkflowTester`. +The `WorkflowTester` starts a workflow and lets you request renderingsand outputs manually +so you can write tests that interact with the runtime from the outside. -This will be an opaque test, as we can only test the behaviors from the rendering and will not be able to inspect the underlying states. This may be a useful test for validation when refactoring a tree of workflows to ensure they behave the same way. +This will be a properly opaque test, +as we can only test the behaviors from the rendering and will not be able to inspect the underlying states. This may be a useful test for validation when refactoring a tree of workflows +to ensure they behave the same way. -The main entry point to this API is to call `Workflow.launchForTestingFromStartWith()` and pass a lambda that implements your test logic. +The main entry point to this API is to call `Workflow.launchForTestingFromStartWith()` +and pass a lambda that implements your test logic. -### RootWorkflow +### RootNavigationWorkflow Let's use `launchForTestingFromStartWith` to write a general integration test for `RootWorkflow`: ```kotlin -class RootWorkflowTest { +class RootNavigationWorkflowTest { // … @Test fun `app flow`() { - RootWorkflow.launchForTestingFromStartWith { + RootNavigationWorkflow.launchForTestingFromStartWith { // First rendering is just the welcome screen. Update the name. awaitNextRendering().let { rendering -> assertEquals(1, rendering.frames.size) val welcomeScreen = rendering.frames[0] as WelcomeScreen - // Enter a name. - welcomeScreen.onUsernameChanged("Ada") - } - - // Log in and go to the todo list. - awaitNextRendering().let { rendering -> - assertEquals(1, rendering.frames.size) - val welcomeScreen = rendering.frames[0] as WelcomeScreen - - welcomeScreen.onLoginTapped() + // Enter a name and tap login + welcomeScreen.onLogInTapped("Ada") } // Expect the todo list to be rendered. Edit the first todo. @@ -613,7 +524,7 @@ class RootWorkflowTest { assertEquals(1, todoScreen.todoTitles.size) // Select the first todo. - todoScreen.onTodoSelected(0) + todoScreen.onRowPressed(0) } // Selected a todo to edit. Expect the todo edit screen. @@ -623,19 +534,9 @@ class RootWorkflowTest { assertTrue(rendering.frames[1] is TodoListScreen) val editScreen = rendering.frames[2] as TodoEditScreen - // Update the title. - editScreen.onTitleChanged("New Title") - } - - // Save the selected todo. - awaitNextRendering().let { rendering -> - assertEquals(3, rendering.frames.size) - assertTrue(rendering.frames[0] is WelcomeScreen) - assertTrue(rendering.frames[1] is TodoListScreen) - val editScreen = rendering.frames[2] as TodoEditScreen - - // Save the changes by tapping the save button. - editScreen.saveChanges() + // Enter a title and save. + editScreen.title.textValue = "New Title" + editScreen.onSavePressed() } // Expect the todo list. Validate the title was updated. @@ -652,8 +553,13 @@ class RootWorkflowTest { } ``` -This test was *very* verbose, and rather long. Generally, it's not recommended to do full integration tests like this (the action tests and render tests can give pretty solid coverage of a workflow's behavior). However, this is an example of how it might be done in case it's needed. +This test was *very* verbose, and rather long. +Generally we prefer smaller tests built around `testRender`, +with a sprinkling of full integration tests like this +when we need to ensure that services or child workflows are being invoked correctly. ## Conclusion -This was intended as a guide of how testing can be facilitated with the `workflow-testing` library provided for workflows. As always, it is up to the judgement of the developer of what and how their software should be tested. +With this tutorial under your belt you should be ready to write solid tests for your workflow based apps. +These are the same testing patterns we use every day at Square, in every Android app we ship — +along with a lot of [Paparazzi](https://github.com/cashapp/paparazzi) snapshot tests of our `Screen` classes. diff --git a/samples/tutorial/build.gradle b/samples/tutorial/build.gradle index 53dcac5960..818d9bccdf 100644 --- a/samples/tutorial/build.gradle +++ b/samples/tutorial/build.gradle @@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile buildscript { ext { kotlin_version = '2.0.21' - workflow_version = "1.12.1-beta04" + workflow_version = "1.19.0" deps = [ activityktx: 'androidx.activity:activity-ktx:1.3.0', @@ -23,7 +23,8 @@ buildscript { viewmodelsavedstate: 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.1.0', workflow: [ core_android: "com.squareup.workflow1:workflow-ui-core-android:$workflow_version", - container_android: "com.squareup.workflow1:workflow-ui-container-android:$workflow_version", + compose: "com.squareup.workflow1:workflow-ui-compose:$workflow_version", + compose_tooling: "com.squareup.workflow1:workflow-ui-compose-tooling:$workflow_version", testing: "com.squareup.workflow1:workflow-testing-jvm:$workflow_version", ], ] diff --git a/samples/tutorial/images/empty-todolist.png b/samples/tutorial/images/empty-todolist.png index e75179a98b..aed9b7d1bd 100644 Binary files a/samples/tutorial/images/empty-todolist.png and b/samples/tutorial/images/empty-todolist.png differ diff --git a/samples/tutorial/images/layout-runner-name.png b/samples/tutorial/images/layout-runner-name.png deleted file mode 100644 index e804e8a7ff..0000000000 Binary files a/samples/tutorial/images/layout-runner-name.png and /dev/null differ diff --git a/samples/tutorial/images/missing-map-output.png b/samples/tutorial/images/missing-map-output.png index 95a44bcb63..3b83f02e02 100644 Binary files a/samples/tutorial/images/missing-map-output.png and b/samples/tutorial/images/missing-map-output.png differ diff --git a/samples/tutorial/images/new-workflow.png b/samples/tutorial/images/new-workflow.png index 0de2dc6ad7..86e4d7b801 100644 Binary files a/samples/tutorial/images/new-workflow.png and b/samples/tutorial/images/new-workflow.png differ diff --git a/samples/tutorial/images/screen-name.png b/samples/tutorial/images/screen-name.png new file mode 100644 index 0000000000..a9155ec80f Binary files /dev/null and b/samples/tutorial/images/screen-name.png differ diff --git a/samples/tutorial/images/tut2-todolist-example.png b/samples/tutorial/images/tut2-todolist-example.png index 5b38f5b3c1..b5334cf360 100644 Binary files a/samples/tutorial/images/tut2-todolist-example.png and b/samples/tutorial/images/tut2-todolist-example.png differ diff --git a/samples/tutorial/images/welcome-to-todolist.gif b/samples/tutorial/images/welcome-to-todolist.gif index 0b9d427f61..346bc781bb 100644 Binary files a/samples/tutorial/images/welcome-to-todolist.gif and b/samples/tutorial/images/welcome-to-todolist.gif differ diff --git a/samples/tutorial/images/welcome.png b/samples/tutorial/images/welcome.png index 7d7098b562..4353c57042 100644 Binary files a/samples/tutorial/images/welcome.png and b/samples/tutorial/images/welcome.png differ diff --git a/samples/tutorial/images/workflow-name.png b/samples/tutorial/images/workflow-name.png index 7d9a02a69f..0054d36352 100644 Binary files a/samples/tutorial/images/workflow-name.png and b/samples/tutorial/images/workflow-name.png differ diff --git a/samples/tutorial/tutorial-1-complete/build.gradle b/samples/tutorial/tutorial-1-complete/build.gradle index 2e99744c8d..b10dde1186 100644 --- a/samples/tutorial/tutorial-1-complete/build.gradle +++ b/samples/tutorial/tutorial-1-complete/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "com.squareup.workflow.tutorial" - minSdk = 21 + minSdk = 24 targetSdk = 33 versionCode 1 versionName "1.0" diff --git a/samples/tutorial/tutorial-1-complete/src/main/AndroidManifest.xml b/samples/tutorial/tutorial-1-complete/src/main/AndroidManifest.xml index bfc92c7b0c..eddb348a72 100644 --- a/samples/tutorial/tutorial-1-complete/src/main/AndroidManifest.xml +++ b/samples/tutorial/tutorial-1-complete/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.WorkflowTutorial"> + android:theme="@style/Theme.WorkflowTutorial" + > diff --git a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/TutorialActivity.kt b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/TutorialActivity.kt index ef76bc6c3f..356ab6c8ba 100644 --- a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/TutorialActivity.kt +++ b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/TutorialActivity.kt @@ -1,5 +1,3 @@ -@file:OptIn(WorkflowUiExperimentalApi::class) - package workflow.tutorial import android.os.Bundle @@ -8,36 +6,48 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import com.squareup.workflow1.ui.renderWorkflowIn -import kotlinx.coroutines.flow.StateFlow - -// This doesn't look like much right now, but we'll add more layout runners shortly. -private val viewRegistry = ViewRegistry(WelcomeLayoutRunner) +import kotlinx.coroutines.flow.Flow class TutorialActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Use an AndroidX ViewModel to start and host an instance of the workflow runtime that runs - // the WelcomeWorkflow and sets the activity's content view using our view factories. + // We use an AndroidX ViewModel and its CoroutineScope to start and host + // an instance of the workflow runtime that runs the WelcomeWorkflow. + // This ensures that our runtime will survive as new `Activity` instances + // are created for configuration changes. val model: TutorialViewModel by viewModels() setContentView( - WorkflowLayout(this).apply { start(model.renderings, viewRegistry) } + WorkflowLayout(this).apply { + take(lifecycle, model.renderings) + } ) } -} -class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { - val renderings: StateFlow by lazy { - renderWorkflowIn( - workflow = WelcomeWorkflow, - scope = viewModelScope, - savedStateHandle = savedState, - ) + class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { + + // We opt in to WorkflowExperimentalRuntime in order turn on all the + // optimizations controlled by the runtimeConfig. + // + // They are in production use at Square, will not be listed as + // experimental much longer, and will soon be enabled by default. + // In the meantime it is much easier to use them from the start + // than to turn them on down the road. + @OptIn(WorkflowExperimentalRuntime::class) + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = WelcomeWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = RuntimeConfigOptions.ALL + ) + } } } diff --git a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt deleted file mode 100644 index 9d4aaf0c7b..0000000000 --- a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt +++ /dev/null @@ -1,38 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.WelcomeViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val welcomeBinding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - // updateText and setTextChangedListener are helpers provided by the workflow library that take - // care of the complexity of correctly interacting with EditTexts in a declarative manner. - welcomeBinding.username.updateText(rendering.username) - welcomeBinding.username.setTextChangedListener { - rendering.onUsernameChanged(it.toString()) - } - welcomeBinding.login.setOnClickListener { rendering.onLoginTapped() } - } - - /** - * Define a [ViewFactory] that will inflate an instance of [WelcomeViewBinding] and an instance - * of [WelcomeLayoutRunner] when asked, then wire them up so that [showRendering] will be called - * whenever the workflow emits a new [WelcomeScreen]. - */ - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt index af5918e4bc..d023aaa3ab 100644 --- a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt +++ b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt @@ -1,10 +1,28 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import workflow.tutorial.views.databinding.WelcomeViewBinding + +/** + * @param promptText error text or other message to show the users + * @param onLogInTapped Log In button event handler + */ data class WelcomeScreen( - /** The current name that has been entered. */ - val username: String, - /** Callback when the name changes in the UI. */ - val onUsernameChanged: (String) -> Unit, - /** Callback when the login button is tapped. */ - val onLoginTapped: () -> Unit -) + val promptText: String, + val onLogInTapped: (String) -> Unit +) : AndroidScreen { + + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +} + +private fun welcomeScreenRunner( + viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, _ -> + viewBinding.prompt.text = screen.promptText + viewBinding.logIn.setOnClickListener { + screen.onLogInTapped(viewBinding.username.text.toString()) + } +} diff --git a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt index c369af398b..7a9b739fe0 100644 --- a/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt +++ b/samples/tutorial/tutorial-1-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt @@ -9,7 +9,7 @@ import workflow.tutorial.WelcomeWorkflow.State object WelcomeWorkflow : StatefulWorkflow() { data class State( - val username: String + val prompt: String ) object Output @@ -17,21 +17,21 @@ object WelcomeWorkflow : StatefulWorkflow() override fun initialState( props: Unit, snapshot: Snapshot? - ): State = State(username = "") + ): State = State(prompt = "") override fun render( renderProps: Unit, renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.username, - onUsernameChanged = { context.actionSink.send(onUsernameChanged(it)) }, - onLoginTapped = {} + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + state = when { + name.isEmpty() -> state.copy(prompt = "name required to log in") + else -> state.copy(prompt = "logging in as \"$name\"…") + } + } ) - private fun onUsernameChanged(username: String) = action("onUsernameChanged") { - state = state.copy(username = username) - } - override fun snapshotState(state: State): Snapshot? = null } diff --git a/samples/tutorial/tutorial-2-complete/build.gradle b/samples/tutorial/tutorial-2-complete/build.gradle index 14bf2225f0..990c52c6a6 100644 --- a/samples/tutorial/tutorial-2-complete/build.gradle +++ b/samples/tutorial/tutorial-2-complete/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "workflow.tutorial" - minSdk = 21 + minSdk = 24 targetSdk = 33 versionCode 1 versionName "1.0" @@ -31,7 +31,6 @@ dependencies { implementation deps.appcompat implementation deps.kotlin.stdlib implementation deps.material - implementation deps.workflow.container_android implementation deps.viewmodelktx implementation deps.viewmodelsavedstate implementation deps.material diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt new file mode 100644 index 0000000000..4e9127f32f --- /dev/null +++ b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt @@ -0,0 +1,65 @@ +package workflow.tutorial + +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.action +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import workflow.tutorial.RootNavigationWorkflow.State +import workflow.tutorial.RootNavigationWorkflow.State.ShowingTodo +import workflow.tutorial.RootNavigationWorkflow.State.ShowingWelcome +import workflow.tutorial.TodoListWorkflow.ListProps + +object RootNavigationWorkflow : StatefulWorkflow>() { + + sealed interface State { + object ShowingWelcome : State + data class ShowingTodo(val username: String) : State + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = ShowingWelcome + + override fun render( + renderProps: Unit, + renderState: State, + context: RenderContext + ): BackStackScreen<*> { + // We always render the welcomeScreen regardless of the current state. + // It's either showing or else we may want to pop back to it. + val welcomeScreen = context.renderChild(WelcomeWorkflow) { loggedIn -> + // When WelcomeWorkflow emits LoggedIn, enqueue our log in action. + logIn(loggedIn.username) + } + + return when (renderState) { + is ShowingWelcome -> { + BackStackScreen(welcomeScreen) + } + + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoListWorkflow, + props = ListProps(renderState.username), + handler = { + // When TodoNavigationWorkflow emits Back, enqueue our log out action. + logOut + } + ) + listOf(welcomeScreen, todoBackStack).toBackStackScreen() + } + } + } + override fun snapshotState(state: State): Snapshot? = null + + private fun logIn(username: String) = action("logIn") { + state = ShowingTodo(username) + } + + private val logOut = action("logOut") { + state = ShowingWelcome + } +} diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/RootWorkflow.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/RootWorkflow.kt deleted file mode 100644 index 12a997be77..0000000000 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/RootWorkflow.kt +++ /dev/null @@ -1,75 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackScreen -import com.squareup.workflow1.ui.backstack.toBackStackScreen -import workflow.tutorial.RootWorkflow.State -import workflow.tutorial.RootWorkflow.State.Todo -import workflow.tutorial.RootWorkflow.State.Welcome -import workflow.tutorial.TodoListWorkflow.ListProps - -@OptIn(WorkflowUiExperimentalApi::class) -object RootWorkflow : StatefulWorkflow>() { - - sealed class State { - object Welcome : State() - data class Todo(val username: String) : State() - } - - override fun initialState( - props: Unit, - snapshot: Snapshot? - ): State = Welcome - - @OptIn(WorkflowUiExperimentalApi::class) - override fun render( - renderProps: Unit, - renderState: State, - context: RenderContext - ): BackStackScreen { - - // Our list of back stack items. Will always include the "WelcomeScreen". - val backstackScreens = mutableListOf() - - // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will create a child workflow with state if one is not already running. - val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> - // When WelcomeWorkflow emits LoggedIn, turn it into our login action. - login(output.username) - } - backstackScreens += welcomeScreen - - when (renderState) { - // When the state is Welcome, defer to the WelcomeWorkflow. - is Welcome -> { - // We always add the welcome screen to the backstack, so this is a no op. - } - - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - backstackScreens += context.renderChild( - TodoListWorkflow, ListProps(username = renderState.username) - ) { - logout - } - } - } - - // Finally, return the BackStackScreen with a list of BackStackScreen.Items - return backstackScreens.toBackStackScreen() - } - - override fun snapshotState(state: State): Snapshot? = null - - private fun login(username: String) = action("login") { - state = Todo(username) - } - - private val logout = action("logout") { - state = Welcome - } -} diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt deleted file mode 100644 index aacab8326b..0000000000 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt +++ /dev/null @@ -1,43 +0,0 @@ -package workflow.tutorial - -import androidx.recyclerview.widget.LinearLayoutManager -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import workflow.tutorial.views.TodoListAdapter -import workflow.tutorial.views.databinding.TodoListViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoListLayoutRunner( - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { - - private val adapter = TodoListAdapter() - - init { - todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) - todoListBinding.todoList.adapter = adapter - } - - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { - todoListBinding.root.backPressedHandler = rendering.onBack - - with(todoListBinding.todoListWelcome) { - text = - resources.getString(workflow.tutorial.views.R.string.todo_list_welcome, rendering.username) - } - - adapter.todoList = rendering.todoTitles - adapter.notifyDataSetChanged() - } - - companion object : ViewFactory by bind( - TodoListViewBinding::inflate, ::TodoListLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListScreen.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListScreen.kt index a31675a342..02f9b1773a 100644 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListScreen.kt +++ b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListScreen.kt @@ -1,14 +1,41 @@ package workflow.tutorial -/** - * This should contain all data to display in the UI. - * - * It should also contain callbacks for any UI events, for example: - * `val onButtonTapped: () -> Unit`. - */ +import androidx.recyclerview.widget.LinearLayoutManager +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.R +import workflow.tutorial.views.TodoListAdapter +import workflow.tutorial.views.databinding.TodoListViewBinding + data class TodoListScreen( val username: String, val todoTitles: List, - val onTodoSelected: (Int) -> Unit, - val onBack: () -> Unit -) + val onBackPressed: () -> Unit, +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoListViewBinding::inflate, ::todoListScreenRunner) +} + +private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding +): ScreenViewRunner { + // This outer scope is run only once, right after the view is inflated. + val adapter = TodoListAdapter() + + todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) + todoListBinding.todoList.adapter = adapter + + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + todoListBinding.root.setBackHandler(screen.onBackPressed) + + with(todoListBinding.todoListWelcome) { + text = resources.getString(R.string.todo_list_welcome, screen.username) + } + + adapter.todoList = screen.todoTitles + adapter.notifyDataSetChanged() + } +} diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt index 4ea295fce7..64c847dee4 100644 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt +++ b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt @@ -3,11 +3,11 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action -import workflow.tutorial.TodoListWorkflow.Back +import workflow.tutorial.TodoListWorkflow.BackPressed import workflow.tutorial.TodoListWorkflow.ListProps import workflow.tutorial.TodoListWorkflow.State -object TodoListWorkflow : StatefulWorkflow() { +object TodoListWorkflow : StatefulWorkflow() { data class ListProps(val username: String) @@ -20,19 +20,19 @@ object TodoListWorkflow : StatefulWorkflow ) - object Back + object BackPressed override fun initialState( props: ListProps, snapshot: Snapshot? ) = State( - listOf( - TodoModel( - title = "Take the cat for a walk", - note = "Cats really need their outside sunshine time. Don't forget to walk " + - "Charlie. Hamilton is less excited about the prospect." - ) + listOf( + TodoModel( + title = "Take the cat for a walk", + note = "Cats really need their outside sunshine time. Don't forget to walk " + + "Charlie. Hamilton is less excited about the prospect." ) + ) ) override fun render( @@ -44,14 +44,9 @@ object TodoListWorkflow : StatefulWorkflow by lazy { - renderWorkflowIn( - workflow = RootWorkflow, - scope = viewModelScope, - savedStateHandle = savedState - ) + class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { + + // We opt in to WorkflowExperimentalRuntime in order turn on all the + // optimizations controlled by the runtimeConfig. + // + // They are in production use at Square, will not be listed as + // experimental much longer, and will soon be enabled by default. + // In the meantime it is much easier to use them from the start + // than to turn them on down the road. + @OptIn(WorkflowExperimentalRuntime::class) + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = RootNavigationWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = RuntimeConfigOptions.ALL + ).reportNavigation { + Log.i("navigate", it.toString()) + } + } } } diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt deleted file mode 100644 index ed78222ba5..0000000000 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt +++ /dev/null @@ -1,38 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.WelcomeViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val welcomeBinding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - // updateText and setTextChangedListener are helpers provided by the workflow library that take - // care of the complexity of correctly interacting with EditTexts in a declarative manner. - welcomeBinding.username.updateText(rendering.username) - welcomeBinding.username.setTextChangedListener { - rendering.onNameChanged(it.toString()) - } - welcomeBinding.login.setOnClickListener { rendering.onLoginTapped() } - } - - /** - * Define a [ViewFactory] that will inflate an instance of [WelcomeViewBinding] and an instance - * of [WelcomeLayoutRunner] when asked, then wire them up so that [showRendering] will be called - * whenever the workflow emits a new [WelcomeScreen]. - */ - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt index e1a011fe23..d023aaa3ab 100644 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt +++ b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt @@ -1,10 +1,28 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import workflow.tutorial.views.databinding.WelcomeViewBinding + +/** + * @param promptText error text or other message to show the users + * @param onLogInTapped Log In button event handler + */ data class WelcomeScreen( - /** The current name that has been entered. */ - val username: String, - /** Callback when the name changes in the UI. */ - val onNameChanged: (String) -> Unit, - /** Callback when the login button is tapped. */ - val onLoginTapped: () -> Unit -) + val promptText: String, + val onLogInTapped: (String) -> Unit +) : AndroidScreen { + + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +} + +private fun welcomeScreenRunner( + viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, _ -> + viewBinding.prompt.text = screen.promptText + viewBinding.logIn.setOnClickListener { + screen.onLogInTapped(viewBinding.username.text.toString()) + } +} diff --git a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt index 72fae5658d..0b391eb996 100644 --- a/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt +++ b/samples/tutorial/tutorial-2-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt @@ -2,14 +2,13 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action import workflow.tutorial.WelcomeWorkflow.LoggedIn import workflow.tutorial.WelcomeWorkflow.State object WelcomeWorkflow : StatefulWorkflow() { data class State( - val username: String + val prompt: String ) data class LoggedIn(val username: String) @@ -17,28 +16,22 @@ object WelcomeWorkflow : StatefulWorkflow( override fun initialState( props: Unit, snapshot: Snapshot? - ): State = State(username = "") + ): State = State(prompt = "") override fun render( renderProps: Unit, renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.username, - onNameChanged = { context.actionSink.send(onUsernameChanged(it)) }, - onLoginTapped = { - // Whenever the login button is tapped, emit the onLogin action. - context.actionSink.send(onLogin()) + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + if (name.isEmpty()) { + state = state.copy(prompt = "name required to log in") + } else { + setOutput(LoggedIn(name)) } + } ) - private fun onUsernameChanged(username: String) = action("onUsernameChanged") { - state = state.copy(username = username) - } - - private fun onLogin() = action("onLogin") { - setOutput(LoggedIn(state.username)) - } - override fun snapshotState(state: State): Snapshot? = null } diff --git a/samples/tutorial/tutorial-3-complete/build.gradle b/samples/tutorial/tutorial-3-complete/build.gradle index 14bf2225f0..990c52c6a6 100644 --- a/samples/tutorial/tutorial-3-complete/build.gradle +++ b/samples/tutorial/tutorial-3-complete/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "workflow.tutorial" - minSdk = 21 + minSdk = 24 targetSdk = 33 versionCode 1 versionName "1.0" @@ -31,7 +31,6 @@ dependencies { implementation deps.appcompat implementation deps.kotlin.stdlib implementation deps.material - implementation deps.workflow.container_android implementation deps.viewmodelktx implementation deps.viewmodelsavedstate implementation deps.material diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt new file mode 100644 index 0000000000..57a59d139f --- /dev/null +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt @@ -0,0 +1,65 @@ +package workflow.tutorial + +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.action +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import workflow.tutorial.RootNavigationWorkflow.State +import workflow.tutorial.RootNavigationWorkflow.State.ShowingTodo +import workflow.tutorial.RootNavigationWorkflow.State.ShowingWelcome +import workflow.tutorial.TodoListWorkflow.ListProps + +object RootNavigationWorkflow : StatefulWorkflow>() { + + sealed interface State { + object ShowingWelcome : State + data class ShowingTodo(val username: String) : State + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = ShowingWelcome + + override fun render( + renderProps: Unit, + renderState: State, + context: RenderContext + ): BackStackScreen<*> { + // We always render the welcomeScreen regardless of the current state. + // It's either showing or else we may want to pop back to it. + val welcomeScreen = context.renderChild(WelcomeWorkflow) { loggedIn -> + // When WelcomeWorkflow emits LoggedIn, enqueue our log in action. + logIn(loggedIn.username) + } + + return when (renderState) { + is ShowingWelcome -> { + BackStackScreen(welcomeScreen) + } + + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoListWorkflow, + props = ListProps(renderState.username) + ) { + // When TodoListWorkflow emits Back, enqueue our log out action. + logOut + } + (listOf(welcomeScreen) + todoBackStack).toBackStackScreen() + } + } + } + + override fun snapshotState(state: State): Snapshot? = null + + private fun logIn(username: String) = action("logIn") { + state = ShowingTodo(username) + } + + private val logOut = action("logOut") { + state = ShowingWelcome + } +} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/RootWorkflow.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/RootWorkflow.kt deleted file mode 100644 index 99b8755959..0000000000 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/RootWorkflow.kt +++ /dev/null @@ -1,72 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackScreen -import com.squareup.workflow1.ui.backstack.toBackStackScreen -import workflow.tutorial.RootWorkflow.State -import workflow.tutorial.RootWorkflow.State.Todo -import workflow.tutorial.RootWorkflow.State.Welcome -import workflow.tutorial.TodoListWorkflow.ListProps - -@OptIn(WorkflowUiExperimentalApi::class) -object RootWorkflow : StatefulWorkflow>() { - - sealed class State { - object Welcome : State() - data class Todo(val username: String) : State() - } - - override fun initialState( - props: Unit, - snapshot: Snapshot? - ): State = Welcome - - override fun render( - renderProps: Unit, - renderState: State, - context: RenderContext - ): BackStackScreen { - // Our list of back stack items. Will always include the "WelcomeScreen". - val backstackScreens = mutableListOf() - - // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will create a child workflow with state if one is not already running. - val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> - // When WelcomeWorkflow emits LoggedIn, turn it into our login action. - login(output.username) - } - backstackScreens += welcomeScreen - - when (renderState) { - // When the state is Welcome, defer to the WelcomeWorkflow. - is Welcome -> { - // We always add the welcome screen to the backstack, so this is a no op. - } - - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoListScreens = context.renderChild(TodoListWorkflow, ListProps(renderState.username)) { - logout - } - backstackScreens.addAll(todoListScreens) - } - } - - // Finally, return the BackStackScreen with a list of BackStackScreen.Items - return backstackScreens.toBackStackScreen() - } - - override fun snapshotState(state: State): Snapshot? = null - - private fun login(username: String) = action("login") { - state = Todo(username) - } - - private val logout = action("logout") { - state = Welcome - } -} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt deleted file mode 100644 index ef67bddd96..0000000000 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt +++ /dev/null @@ -1,33 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.TodoEditViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoEditLayoutRunner( - private val binding: TodoEditViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: TodoEditScreen, - viewEnvironment: ViewEnvironment - ) { - binding.root.backPressedHandler = rendering.discardChanges - binding.save.setOnClickListener { rendering.saveChanges() } - binding.todoTitle.updateText(rendering.title) - binding.todoTitle.setTextChangedListener { rendering.onTitleChanged(it.toString()) } - binding.todoNote.updateText(rendering.note) - binding.todoNote.setTextChangedListener { rendering.onNoteChanged(it.toString()) } - } - - companion object : ViewFactory by bind( - TodoEditViewBinding::inflate, ::TodoEditLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt index b64bb8798d..ea368326de 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt @@ -1,15 +1,32 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.TextController +import com.squareup.workflow1.ui.control +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.databinding.TodoEditViewBinding + data class TodoEditScreen( /** The title of this todo item. */ - val title: String, + val title: TextController, /** The contents, or "note" of the todo. */ - val note: String, + val note: TextController, + + val onBackPressed: () -> Unit, + val onSavePressed: () -> Unit +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoEditViewBinding::inflate, ::todoEditScreenRunner) +} - /** Callbacks for when the title or note changes. */ - val onTitleChanged: (String) -> Unit, - val onNoteChanged: (String) -> Unit, +private fun todoEditScreenRunner( + binding: TodoEditViewBinding +) = ScreenViewRunner { screen: TodoEditScreen, _ -> + binding.root.setBackHandler(screen.onBackPressed) + binding.save.setOnClickListener { screen.onSavePressed() } - val discardChanges: () -> Unit, - val saveChanges: () -> Unit -) + screen.title.control(binding.todoTitle) + screen.note.control(binding.todoNote) +} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt index d5f36fade0..8839d14b23 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt @@ -2,85 +2,65 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action +import com.squareup.workflow1.ui.TextController import workflow.tutorial.TodoEditWorkflow.Output -import workflow.tutorial.TodoEditWorkflow.Output.Discard -import workflow.tutorial.TodoEditWorkflow.Output.Save +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges import workflow.tutorial.TodoEditWorkflow.EditProps import workflow.tutorial.TodoEditWorkflow.State +import workflow.tutorial.TodoListWorkflow.TodoModel object TodoEditWorkflow : StatefulWorkflow() { + /** @param initialTodo The model passed from our parent to be edited. */ data class EditProps( - /** The "Todo" passed from our parent. */ val initialTodo: TodoModel ) + /** + * In-flight edits to be applied to the [TodoModel] originally provided + * by the parent workflow. + */ data class State( - /** The workflow's copy of the Todo item. Changes are local to this workflow. */ - val todo: TodoModel - ) + val editedTitle: TextController, + val editedNote: TextController + ) { + /** Transform this edited [State] back to a [TodoModel]. */ + fun toModel(): TodoModel = TodoModel(editedTitle.textValue, editedNote.textValue) - sealed class Output { - object Discard : Output() - data class Save(val todo: TodoModel) : Output() + companion object { + /** Create a [State] suitable for editing the given [model]. */ + fun forModel(model: TodoModel): State = State( + editedTitle = TextController(model.title), + editedNote = TextController(model.note) + ) + } + } + + sealed interface Output { + object DiscardChanges : Output + data class SaveChanges(val todo: TodoModel) : Output } override fun initialState( props: EditProps, snapshot: Snapshot? - ): State = State(props.initialTodo) - - override fun onPropsChanged( - old: EditProps, - new: EditProps, - state: State - ): State { - // The `Todo` from our parent changed. Update our internal copy so we are starting from the same - // item. The "correct" behavior depends on the business logic - would we only want to update if - // the users hasn't changed the todo from the initial one? Or is it ok to delete whatever edits - // were in progress if the state from the parent changes? - if (old.initialTodo != new.initialTodo) { - return state.copy(todo = new.initialTodo) - } - return state - } + ): State = State.forModel(props.initialTodo) override fun render( renderProps: EditProps, renderState: State, context: RenderContext - ): TodoEditScreen { - return TodoEditScreen( - title = renderState.todo.title, - note = renderState.todo.note, - onTitleChanged = { context.actionSink.send(onTitleChanged(it)) }, - onNoteChanged = { context.actionSink.send(onNoteChanged(it)) }, - saveChanges = { context.actionSink.send(onSave()) }, - discardChanges = { context.actionSink.send(onDiscard()) } - ) - } + ): TodoEditScreen = TodoEditScreen( + title = renderState.editedTitle, + note = renderState.editedNote, + onSavePressed = context.eventHandler("onSavePressed") { + setOutput(SaveChanges(state.toModel())) + }, + onBackPressed = context.eventHandler("onBackPressed") { + setOutput(DiscardChanges) + } + ) override fun snapshotState(state: State): Snapshot? = null - - private fun onTitleChanged(title: String) = action("onTitleChanged") { - state = state.withTitle(title) - } - - private fun onNoteChanged(note: String) = action("onNoteChanged") { - state = state.withNote(note) - } - - private fun onDiscard() = action("onDiscard") { - // Emit the Discard output when the discard action is received. - setOutput(Discard) - } - - private fun onSave() = action("onSave") { - // Emit the Save output with the current todo state when the save action is received. - setOutput(Save(state.todo)) - } - - private fun State.withTitle(title: String) = copy(todo = todo.copy(title = title)) - private fun State.withNote(note: String) = copy(todo = todo.copy(note = note)) } diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt deleted file mode 100644 index 8b6405c04e..0000000000 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt +++ /dev/null @@ -1,44 +0,0 @@ -package workflow.tutorial - -import androidx.recyclerview.widget.LinearLayoutManager -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import workflow.tutorial.views.TodoListAdapter -import workflow.tutorial.views.databinding.TodoListViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoListLayoutRunner( - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { - - private val adapter = TodoListAdapter() - - init { - todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) - todoListBinding.todoList.adapter = adapter - } - - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { - todoListBinding.root.backPressedHandler = rendering.onBack - - with(todoListBinding.todoListWelcome) { - text = - resources.getString(workflow.tutorial.views.R.string.todo_list_welcome, rendering.username) - } - - adapter.todoList = rendering.todoTitles - adapter.onTodoSelected = rendering.onTodoSelected - adapter.notifyDataSetChanged() - } - - companion object : ViewFactory by bind( - TodoListViewBinding::inflate, ::TodoListLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListScreen.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListScreen.kt index a31675a342..ad4a734cc6 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListScreen.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListScreen.kt @@ -1,14 +1,43 @@ package workflow.tutorial -/** - * This should contain all data to display in the UI. - * - * It should also contain callbacks for any UI events, for example: - * `val onButtonTapped: () -> Unit`. - */ +import androidx.recyclerview.widget.LinearLayoutManager +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.R +import workflow.tutorial.views.TodoListAdapter +import workflow.tutorial.views.databinding.TodoListViewBinding + data class TodoListScreen( val username: String, val todoTitles: List, - val onTodoSelected: (Int) -> Unit, - val onBack: () -> Unit -) + val onRowPressed: (Int) -> Unit, + val onBackPressed: () -> Unit, +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoListViewBinding::inflate, ::todoListScreenRunner) +} + +private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding +): ScreenViewRunner { + // This outer scope is run only once, right after the view is inflated. + val adapter = TodoListAdapter() + + todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) + todoListBinding.todoList.adapter = adapter + + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + todoListBinding.root.setBackHandler(screen.onBackPressed) + + with(todoListBinding.todoListWelcome) { + text = resources.getString(R.string.todo_list_welcome, screen.username) + } + + adapter.todoList = screen.todoTitles + adapter.onTodoSelected = screen.onRowPressed + adapter.notifyDataSetChanged() + } +} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt index ccd03c788a..e6ae4d7275 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt @@ -3,111 +3,110 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import workflow.tutorial.TodoEditWorkflow.Output.Discard -import workflow.tutorial.TodoEditWorkflow.Output.Save -import workflow.tutorial.TodoEditWorkflow.EditProps -import workflow.tutorial.TodoListWorkflow.Back +import com.squareup.workflow1.ui.Screen +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges +import workflow.tutorial.TodoListWorkflow.BackPressed import workflow.tutorial.TodoListWorkflow.ListProps import workflow.tutorial.TodoListWorkflow.State import workflow.tutorial.TodoListWorkflow.State.Step -@OptIn(WorkflowUiExperimentalApi::class) -object TodoListWorkflow : StatefulWorkflow>() { +object TodoListWorkflow : StatefulWorkflow>() { data class ListProps(val username: String) + data class TodoModel( + val title: String, + val note: String + ) + data class State( val todos: List, val step: Step ) { - sealed class Step { + sealed interface Step { /** Showing the list of items. */ - object List : Step() + object ShowList : Step /** - * Editing a single item. The state holds the index so it can be updated when a save action is - * received. + * Editing a single item. The state holds the index + * so it can be updated when a save action is received. */ - data class Edit(val index: Int) : Step() + data class EditItem(val index: Int) : Step } } - object Back + object BackPressed override fun initialState( props: ListProps, snapshot: Snapshot? ) = State( - todos = listOf( - TodoModel( - title = "Take the cat for a walk", - note = "Cats really need their outside sunshine time. Don't forget to walk " + - "Charlie. Hamilton is less excited about the prospect." - ) - ), - step = Step.List + todos = listOf( + TodoModel( + title = "Take the cat for a walk", + note = "Cats really need their outside sunshine time. Don't forget to walk " + + "Charlie. Hamilton is less excited about the prospect." + ) + ), + step = Step.ShowList ) override fun render( renderProps: ListProps, renderState: State, context: RenderContext - ): List { + ): List { val titles = renderState.todos.map { it.title } val todoListScreen = TodoListScreen( - username = renderProps.username, - todoTitles = titles, - onTodoSelected = { context.actionSink.send(selectTodo(it)) }, - onBack = { context.actionSink.send(onBack()) } + username = renderProps.username, + todoTitles = titles, + onBackPressed = context.eventHandler("onBackPressed") { setOutput(BackPressed) }, + onRowPressed = context.eventHandler("onRowPressed") { index -> + // When a todo item is selected, edit it. + state = state.copy(step = Step.EditItem(index)) + } ) return when (val step = renderState.step) { // On the "list" step, return just the list screen. - Step.List -> listOf(todoListScreen) - is Step.Edit -> { - // On the "edit" step, return both the list and edit screens. + Step.ShowList -> listOf(todoListScreen) + + // On the "edit" step, return both the list and edit screens. + is Step.EditItem -> { val todoEditScreen = context.renderChild( - TodoEditWorkflow, - props = EditProps(renderState.todos[step.index]) + TodoEditWorkflow, + props = TodoEditWorkflow.EditProps(renderState.todos[step.index]) ) { output -> when (output) { // Send the discardChanges action when the discard output is received. - Discard -> discardChanges() + DiscardChanges -> discardChanges() + // Send the saveChanges action when the save output is received. - is Save -> saveChanges(output.todo, step.index) + is SaveChanges -> saveChanges(output.todo, step.index) } } - return listOf(todoListScreen, todoEditScreen) + + listOf(todoListScreen, todoEditScreen) } } } override fun snapshotState(state: State): Snapshot? = null - private fun onBack() = action("onBack") { - // When an onBack action is received, emit a Back output. - setOutput(Back) - } - - private fun selectTodo(index: Int) = action("selectTodo") { - // When a todo item is selected, edit it. - state = state.copy(step = Step.Edit(index)) - } - private fun discardChanges() = action("discardChanges") { - // When a discard action is received, return to the list. - state = state.copy(step = Step.List) + // Discard changes by simply returning to the list. + state = state.copy(step = Step.ShowList) } private fun saveChanges( todo: TodoModel, index: Int ) = action("saveChanges") { - // When changes are saved, update the state of that todo item and return to the list. + // To save changes update the state of the item at index and return to the list. state = state.copy( - todos = state.todos.toMutableList().also { it[index] = todo }, - step = Step.List + todos = state.todos.toMutableList().also { it[index] = todo }, + step = Step.ShowList ) } } diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoModel.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoModel.kt deleted file mode 100644 index d9eb22fef2..0000000000 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TodoModel.kt +++ /dev/null @@ -1,6 +0,0 @@ -package workflow.tutorial - -data class TodoModel( - val title: String, - val note: String -) diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TutorialActivity.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TutorialActivity.kt index 9aa52a2e1e..5f3ba89874 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TutorialActivity.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/TutorialActivity.kt @@ -1,48 +1,57 @@ -@file:OptIn(WorkflowUiExperimentalApi::class) - package workflow.tutorial import android.os.Bundle +import android.util.Log import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackContainer +import com.squareup.workflow1.ui.navigation.reportNavigation import com.squareup.workflow1.ui.renderWorkflowIn -import kotlinx.coroutines.flow.StateFlow - -private val viewRegistry = ViewRegistry( - BackStackContainer, - WelcomeLayoutRunner, - TodoListLayoutRunner, - TodoEditLayoutRunner -) +import kotlinx.coroutines.flow.Flow class TutorialActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Use an AndroidX ViewModel to start and host an instance of the workflow runtime that runs - // the WelcomeWorkflow and sets the activity's content view using our view factories. + // We use an AndroidX ViewModel and its CoroutineScope to start and host + // an instance of the workflow runtime that runs the WelcomeWorkflow. + // This ensures that our runtime will survive as new `Activity` instances + // are created for configuration changes. val model: TutorialViewModel by viewModels() setContentView( - WorkflowLayout(this).apply { start(model.renderings, viewRegistry) } + WorkflowLayout(this).apply { + take(lifecycle, model.renderings) + } ) } -} -class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { - val renderings: StateFlow by lazy { - renderWorkflowIn( - workflow = RootWorkflow, - scope = viewModelScope, - savedStateHandle = savedState - ) + class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { + + // We opt in to WorkflowExperimentalRuntime in order turn on all the + // optimizations controlled by the runtimeConfig. + // + // They are in production use at Square, will not be listed as + // experimental much longer, and will soon be enabled by default. + // In the meantime it is much easier to use them from the start + // than to turn them on down the road. + @OptIn(WorkflowExperimentalRuntime::class) + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = RootNavigationWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = RuntimeConfigOptions.ALL + ).reportNavigation { + Log.i("navigate", it.toString()) + } + } } } diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt deleted file mode 100644 index ed78222ba5..0000000000 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt +++ /dev/null @@ -1,38 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.WelcomeViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val welcomeBinding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - // updateText and setTextChangedListener are helpers provided by the workflow library that take - // care of the complexity of correctly interacting with EditTexts in a declarative manner. - welcomeBinding.username.updateText(rendering.username) - welcomeBinding.username.setTextChangedListener { - rendering.onNameChanged(it.toString()) - } - welcomeBinding.login.setOnClickListener { rendering.onLoginTapped() } - } - - /** - * Define a [ViewFactory] that will inflate an instance of [WelcomeViewBinding] and an instance - * of [WelcomeLayoutRunner] when asked, then wire them up so that [showRendering] will be called - * whenever the workflow emits a new [WelcomeScreen]. - */ - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt index e1a011fe23..d023aaa3ab 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt @@ -1,10 +1,28 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import workflow.tutorial.views.databinding.WelcomeViewBinding + +/** + * @param promptText error text or other message to show the users + * @param onLogInTapped Log In button event handler + */ data class WelcomeScreen( - /** The current name that has been entered. */ - val username: String, - /** Callback when the name changes in the UI. */ - val onNameChanged: (String) -> Unit, - /** Callback when the login button is tapped. */ - val onLoginTapped: () -> Unit -) + val promptText: String, + val onLogInTapped: (String) -> Unit +) : AndroidScreen { + + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +} + +private fun welcomeScreenRunner( + viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, _ -> + viewBinding.prompt.text = screen.promptText + viewBinding.logIn.setOnClickListener { + screen.onLogInTapped(viewBinding.username.text.toString()) + } +} diff --git a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt index a787b12629..0b391eb996 100644 --- a/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt +++ b/samples/tutorial/tutorial-3-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt @@ -2,14 +2,13 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action import workflow.tutorial.WelcomeWorkflow.LoggedIn import workflow.tutorial.WelcomeWorkflow.State object WelcomeWorkflow : StatefulWorkflow() { data class State( - val name: String + val prompt: String ) data class LoggedIn(val username: String) @@ -17,28 +16,22 @@ object WelcomeWorkflow : StatefulWorkflow( override fun initialState( props: Unit, snapshot: Snapshot? - ): State = State(name = "") + ): State = State(prompt = "") override fun render( renderProps: Unit, renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.name, - onNameChanged = { context.actionSink.send(onNameChanged(it)) }, - onLoginTapped = { - // Whenever the login button is tapped, emit the onLogin action. - context.actionSink.send(onLogin()) + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + if (name.isEmpty()) { + state = state.copy(prompt = "name required to log in") + } else { + setOutput(LoggedIn(name)) } + } ) - private fun onNameChanged(name: String) = action("onNameChanged") { - state = state.copy(name = name) - } - - private fun onLogin() = action("onLogin") { - setOutput(LoggedIn(state.name)) - } - override fun snapshotState(state: State): Snapshot? = null } diff --git a/samples/tutorial/tutorial-4-complete/build.gradle b/samples/tutorial/tutorial-4-complete/build.gradle index 14bf2225f0..990c52c6a6 100644 --- a/samples/tutorial/tutorial-4-complete/build.gradle +++ b/samples/tutorial/tutorial-4-complete/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "workflow.tutorial" - minSdk = 21 + minSdk = 24 targetSdk = 33 versionCode 1 versionName "1.0" @@ -31,7 +31,6 @@ dependencies { implementation deps.appcompat implementation deps.kotlin.stdlib implementation deps.material - implementation deps.workflow.container_android implementation deps.viewmodelktx implementation deps.viewmodelsavedstate implementation deps.material diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt new file mode 100644 index 0000000000..217ffc8280 --- /dev/null +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt @@ -0,0 +1,66 @@ +package workflow.tutorial + +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.action +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import workflow.tutorial.RootNavigationWorkflow.State +import workflow.tutorial.RootNavigationWorkflow.State.ShowingTodo +import workflow.tutorial.RootNavigationWorkflow.State.ShowingWelcome +import workflow.tutorial.TodoNavigationWorkflow.TodoProps + +object RootNavigationWorkflow : StatefulWorkflow>() { + + sealed interface State { + object ShowingWelcome : State + data class ShowingTodo(val username: String) : State + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = ShowingWelcome + + override fun render( + renderProps: Unit, + renderState: State, + context: RenderContext + ): BackStackScreen<*> { + // We always render the welcomeScreen regardless of the current state. + // It's either showing or else we may want to pop back to it. + val welcomeScreen = context.renderChild(WelcomeWorkflow) { loggedIn -> + // When WelcomeWorkflow emits LoggedIn, enqueue our log in action. + logIn(loggedIn.username) + } + + return when (renderState) { + is ShowingWelcome -> { + BackStackScreen(welcomeScreen) + } + + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoNavigationWorkflow, + props = TodoProps(renderState.username), + handler = { + // When TodoNavigationWorkflow emits Back, enqueue our log out action. + logOut + } + ) + (listOf(welcomeScreen) + todoBackStack).toBackStackScreen() + } + } + } + + override fun snapshotState(state: State): Snapshot? = null + + private fun logIn(username: String) = action("logIn") { + state = ShowingTodo(username) + } + + private val logOut = action("logOut") { + state = ShowingWelcome + } +} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/RootWorkflow.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/RootWorkflow.kt deleted file mode 100644 index 6020fff728..0000000000 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/RootWorkflow.kt +++ /dev/null @@ -1,75 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackScreen -import com.squareup.workflow1.ui.backstack.toBackStackScreen -import workflow.tutorial.RootWorkflow.State -import workflow.tutorial.RootWorkflow.State.Todo -import workflow.tutorial.RootWorkflow.State.Welcome -import workflow.tutorial.TodoWorkflow.TodoProps - -@OptIn(WorkflowUiExperimentalApi::class) -object RootWorkflow : StatefulWorkflow>() { - - sealed class State { - object Welcome : State() - data class Todo(val name: String) : State() - } - - override fun initialState( - props: Unit, - snapshot: Snapshot? - ): State = Welcome - - @OptIn(WorkflowUiExperimentalApi::class) - override fun render( - renderProps: Unit, - renderState: State, - context: RenderContext - ): BackStackScreen { - - // Our list of back stack items. Will always include the "WelcomeScreen". - val backstackScreens = mutableListOf() - - // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will create a child workflow with state if one is not already running. - val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> - // When WelcomeWorkflow emits LoggedIn, turn it into our login action. - login(output.username) - } - backstackScreens += welcomeScreen - - when (renderState) { - // When the state is Welcome, defer to the WelcomeWorkflow. - is Welcome -> { - // We always add the welcome screen to the backstack, so this is a no op. - } - - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoListScreens = context.renderChild(TodoWorkflow, TodoProps(renderState.name)) { - // When receiving a Back output, treat it as a logout action. - logout - } - backstackScreens.addAll(todoListScreens) - } - } - - // Finally, return the BackStackScreen with a list of BackStackScreen.Items - return backstackScreens.toBackStackScreen() - } - - override fun snapshotState(state: State): Snapshot? = null - - private fun login(name: String) = action("login") { - state = Todo(name) - } - - private val logout = action("logout") { - state = Welcome - } -} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt deleted file mode 100644 index ef67bddd96..0000000000 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt +++ /dev/null @@ -1,33 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.TodoEditViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoEditLayoutRunner( - private val binding: TodoEditViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: TodoEditScreen, - viewEnvironment: ViewEnvironment - ) { - binding.root.backPressedHandler = rendering.discardChanges - binding.save.setOnClickListener { rendering.saveChanges() } - binding.todoTitle.updateText(rendering.title) - binding.todoTitle.setTextChangedListener { rendering.onTitleChanged(it.toString()) } - binding.todoNote.updateText(rendering.note) - binding.todoNote.setTextChangedListener { rendering.onNoteChanged(it.toString()) } - } - - companion object : ViewFactory by bind( - TodoEditViewBinding::inflate, ::TodoEditLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt index b64bb8798d..ea368326de 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditScreen.kt @@ -1,15 +1,32 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.TextController +import com.squareup.workflow1.ui.control +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.databinding.TodoEditViewBinding + data class TodoEditScreen( /** The title of this todo item. */ - val title: String, + val title: TextController, /** The contents, or "note" of the todo. */ - val note: String, + val note: TextController, + + val onBackPressed: () -> Unit, + val onSavePressed: () -> Unit +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoEditViewBinding::inflate, ::todoEditScreenRunner) +} - /** Callbacks for when the title or note changes. */ - val onTitleChanged: (String) -> Unit, - val onNoteChanged: (String) -> Unit, +private fun todoEditScreenRunner( + binding: TodoEditViewBinding +) = ScreenViewRunner { screen: TodoEditScreen, _ -> + binding.root.setBackHandler(screen.onBackPressed) + binding.save.setOnClickListener { screen.onSavePressed() } - val discardChanges: () -> Unit, - val saveChanges: () -> Unit -) + screen.title.control(binding.todoTitle) + screen.note.control(binding.todoNote) +} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt index d5f36fade0..ff908504cb 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoEditWorkflow.kt @@ -2,85 +2,64 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action +import com.squareup.workflow1.ui.TextController import workflow.tutorial.TodoEditWorkflow.Output -import workflow.tutorial.TodoEditWorkflow.Output.Discard -import workflow.tutorial.TodoEditWorkflow.Output.Save +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges import workflow.tutorial.TodoEditWorkflow.EditProps import workflow.tutorial.TodoEditWorkflow.State object TodoEditWorkflow : StatefulWorkflow() { + /** @param initialTodo The model passed from our parent to be edited. */ data class EditProps( - /** The "Todo" passed from our parent. */ val initialTodo: TodoModel ) + /** + * In-flight edits to be applied to the [TodoModel] originally provided + * by the parent workflow. + */ data class State( - /** The workflow's copy of the Todo item. Changes are local to this workflow. */ - val todo: TodoModel - ) + val editedTitle: TextController, + val editedNote: TextController + ) { + /** Transform this edited [State] back to a [TodoModel]. */ + fun toModel(): TodoModel = TodoModel(editedTitle.textValue, editedNote.textValue) - sealed class Output { - object Discard : Output() - data class Save(val todo: TodoModel) : Output() + companion object { + /** Create a [State] suitable for editing the given [model]. */ + fun forModel(model: TodoModel): State = State( + editedTitle = TextController(model.title), + editedNote = TextController(model.note) + ) + } + } + + sealed interface Output { + object DiscardChanges : Output + data class SaveChanges(val todo: TodoModel) : Output } override fun initialState( props: EditProps, snapshot: Snapshot? - ): State = State(props.initialTodo) - - override fun onPropsChanged( - old: EditProps, - new: EditProps, - state: State - ): State { - // The `Todo` from our parent changed. Update our internal copy so we are starting from the same - // item. The "correct" behavior depends on the business logic - would we only want to update if - // the users hasn't changed the todo from the initial one? Or is it ok to delete whatever edits - // were in progress if the state from the parent changes? - if (old.initialTodo != new.initialTodo) { - return state.copy(todo = new.initialTodo) - } - return state - } + ): State = State.forModel(props.initialTodo) override fun render( renderProps: EditProps, renderState: State, context: RenderContext - ): TodoEditScreen { - return TodoEditScreen( - title = renderState.todo.title, - note = renderState.todo.note, - onTitleChanged = { context.actionSink.send(onTitleChanged(it)) }, - onNoteChanged = { context.actionSink.send(onNoteChanged(it)) }, - saveChanges = { context.actionSink.send(onSave()) }, - discardChanges = { context.actionSink.send(onDiscard()) } - ) - } + ): TodoEditScreen = TodoEditScreen( + title = renderState.editedTitle, + note = renderState.editedNote, + onSavePressed = context.eventHandler("onSavePressed") { + setOutput(SaveChanges(state.toModel())) + }, + onBackPressed = context.eventHandler("onBackPressed") { + setOutput(DiscardChanges) + } + ) override fun snapshotState(state: State): Snapshot? = null - - private fun onTitleChanged(title: String) = action("onTitleChanged") { - state = state.withTitle(title) - } - - private fun onNoteChanged(note: String) = action("onNoteChanged") { - state = state.withNote(note) - } - - private fun onDiscard() = action("onDiscard") { - // Emit the Discard output when the discard action is received. - setOutput(Discard) - } - - private fun onSave() = action("onSave") { - // Emit the Save output with the current todo state when the save action is received. - setOutput(Save(state.todo)) - } - - private fun State.withTitle(title: String) = copy(todo = todo.copy(title = title)) - private fun State.withNote(note: String) = copy(todo = todo.copy(note = note)) } diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt deleted file mode 100644 index 8b6405c04e..0000000000 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt +++ /dev/null @@ -1,44 +0,0 @@ -package workflow.tutorial - -import androidx.recyclerview.widget.LinearLayoutManager -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import workflow.tutorial.views.TodoListAdapter -import workflow.tutorial.views.databinding.TodoListViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoListLayoutRunner( - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { - - private val adapter = TodoListAdapter() - - init { - todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) - todoListBinding.todoList.adapter = adapter - } - - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { - todoListBinding.root.backPressedHandler = rendering.onBack - - with(todoListBinding.todoListWelcome) { - text = - resources.getString(workflow.tutorial.views.R.string.todo_list_welcome, rendering.username) - } - - adapter.todoList = rendering.todoTitles - adapter.onTodoSelected = rendering.onTodoSelected - adapter.notifyDataSetChanged() - } - - companion object : ViewFactory by bind( - TodoListViewBinding::inflate, ::TodoListLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListScreen.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListScreen.kt index a31675a342..f075479f8a 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListScreen.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListScreen.kt @@ -1,14 +1,45 @@ package workflow.tutorial -/** - * This should contain all data to display in the UI. - * - * It should also contain callbacks for any UI events, for example: - * `val onButtonTapped: () -> Unit`. - */ +import androidx.recyclerview.widget.LinearLayoutManager +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.R +import workflow.tutorial.views.TodoListAdapter +import workflow.tutorial.views.databinding.TodoListViewBinding + data class TodoListScreen( val username: String, val todoTitles: List, - val onTodoSelected: (Int) -> Unit, - val onBack: () -> Unit -) + val onRowPressed: (Int) -> Unit, + val onBackPressed: () -> Unit, + val onAddPressed: () -> Unit +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoListViewBinding::inflate, ::todoListScreenRunner) +} + +private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding +): ScreenViewRunner { + // This outer scope is run only once, right after the view is inflated. + val adapter = TodoListAdapter() + + todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) + todoListBinding.todoList.adapter = adapter + + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + todoListBinding.root.setBackHandler(screen.onBackPressed) + todoListBinding.add.setOnClickListener { screen.onAddPressed() } + + with(todoListBinding.todoListWelcome) { + text = resources.getString(R.string.todo_list_welcome, screen.username) + } + + adapter.todoList = screen.todoTitles + adapter.onTodoSelected = screen.onRowPressed + adapter.notifyDataSetChanged() + } +} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt index e2fbb6d59d..7495984adc 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoListWorkflow.kt @@ -1,14 +1,12 @@ package workflow.tutorial import com.squareup.workflow1.StatelessWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import workflow.tutorial.TodoListWorkflow.ListProps import workflow.tutorial.TodoListWorkflow.Output -import workflow.tutorial.TodoListWorkflow.Output.Back -import workflow.tutorial.TodoListWorkflow.Output.SelectTodo +import workflow.tutorial.TodoListWorkflow.Output.AddPressed +import workflow.tutorial.TodoListWorkflow.Output.BackPressed +import workflow.tutorial.TodoListWorkflow.Output.TodoSelected -@OptIn(WorkflowUiExperimentalApi::class) object TodoListWorkflow : StatelessWorkflow() { data class ListProps( @@ -16,9 +14,10 @@ object TodoListWorkflow : StatelessWorkflow() val todos: List ) - sealed class Output { - object Back : Output() - data class SelectTodo(val index: Int) : Output() + sealed interface Output { + object BackPressed : Output + data class TodoSelected(val index: Int) : Output + object AddPressed : Output } override fun render( @@ -27,20 +26,14 @@ object TodoListWorkflow : StatelessWorkflow() ): TodoListScreen { val titles = renderProps.todos.map { it.title } return TodoListScreen( - username = renderProps.username, - todoTitles = titles, - onTodoSelected = { context.actionSink.send(selectTodo(it)) }, - onBack = { context.actionSink.send(onBack()) } + username = renderProps.username, + todoTitles = titles, + onBackPressed = context.eventHandler("onBackPressed") { setOutput(BackPressed) }, + onRowPressed = context.eventHandler("onRowPressed") { index -> + // Tell our parent that a todo item was selected. + setOutput(TodoSelected(index)) + }, + onAddPressed = context.eventHandler("onAddPressed") { setOutput(AddPressed) } ) } - - private fun onBack() = action("onBack") { - // When an onBack action is received, emit a Back output. - setOutput(Back) - } - - private fun selectTodo(index: Int) = action("selectTodo") { - // Tell our parent that a todo item was selected. - setOutput(SelectTodo(index)) - } } diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoNavigationWorkflow.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoNavigationWorkflow.kt new file mode 100644 index 0000000000..c225fc78e5 --- /dev/null +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoNavigationWorkflow.kt @@ -0,0 +1,129 @@ +package workflow.tutorial + +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.action +import com.squareup.workflow1.ui.Screen +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges +import workflow.tutorial.TodoEditWorkflow.EditProps +import workflow.tutorial.TodoListWorkflow.ListProps +import workflow.tutorial.TodoListWorkflow.Output +import workflow.tutorial.TodoListWorkflow.Output.AddPressed +import workflow.tutorial.TodoListWorkflow.Output.TodoSelected +import workflow.tutorial.TodoNavigationWorkflow.Back +import workflow.tutorial.TodoNavigationWorkflow.State +import workflow.tutorial.TodoNavigationWorkflow.State.Step +import workflow.tutorial.TodoNavigationWorkflow.TodoProps + +object TodoNavigationWorkflow : StatefulWorkflow>() { + + data class TodoProps(val name: String) + + data class State( + val todos: List, + val step: Step + ) { + sealed interface Step { + /** Showing the list of items. */ + object List : Step + + /** + * Editing a single item. The state holds the index so it can be updated when + * a save action is received. + */ + data class Edit(val index: Int) : Step + } + } + + object Back + + override fun initialState( + props: TodoProps, + snapshot: Snapshot? + ) = State( + todos = listOf( + TodoModel( + title = "Take the cat for a walk", + note = "Cats really need their outside sunshine time. Don't forget to walk " + + "Charlie. Hamilton is less excited about the prospect." + ) + ), + step = Step.List + ) + + override fun render( + renderProps: TodoProps, + renderState: State, + context: RenderContext + ): List { + val todoListScreen = context.renderChild( + TodoListWorkflow, + props = ListProps( + username = renderProps.name, + todos = renderState.todos + ) + ) { output -> + when (output) { + Output.BackPressed -> goBack() + is TodoSelected -> editTodo(output.index) + AddPressed -> createTodo() + } + } + + return when (val step = renderState.step) { + // On the "list" step, return just the list screen. + Step.List -> listOf(todoListScreen) + + is Step.Edit -> { + // On the "edit" step, return both the list and edit screens. + val todoEditScreen = context.renderChild( + TodoEditWorkflow, + EditProps(renderState.todos[step.index]) + ) { output -> + when (output) { + DiscardChanges -> discardChanges() + is SaveChanges -> saveChanges(output.todo, step.index) + } + } + return listOf(todoListScreen, todoEditScreen) + } + } + } + + private fun goBack() = action("goBack") { + setOutput(Back) + } + + private fun editTodo(index: Int) = action("editTodo") { + state = state.copy(step = Step.Edit(index)) + } + + private fun createTodo() = action("createTodo") { + // Append a new todo model to the end of the list. + state = state.copy( + todos = state.todos + TodoModel( + title = "New Todo", + note = "" + ) + ) + } + + private fun discardChanges() = action("discardChanges") { + // To discard edits, just return to the list. + state = state.copy(step = Step.List) + } + + private fun saveChanges( + todo: TodoModel, + index: Int + ) = action("saveChanges") { + // When changes are saved, update the state of that todo item and return to the list. + state = state.copy( + todos = state.todos.toMutableList().also { it[index] = todo }, + step = Step.List + ) + } + + override fun snapshotState(state: State): Snapshot? = null +} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TutorialActivity.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TutorialActivity.kt index 99c67c824c..5f3ba89874 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TutorialActivity.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TutorialActivity.kt @@ -1,48 +1,57 @@ -@file:OptIn(WorkflowUiExperimentalApi::class) - package workflow.tutorial import android.os.Bundle +import android.util.Log import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackContainer +import com.squareup.workflow1.ui.navigation.reportNavigation import com.squareup.workflow1.ui.renderWorkflowIn -import kotlinx.coroutines.flow.StateFlow - -private val viewRegistry = ViewRegistry( - BackStackContainer, - WelcomeLayoutRunner, - TodoListLayoutRunner, - TodoEditLayoutRunner -) +import kotlinx.coroutines.flow.Flow class TutorialActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Use an AndroidX ViewModel to start and host an instance of the workflow runtime that runs - // the WelcomeWorkflow and sets the activity's content view using our view factories. + // We use an AndroidX ViewModel and its CoroutineScope to start and host + // an instance of the workflow runtime that runs the WelcomeWorkflow. + // This ensures that our runtime will survive as new `Activity` instances + // are created for configuration changes. val model: TutorialViewModel by viewModels() setContentView( - WorkflowLayout(this).apply { start(model.renderings, viewRegistry) } + WorkflowLayout(this).apply { + take(lifecycle, model.renderings) + } ) } -} -class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { - val renderings: StateFlow by lazy { - renderWorkflowIn( - workflow = RootWorkflow, - scope = viewModelScope, - savedStateHandle = savedState - ) + class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { + + // We opt in to WorkflowExperimentalRuntime in order turn on all the + // optimizations controlled by the runtimeConfig. + // + // They are in production use at Square, will not be listed as + // experimental much longer, and will soon be enabled by default. + // In the meantime it is much easier to use them from the start + // than to turn them on down the road. + @OptIn(WorkflowExperimentalRuntime::class) + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = RootNavigationWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = RuntimeConfigOptions.ALL + ).reportNavigation { + Log.i("navigate", it.toString()) + } + } } } diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt deleted file mode 100644 index 7eb9af6340..0000000000 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt +++ /dev/null @@ -1,38 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.WelcomeViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val welcomeBinding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - // updateText and setTextChangedListener are helpers provided by the workflow library that take - // care of the complexity of correctly interacting with EditTexts in a declarative manner. - welcomeBinding.username.updateText(rendering.username) - welcomeBinding.username.setTextChangedListener { - rendering.onUsernameChanged(it.toString()) - } - welcomeBinding.login.setOnClickListener { rendering.onLoginTapped() } - } - - /** - * Define a [ViewFactory] that will inflate an instance of [WelcomeViewBinding] and an instance - * of [WelcomeLayoutRunner] when asked, then wire them up so that [showRendering] will be called - * whenever the workflow emits a new [WelcomeScreen]. - */ - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt index af5918e4bc..d023aaa3ab 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeScreen.kt @@ -1,10 +1,28 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import workflow.tutorial.views.databinding.WelcomeViewBinding + +/** + * @param promptText error text or other message to show the users + * @param onLogInTapped Log In button event handler + */ data class WelcomeScreen( - /** The current name that has been entered. */ - val username: String, - /** Callback when the name changes in the UI. */ - val onUsernameChanged: (String) -> Unit, - /** Callback when the login button is tapped. */ - val onLoginTapped: () -> Unit -) + val promptText: String, + val onLogInTapped: (String) -> Unit +) : AndroidScreen { + + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +} + +private fun welcomeScreenRunner( + viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, _ -> + viewBinding.prompt.text = screen.promptText + viewBinding.logIn.setOnClickListener { + screen.onLogInTapped(viewBinding.username.text.toString()) + } +} diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt index 9363d7e0ba..0b391eb996 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt +++ b/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/WelcomeWorkflow.kt @@ -2,14 +2,13 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action import workflow.tutorial.WelcomeWorkflow.LoggedIn import workflow.tutorial.WelcomeWorkflow.State object WelcomeWorkflow : StatefulWorkflow() { data class State( - val username: String + val prompt: String ) data class LoggedIn(val username: String) @@ -17,28 +16,22 @@ object WelcomeWorkflow : StatefulWorkflow( override fun initialState( props: Unit, snapshot: Snapshot? - ): State = State(username = "") + ): State = State(prompt = "") override fun render( renderProps: Unit, renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.username, - onUsernameChanged = { context.actionSink.send(onNameChanged(it)) }, - onLoginTapped = { - // Whenever the login button is tapped, emit the onLogin action. - context.actionSink.send(onLogin()) + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + if (name.isEmpty()) { + state = state.copy(prompt = "name required to log in") + } else { + setOutput(LoggedIn(name)) } + } ) - private fun onNameChanged(name: String) = action("onNameChanged") { - state = state.copy(username = name) - } - - private fun onLogin() = action("onLogin") { - setOutput(LoggedIn(state.username)) - } - override fun snapshotState(state: State): Snapshot? = null } diff --git a/samples/tutorial/tutorial-base/build.gradle b/samples/tutorial/tutorial-base/build.gradle index 839f8de42a..870932f031 100644 --- a/samples/tutorial/tutorial-base/build.gradle +++ b/samples/tutorial/tutorial-base/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "workflow.tutorial" - minSdk = 21 + minSdk = 24 targetSdk = 33 versionCode 1 versionName "1.0" diff --git a/samples/tutorial/tutorial-base/src/main/AndroidManifest.xml b/samples/tutorial/tutorial-base/src/main/AndroidManifest.xml index 3c0e4ce754..eddb348a72 100644 --- a/samples/tutorial/tutorial-base/src/main/AndroidManifest.xml +++ b/samples/tutorial/tutorial-base/src/main/AndroidManifest.xml @@ -2,11 +2,13 @@ + android:theme="@style/Theme.WorkflowTutorial" + > diff --git a/samples/tutorial/tutorial-final/build.gradle b/samples/tutorial/tutorial-final/build.gradle index a6b3793ec0..b724bd8a8c 100644 --- a/samples/tutorial/tutorial-final/build.gradle +++ b/samples/tutorial/tutorial-final/build.gradle @@ -8,7 +8,7 @@ android { defaultConfig { applicationId "workflow.tutorial" - minSdk = 21 + minSdk = 24 targetSdk = 33 versionCode 1 versionName "1.0" @@ -31,7 +31,6 @@ dependencies { implementation deps.appcompat implementation deps.kotlin.stdlib implementation deps.material - implementation deps.workflow.container_android implementation deps.viewmodelktx implementation deps.viewmodelsavedstate implementation deps.material diff --git a/samples/tutorial/tutorial-final/src/main/AndroidManifest.xml b/samples/tutorial/tutorial-final/src/main/AndroidManifest.xml index bfc92c7b0c..eddb348a72 100644 --- a/samples/tutorial/tutorial-final/src/main/AndroidManifest.xml +++ b/samples/tutorial/tutorial-final/src/main/AndroidManifest.xml @@ -7,7 +7,8 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/Theme.WorkflowTutorial"> + android:theme="@style/Theme.WorkflowTutorial" + > diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt new file mode 100644 index 0000000000..217ffc8280 --- /dev/null +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/RootNavigationWorkflow.kt @@ -0,0 +1,66 @@ +package workflow.tutorial + +import com.squareup.workflow1.Snapshot +import com.squareup.workflow1.StatefulWorkflow +import com.squareup.workflow1.action +import com.squareup.workflow1.renderChild +import com.squareup.workflow1.ui.navigation.BackStackScreen +import com.squareup.workflow1.ui.navigation.toBackStackScreen +import workflow.tutorial.RootNavigationWorkflow.State +import workflow.tutorial.RootNavigationWorkflow.State.ShowingTodo +import workflow.tutorial.RootNavigationWorkflow.State.ShowingWelcome +import workflow.tutorial.TodoNavigationWorkflow.TodoProps + +object RootNavigationWorkflow : StatefulWorkflow>() { + + sealed interface State { + object ShowingWelcome : State + data class ShowingTodo(val username: String) : State + } + + override fun initialState( + props: Unit, + snapshot: Snapshot? + ): State = ShowingWelcome + + override fun render( + renderProps: Unit, + renderState: State, + context: RenderContext + ): BackStackScreen<*> { + // We always render the welcomeScreen regardless of the current state. + // It's either showing or else we may want to pop back to it. + val welcomeScreen = context.renderChild(WelcomeWorkflow) { loggedIn -> + // When WelcomeWorkflow emits LoggedIn, enqueue our log in action. + logIn(loggedIn.username) + } + + return when (renderState) { + is ShowingWelcome -> { + BackStackScreen(welcomeScreen) + } + + is ShowingTodo -> { + val todoBackStack = context.renderChild( + child = TodoNavigationWorkflow, + props = TodoProps(renderState.username), + handler = { + // When TodoNavigationWorkflow emits Back, enqueue our log out action. + logOut + } + ) + (listOf(welcomeScreen) + todoBackStack).toBackStackScreen() + } + } + } + + override fun snapshotState(state: State): Snapshot? = null + + private fun logIn(username: String) = action("logIn") { + state = ShowingTodo(username) + } + + private val logOut = action("logOut") { + state = ShowingWelcome + } +} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/RootWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/RootWorkflow.kt deleted file mode 100644 index 9186530c9c..0000000000 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/RootWorkflow.kt +++ /dev/null @@ -1,75 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.renderChild -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackScreen -import com.squareup.workflow1.ui.backstack.toBackStackScreen -import workflow.tutorial.RootWorkflow.State -import workflow.tutorial.RootWorkflow.State.Todo -import workflow.tutorial.RootWorkflow.State.Welcome -import workflow.tutorial.TodoWorkflow.TodoProps - -@OptIn(WorkflowUiExperimentalApi::class) -object RootWorkflow : StatefulWorkflow>() { - - sealed class State { - object Welcome : State() - data class Todo(val username: String) : State() - } - - override fun initialState( - props: Unit, - snapshot: Snapshot? - ): State = Welcome - - @OptIn(WorkflowUiExperimentalApi::class) - override fun render( - renderProps: Unit, - renderState: State, - context: RenderContext - ): BackStackScreen { - - // Our list of back stack items. Will always include the "WelcomeScreen". - val backstackScreens = mutableListOf() - - // Render a child workflow of type WelcomeWorkflow. When renderChild is called, the - // infrastructure will create a child workflow with state if one is not already running. - val welcomeScreen = context.renderChild(WelcomeWorkflow) { output -> - // When WelcomeWorkflow emits LoggedIn, turn it into our login action. - login(output.username) - } - backstackScreens += welcomeScreen - - when (renderState) { - // When the state is Welcome, defer to the WelcomeWorkflow. - is Welcome -> { - // We always add the welcome screen to the backstack, so this is a no op. - } - - // When the state is Todo, defer to the TodoListWorkflow. - is Todo -> { - val todoListScreens = context.renderChild(TodoWorkflow, TodoProps(renderState.username)) { - // When receiving a Back output, treat it as a logout action. - logout - } - backstackScreens.addAll(todoListScreens) - } - } - - // Finally, return the BackStackScreen with a list of BackStackScreen.Items - return backstackScreens.toBackStackScreen() - } - - override fun snapshotState(state: State): Snapshot? = null - - private fun login(username: String) = action("login") { - state = Todo(username) - } - - private val logout = action("logout") { - state = Welcome - } -} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt deleted file mode 100644 index ef67bddd96..0000000000 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditLayoutRunner.kt +++ /dev/null @@ -1,33 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.TodoEditViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoEditLayoutRunner( - private val binding: TodoEditViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: TodoEditScreen, - viewEnvironment: ViewEnvironment - ) { - binding.root.backPressedHandler = rendering.discardChanges - binding.save.setOnClickListener { rendering.saveChanges() } - binding.todoTitle.updateText(rendering.title) - binding.todoTitle.setTextChangedListener { rendering.onTitleChanged(it.toString()) } - binding.todoNote.updateText(rendering.note) - binding.todoNote.setTextChangedListener { rendering.onNoteChanged(it.toString()) } - } - - companion object : ViewFactory by bind( - TodoEditViewBinding::inflate, ::TodoEditLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditScreen.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditScreen.kt index b64bb8798d..ea368326de 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditScreen.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditScreen.kt @@ -1,15 +1,32 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.TextController +import com.squareup.workflow1.ui.control +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.databinding.TodoEditViewBinding + data class TodoEditScreen( /** The title of this todo item. */ - val title: String, + val title: TextController, /** The contents, or "note" of the todo. */ - val note: String, + val note: TextController, + + val onBackPressed: () -> Unit, + val onSavePressed: () -> Unit +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoEditViewBinding::inflate, ::todoEditScreenRunner) +} - /** Callbacks for when the title or note changes. */ - val onTitleChanged: (String) -> Unit, - val onNoteChanged: (String) -> Unit, +private fun todoEditScreenRunner( + binding: TodoEditViewBinding +) = ScreenViewRunner { screen: TodoEditScreen, _ -> + binding.root.setBackHandler(screen.onBackPressed) + binding.save.setOnClickListener { screen.onSavePressed() } - val discardChanges: () -> Unit, - val saveChanges: () -> Unit -) + screen.title.control(binding.todoTitle) + screen.note.control(binding.todoNote) +} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditWorkflow.kt index c91fc59d08..ff908504cb 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditWorkflow.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoEditWorkflow.kt @@ -2,85 +2,64 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action +import com.squareup.workflow1.ui.TextController import workflow.tutorial.TodoEditWorkflow.Output -import workflow.tutorial.TodoEditWorkflow.Output.Discard -import workflow.tutorial.TodoEditWorkflow.Output.Save +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges import workflow.tutorial.TodoEditWorkflow.EditProps import workflow.tutorial.TodoEditWorkflow.State object TodoEditWorkflow : StatefulWorkflow() { + /** @param initialTodo The model passed from our parent to be edited. */ data class EditProps( - /** The "Todo" passed from our parent. */ val initialTodo: TodoModel ) + /** + * In-flight edits to be applied to the [TodoModel] originally provided + * by the parent workflow. + */ data class State( - /** The workflow's copy of the Todo item. Changes are local to this workflow. */ - val todo: TodoModel - ) + val editedTitle: TextController, + val editedNote: TextController + ) { + /** Transform this edited [State] back to a [TodoModel]. */ + fun toModel(): TodoModel = TodoModel(editedTitle.textValue, editedNote.textValue) - sealed class Output { - object Discard : Output() - data class Save(val todo: TodoModel) : Output() + companion object { + /** Create a [State] suitable for editing the given [model]. */ + fun forModel(model: TodoModel): State = State( + editedTitle = TextController(model.title), + editedNote = TextController(model.note) + ) + } + } + + sealed interface Output { + object DiscardChanges : Output + data class SaveChanges(val todo: TodoModel) : Output } override fun initialState( props: EditProps, snapshot: Snapshot? - ): State = State(props.initialTodo) - - override fun onPropsChanged( - old: EditProps, - new: EditProps, - state: State - ): State { - // The `Todo` from our parent changed. Update our internal copy so we are starting from the same - // item. The "correct" behavior depends on the business logic - would we only want to update if - // the users hasn't changed the todo from the initial one? Or is it ok to delete whatever edits - // were in progress if the state from the parent changes? - if (old.initialTodo != new.initialTodo) { - return state.copy(todo = new.initialTodo) - } - return state - } + ): State = State.forModel(props.initialTodo) override fun render( renderProps: EditProps, renderState: State, context: RenderContext - ): TodoEditScreen { - return TodoEditScreen( - title = renderState.todo.title, - note = renderState.todo.note, - onTitleChanged = { context.actionSink.send(onTitleChanged(it)) }, - onNoteChanged = { context.actionSink.send(onNoteChanged(it)) }, - saveChanges = { context.actionSink.send(onSave()) }, - discardChanges = { context.actionSink.send(onDiscard()) } - ) - } + ): TodoEditScreen = TodoEditScreen( + title = renderState.editedTitle, + note = renderState.editedNote, + onSavePressed = context.eventHandler("onSavePressed") { + setOutput(SaveChanges(state.toModel())) + }, + onBackPressed = context.eventHandler("onBackPressed") { + setOutput(DiscardChanges) + } + ) override fun snapshotState(state: State): Snapshot? = null - - internal fun onTitleChanged(title: String) = action("onTitleChanged") { - state = state.withTitle(title) - } - - internal fun onNoteChanged(note: String) = action("onNoteChanged") { - state = state.withNote(note) - } - - private fun onDiscard() = action("onDiscard") { - // Emit the Discard output when the discard action is received. - setOutput(Discard) - } - - internal fun onSave() = action("onSave") { - // Emit the Save output with the current todo state when the save action is received. - setOutput(Save(state.todo)) - } - - private fun State.withTitle(title: String) = copy(todo = todo.copy(title = title)) - private fun State.withNote(note: String) = copy(todo = todo.copy(note = note)) } diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt deleted file mode 100644 index 8b6405c04e..0000000000 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListLayoutRunner.kt +++ /dev/null @@ -1,44 +0,0 @@ -package workflow.tutorial - -import androidx.recyclerview.widget.LinearLayoutManager -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backPressedHandler -import workflow.tutorial.views.TodoListAdapter -import workflow.tutorial.views.databinding.TodoListViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoListLayoutRunner( - private val todoListBinding: TodoListViewBinding -) : LayoutRunner { - - private val adapter = TodoListAdapter() - - init { - todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) - todoListBinding.todoList.adapter = adapter - } - - override fun showRendering( - rendering: TodoListScreen, - viewEnvironment: ViewEnvironment - ) { - todoListBinding.root.backPressedHandler = rendering.onBack - - with(todoListBinding.todoListWelcome) { - text = - resources.getString(workflow.tutorial.views.R.string.todo_list_welcome, rendering.username) - } - - adapter.todoList = rendering.todoTitles - adapter.onTodoSelected = rendering.onTodoSelected - adapter.notifyDataSetChanged() - } - - companion object : ViewFactory by bind( - TodoListViewBinding::inflate, ::TodoListLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListScreen.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListScreen.kt index a31675a342..f075479f8a 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListScreen.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListScreen.kt @@ -1,14 +1,45 @@ package workflow.tutorial -/** - * This should contain all data to display in the UI. - * - * It should also contain callbacks for any UI events, for example: - * `val onButtonTapped: () -> Unit`. - */ +import androidx.recyclerview.widget.LinearLayoutManager +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import com.squareup.workflow1.ui.navigation.setBackHandler +import workflow.tutorial.views.R +import workflow.tutorial.views.TodoListAdapter +import workflow.tutorial.views.databinding.TodoListViewBinding + data class TodoListScreen( val username: String, val todoTitles: List, - val onTodoSelected: (Int) -> Unit, - val onBack: () -> Unit -) + val onRowPressed: (Int) -> Unit, + val onBackPressed: () -> Unit, + val onAddPressed: () -> Unit +) : AndroidScreen { + override val viewFactory = + ScreenViewFactory.fromViewBinding(TodoListViewBinding::inflate, ::todoListScreenRunner) +} + +private fun todoListScreenRunner( + todoListBinding: TodoListViewBinding +): ScreenViewRunner { + // This outer scope is run only once, right after the view is inflated. + val adapter = TodoListAdapter() + + todoListBinding.todoList.layoutManager = LinearLayoutManager(todoListBinding.root.context) + todoListBinding.todoList.adapter = adapter + + return ScreenViewRunner { screen: TodoListScreen, _ -> + // This inner lambda is run on each update. + todoListBinding.root.setBackHandler(screen.onBackPressed) + todoListBinding.add.setOnClickListener { screen.onAddPressed() } + + with(todoListBinding.todoListWelcome) { + text = resources.getString(R.string.todo_list_welcome, screen.username) + } + + adapter.todoList = screen.todoTitles + adapter.onTodoSelected = screen.onRowPressed + adapter.notifyDataSetChanged() + } +} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListWorkflow.kt index 683e743d1c..7495984adc 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListWorkflow.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoListWorkflow.kt @@ -1,15 +1,12 @@ package workflow.tutorial import com.squareup.workflow1.StatelessWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi import workflow.tutorial.TodoListWorkflow.ListProps import workflow.tutorial.TodoListWorkflow.Output -import workflow.tutorial.TodoListWorkflow.Output.Back -import workflow.tutorial.TodoListWorkflow.Output.NewTodo -import workflow.tutorial.TodoListWorkflow.Output.SelectTodo +import workflow.tutorial.TodoListWorkflow.Output.AddPressed +import workflow.tutorial.TodoListWorkflow.Output.BackPressed +import workflow.tutorial.TodoListWorkflow.Output.TodoSelected -@OptIn(WorkflowUiExperimentalApi::class) object TodoListWorkflow : StatelessWorkflow() { data class ListProps( @@ -17,10 +14,10 @@ object TodoListWorkflow : StatelessWorkflow() val todos: List ) - sealed class Output { - object Back : Output() - data class SelectTodo(val index: Int) : Output() - object NewTodo : Output() + sealed interface Output { + object BackPressed : Output + data class TodoSelected(val index: Int) : Output + object AddPressed : Output } override fun render( @@ -29,25 +26,14 @@ object TodoListWorkflow : StatelessWorkflow() ): TodoListScreen { val titles = renderProps.todos.map { it.title } return TodoListScreen( - username = renderProps.username, - todoTitles = titles, - onTodoSelected = { context.actionSink.send(selectTodo(it)) }, - onBack = { context.actionSink.send(onBack()) } + username = renderProps.username, + todoTitles = titles, + onBackPressed = context.eventHandler("onBackPressed") { setOutput(BackPressed) }, + onRowPressed = context.eventHandler("onRowPressed") { index -> + // Tell our parent that a todo item was selected. + setOutput(TodoSelected(index)) + }, + onAddPressed = context.eventHandler("onAddPressed") { setOutput(AddPressed) } ) } - - private fun onBack() = action("onBack") { - // When an onBack action is received, emit a Back output. - setOutput(Back) - } - - private fun selectTodo(index: Int) = action("selectTodo") { - // Tell our parent that a todo item was selected. - setOutput(SelectTodo(index)) - } - - private fun new() = action("new") { - // Tell our parent a new todo item should be created. - setOutput(NewTodo) - } } diff --git a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoNavigationWorkflow.kt similarity index 51% rename from samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoWorkflow.kt rename to samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoNavigationWorkflow.kt index da48fe9219..7017f2af34 100644 --- a/samples/tutorial/tutorial-4-complete/src/main/java/workflow/tutorial/TodoWorkflow.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoNavigationWorkflow.kt @@ -3,22 +3,22 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow import com.squareup.workflow1.action -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi +import com.squareup.workflow1.ui.Screen +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges import workflow.tutorial.TodoEditWorkflow.EditProps -import workflow.tutorial.TodoEditWorkflow.Output.Discard -import workflow.tutorial.TodoEditWorkflow.Output.Save import workflow.tutorial.TodoListWorkflow.ListProps import workflow.tutorial.TodoListWorkflow.Output -import workflow.tutorial.TodoListWorkflow.Output.SelectTodo -import workflow.tutorial.TodoWorkflow.Back -import workflow.tutorial.TodoWorkflow.State -import workflow.tutorial.TodoWorkflow.State.Step -import workflow.tutorial.TodoWorkflow.TodoProps +import workflow.tutorial.TodoListWorkflow.Output.AddPressed +import workflow.tutorial.TodoListWorkflow.Output.TodoSelected +import workflow.tutorial.TodoNavigationWorkflow.Back +import workflow.tutorial.TodoNavigationWorkflow.State +import workflow.tutorial.TodoNavigationWorkflow.State.Step +import workflow.tutorial.TodoNavigationWorkflow.TodoProps -@OptIn(WorkflowUiExperimentalApi::class) -object TodoWorkflow : StatefulWorkflow>() { +object TodoNavigationWorkflow : StatefulWorkflow>() { - data class TodoProps(val username: String) + data class TodoProps(val name: String) data class State( val todos: List, @@ -29,8 +29,8 @@ object TodoWorkflow : StatefulWorkflow>() { object List : Step() /** - * Editing a single item. The state holds the index so it can be updated when a save action is - * received. + * Editing a single item. The state holds the index so it can be updated when + * a save action is received. */ data class Edit(val index: Int) : Step() } @@ -42,48 +42,48 @@ object TodoWorkflow : StatefulWorkflow>() { props: TodoProps, snapshot: Snapshot? ) = State( - todos = listOf( - TodoModel( - title = "Take the cat for a walk", - note = "Cats really need their outside sunshine time. Don't forget to walk " + - "Charlie. Hamilton is less excited about the prospect." - ) - ), - step = Step.List + todos = listOf( + TodoModel( + title = "Take the cat for a walk", + note = "Cats really need their outside sunshine time. Don't forget to walk " + + "Charlie. Hamilton is less excited about the prospect." + ) + ), + step = Step.List ) override fun render( renderProps: TodoProps, renderState: State, context: RenderContext - ): List { + ): List { val todoListScreen = context.renderChild( - TodoListWorkflow, - props = ListProps( - username = renderProps.username, - todos = renderState.todos - ) + TodoListWorkflow, + props = ListProps( + username = renderProps.name, + todos = renderState.todos + ) ) { output -> when (output) { - Output.Back -> onBack() - is SelectTodo -> editTodo(output.index) + Output.BackPressed -> goBack() + is TodoSelected -> editTodo(output.index) + AddPressed -> createTodo() } } return when (val step = renderState.step) { // On the "list" step, return just the list screen. Step.List -> listOf(todoListScreen) + is Step.Edit -> { // On the "edit" step, return both the list and edit screens. val todoEditScreen = context.renderChild( - TodoEditWorkflow, - EditProps(renderState.todos[step.index]) + TodoEditWorkflow, + EditProps(renderState.todos[step.index]) ) { output -> when (output) { - // Send the discardChanges action when the discard output is received. - Discard -> discardChanges() - // Send the saveChanges action when the save output is received. - is Save -> saveChanges(output.todo, step.index) + DiscardChanges -> discardChanges() + is SaveChanges -> saveChanges(output.todo, step.index) } } return listOf(todoListScreen, todoEditScreen) @@ -91,21 +91,26 @@ object TodoWorkflow : StatefulWorkflow>() { } } - override fun snapshotState(state: State): Snapshot? = null - - private fun onBack() = action("onBack") { - // When an onBack action is received, emit a Back output. + private fun goBack() = action("goBack") { setOutput(Back) } private fun editTodo(index: Int) = action("editTodo") { - // When a todo item is selected, edit it. state = state.copy(step = Step.Edit(index)) } + private fun createTodo() = action("createTodo") { + // Append a new todo model to the end of the list. + state = state.copy( + todos = state.todos + TodoModel( + title = "New Todo", + note = "" + ) + ) + } private fun discardChanges() = action("discardChanges") { - // When a discard action is received, return to the list. + // To discard edits, just return to the list. state = state.copy(step = Step.List) } @@ -115,8 +120,10 @@ object TodoWorkflow : StatefulWorkflow>() { ) = action("saveChanges") { // When changes are saved, update the state of that todo item and return to the list. state = state.copy( - todos = state.todos.toMutableList().also { it[index] = todo }, - step = Step.List + todos = state.todos.toMutableList().also { it[index] = todo }, + step = Step.List ) } + + override fun snapshotState(state: State): Snapshot? = null } diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoWorkflow.kt deleted file mode 100644 index e04fcd0aef..0000000000 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TodoWorkflow.kt +++ /dev/null @@ -1,133 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.Snapshot -import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import workflow.tutorial.TodoEditWorkflow.EditProps -import workflow.tutorial.TodoEditWorkflow.Output.Discard -import workflow.tutorial.TodoEditWorkflow.Output.Save -import workflow.tutorial.TodoListWorkflow.ListProps -import workflow.tutorial.TodoListWorkflow.Output -import workflow.tutorial.TodoListWorkflow.Output.NewTodo -import workflow.tutorial.TodoListWorkflow.Output.SelectTodo -import workflow.tutorial.TodoWorkflow.Back -import workflow.tutorial.TodoWorkflow.State -import workflow.tutorial.TodoWorkflow.State.Step -import workflow.tutorial.TodoWorkflow.TodoProps - -@OptIn(WorkflowUiExperimentalApi::class) -object TodoWorkflow : StatefulWorkflow>() { - - data class TodoProps(val name: String) - - data class State( - val todos: List, - val step: Step - ) { - sealed class Step { - /** Showing the list of items. */ - object List : Step() - - /** - * Editing a single item. The state holds the index so it can be updated when a save action is - * received. - */ - data class Edit(val index: Int) : Step() - } - } - - object Back - - override fun initialState( - props: TodoProps, - snapshot: Snapshot? - ) = State( - todos = listOf( - TodoModel( - title = "Take the cat for a walk", - note = "Cats really need their outside sunshine time. Don't forget to walk " + - "Charlie. Hamilton is less excited about the prospect." - ) - ), - step = Step.List - ) - - override fun render( - renderProps: TodoProps, - renderState: State, - context: RenderContext - ): List { - val todoListScreen = context.renderChild( - TodoListWorkflow, - props = ListProps( - username = renderProps.name, - todos = renderState.todos - ) - ) { output -> - when (output) { - Output.Back -> onBack() - is SelectTodo -> editTodo(output.index) - NewTodo -> newTodo() - } - } - - return when (val step = renderState.step) { - // On the "list" step, return just the list screen. - Step.List -> listOf(todoListScreen) - is Step.Edit -> { - // On the "edit" step, return both the list and edit screens. - val todoEditScreen = context.renderChild( - TodoEditWorkflow, - EditProps(renderState.todos[step.index]) - ) { output -> - when (output) { - // Send the discardChanges action when the discard output is received. - Discard -> discardChanges() - // Send the saveChanges action when the save output is received. - is Save -> saveChanges(output.todo, step.index) - } - } - return listOf(todoListScreen, todoEditScreen) - } - } - } - - override fun snapshotState(state: State): Snapshot? = null - - private fun onBack() = action("onBack") { - // When an onBack action is received, emit a Back output. - setOutput(Back) - } - - private fun editTodo(index: Int) = action("editTodo") { - // When a todo item is selected, edit it. - state = state.copy(step = Step.Edit(index)) - } - - private fun newTodo() = action("newTodo") { - // Append a new todo model to the end of the list. - state = state.copy( - todos = state.todos + TodoModel( - title = "New Todo", - note = "" - ) - ) - } - - private fun discardChanges() = action("discardChanges") { - // When a discard action is received, return to the list. - state = state.copy(step = Step.List) - } - - private fun saveChanges( - todo: TodoModel, - index: Int - ) = action("saveChanges") { - // When changes are saved, update the state of that todo item and return to the list. - state = state.copy( - todos = state.todos.toMutableList().also { it[index] = todo }, - step = Step.List - ) - } -} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TutorialActivity.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TutorialActivity.kt index 99c67c824c..5f3ba89874 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TutorialActivity.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/TutorialActivity.kt @@ -1,48 +1,57 @@ -@file:OptIn(WorkflowUiExperimentalApi::class) - package workflow.tutorial import android.os.Bundle +import android.util.Log import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.squareup.workflow1.ui.ViewRegistry +import com.squareup.workflow1.RuntimeConfigOptions +import com.squareup.workflow1.WorkflowExperimentalRuntime +import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackContainer +import com.squareup.workflow1.ui.navigation.reportNavigation import com.squareup.workflow1.ui.renderWorkflowIn -import kotlinx.coroutines.flow.StateFlow - -private val viewRegistry = ViewRegistry( - BackStackContainer, - WelcomeLayoutRunner, - TodoListLayoutRunner, - TodoEditLayoutRunner -) +import kotlinx.coroutines.flow.Flow class TutorialActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Use an AndroidX ViewModel to start and host an instance of the workflow runtime that runs - // the WelcomeWorkflow and sets the activity's content view using our view factories. + // We use an AndroidX ViewModel and its CoroutineScope to start and host + // an instance of the workflow runtime that runs the WelcomeWorkflow. + // This ensures that our runtime will survive as new `Activity` instances + // are created for configuration changes. val model: TutorialViewModel by viewModels() setContentView( - WorkflowLayout(this).apply { start(model.renderings, viewRegistry) } + WorkflowLayout(this).apply { + take(lifecycle, model.renderings) + } ) } -} -class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { - val renderings: StateFlow by lazy { - renderWorkflowIn( - workflow = RootWorkflow, - scope = viewModelScope, - savedStateHandle = savedState - ) + class TutorialViewModel(savedState: SavedStateHandle) : ViewModel() { + + // We opt in to WorkflowExperimentalRuntime in order turn on all the + // optimizations controlled by the runtimeConfig. + // + // They are in production use at Square, will not be listed as + // experimental much longer, and will soon be enabled by default. + // In the meantime it is much easier to use them from the start + // than to turn them on down the road. + @OptIn(WorkflowExperimentalRuntime::class) + val renderings: Flow by lazy { + renderWorkflowIn( + workflow = RootNavigationWorkflow, + scope = viewModelScope, + savedStateHandle = savedState, + runtimeConfig = RuntimeConfigOptions.ALL + ).reportNavigation { + Log.i("navigate", it.toString()) + } + } } } diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt deleted file mode 100644 index 7eb9af6340..0000000000 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeLayoutRunner.kt +++ /dev/null @@ -1,38 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.ui.LayoutRunner -import com.squareup.workflow1.ui.LayoutRunner.Companion.bind -import com.squareup.workflow1.ui.ViewEnvironment -import com.squareup.workflow1.ui.ViewFactory -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.setTextChangedListener -import com.squareup.workflow1.ui.updateText -import workflow.tutorial.views.databinding.WelcomeViewBinding - -@OptIn(WorkflowUiExperimentalApi::class) -class WelcomeLayoutRunner( - private val welcomeBinding: WelcomeViewBinding -) : LayoutRunner { - - override fun showRendering( - rendering: WelcomeScreen, - viewEnvironment: ViewEnvironment - ) { - // updateText and setTextChangedListener are helpers provided by the workflow library that take - // care of the complexity of correctly interacting with EditTexts in a declarative manner. - welcomeBinding.username.updateText(rendering.username) - welcomeBinding.username.setTextChangedListener { - rendering.onUsernameChanged(it.toString()) - } - welcomeBinding.login.setOnClickListener { rendering.onLoginTapped() } - } - - /** - * Define a [ViewFactory] that will inflate an instance of [WelcomeViewBinding] and an instance - * of [WelcomeLayoutRunner] when asked, then wire them up so that [showRendering] will be called - * whenever the workflow emits a new [WelcomeScreen]. - */ - companion object : ViewFactory by bind( - WelcomeViewBinding::inflate, ::WelcomeLayoutRunner - ) -} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeScreen.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeScreen.kt index af5918e4bc..d023aaa3ab 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeScreen.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeScreen.kt @@ -1,10 +1,28 @@ package workflow.tutorial +import com.squareup.workflow1.ui.AndroidScreen +import com.squareup.workflow1.ui.ScreenViewFactory +import com.squareup.workflow1.ui.ScreenViewRunner +import workflow.tutorial.views.databinding.WelcomeViewBinding + +/** + * @param promptText error text or other message to show the users + * @param onLogInTapped Log In button event handler + */ data class WelcomeScreen( - /** The current name that has been entered. */ - val username: String, - /** Callback when the name changes in the UI. */ - val onUsernameChanged: (String) -> Unit, - /** Callback when the login button is tapped. */ - val onLoginTapped: () -> Unit -) + val promptText: String, + val onLogInTapped: (String) -> Unit +) : AndroidScreen { + + override val viewFactory = + ScreenViewFactory.fromViewBinding(WelcomeViewBinding::inflate, ::welcomeScreenRunner) +} + +private fun welcomeScreenRunner( + viewBinding: WelcomeViewBinding +) = ScreenViewRunner { screen: WelcomeScreen, _ -> + viewBinding.prompt.text = screen.promptText + viewBinding.logIn.setOnClickListener { + screen.onLogInTapped(viewBinding.username.text.toString()) + } +} diff --git a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeWorkflow.kt b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeWorkflow.kt index 6cf98dce4c..0b391eb996 100644 --- a/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeWorkflow.kt +++ b/samples/tutorial/tutorial-final/src/main/java/workflow/tutorial/WelcomeWorkflow.kt @@ -2,14 +2,13 @@ package workflow.tutorial import com.squareup.workflow1.Snapshot import com.squareup.workflow1.StatefulWorkflow -import com.squareup.workflow1.action import workflow.tutorial.WelcomeWorkflow.LoggedIn import workflow.tutorial.WelcomeWorkflow.State object WelcomeWorkflow : StatefulWorkflow() { data class State( - val name: String + val prompt: String ) data class LoggedIn(val username: String) @@ -17,32 +16,22 @@ object WelcomeWorkflow : StatefulWorkflow( override fun initialState( props: Unit, snapshot: Snapshot? - ): State = State(name = "") + ): State = State(prompt = "") override fun render( renderProps: Unit, renderState: State, context: RenderContext ): WelcomeScreen = WelcomeScreen( - username = renderState.name, - onUsernameChanged = { context.actionSink.send(onNameChanged(it)) }, - onLoginTapped = { - // Whenever the login button is tapped, emit the onLogin action. - context.actionSink.send(onLogin()) + promptText = renderState.prompt, + onLogInTapped = context.eventHandler("onLogInTapped") { name -> + if (name.isEmpty()) { + state = state.copy(prompt = "name required to log in") + } else { + setOutput(LoggedIn(name)) } - ) - - // Needs to be internal so we can access it from the tests. - internal fun onNameChanged(name: String) = action("onNameChanged") { - state = state.copy(name = name) - } - - internal fun onLogin() = action("onLogin") { - // Don't log in if the name isn't filled in. - if (state.name.isNotEmpty()) { - setOutput(LoggedIn(state.name)) } - } + ) override fun snapshotState(state: State): Snapshot? = null } diff --git a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/RootNavigationWorkflowTest.kt b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/RootNavigationWorkflowTest.kt new file mode 100644 index 0000000000..e2e906d82f --- /dev/null +++ b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/RootNavigationWorkflowTest.kt @@ -0,0 +1,127 @@ +package workflow.tutorial + +import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.testing.expectWorkflow +import com.squareup.workflow1.testing.launchForTestingFromStartWith +import com.squareup.workflow1.testing.testRender +import workflow.tutorial.RootNavigationWorkflow.State.ShowingTodo +import workflow.tutorial.RootNavigationWorkflow.State.ShowingWelcome +import workflow.tutorial.WelcomeWorkflow.LoggedIn +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class RootNavigationWorkflowTest { + + // region Render + + @Test fun `welcome rendering`() { + RootNavigationWorkflow + // Start in the ShowingWelcome state + .testRender(initialState = ShowingWelcome, props = Unit) + // The `WelcomeWorkflow` is expected to be started in this render. + .expectWorkflow( + workflowType = WelcomeWorkflow::class, + rendering = WelcomeScreen( + promptText = "Well hello there!", + onLogInTapped = {} + ) + ) + // Now, validate that there is a single item in the BackStackScreen, + // which is our welcome screen. + .render { rendering -> + val frames = rendering.frames + assertEquals(1, frames.size) + + val welcomeScreen = frames[0] as WelcomeScreen + assertEquals("Well hello there!", welcomeScreen.promptText) + } + // Assert that no action was produced during this render, + // meaning our state remains unchanged + .verifyActionResult { _, output -> + assertNull(output) + } + } + + @Test fun `login event`() { + RootNavigationWorkflow + // Start in the Welcome state + .testRender(initialState = ShowingWelcome, props = Unit) + // The WelcomeWorkflow is expected to be started in this render. + .expectWorkflow( + workflowType = WelcomeWorkflow::class, + rendering = WelcomeScreen( + promptText = "yo", + onLogInTapped = {} + ), + // Simulate the WelcomeWorkflow sending an output of LoggedIn + // as if the "log in" button was tapped. + output = WorkflowOutput(LoggedIn(username = "Ada")) + ) + // Now, validate that there is a single item in the BackStackScreen, + // which is our welcome screen (prior to the output). + .render { rendering -> + val backstack = rendering.frames + assertEquals(1, backstack.size) + + val welcomeScreen = backstack[0] as WelcomeScreen + } + // Assert that the state transitioned to Todo. + .verifyActionResult { newState, _ -> + assertEquals(ShowingTodo(username = "Ada"), newState) + } + } + + // endregion + + // region Integration + + @Test fun `app flow`() { + RootNavigationWorkflow.launchForTestingFromStartWith { + // First rendering is just the welcome screen. Update the name. + awaitNextRendering().let { rendering -> + assertEquals(1, rendering.frames.size) + val welcomeScreen = rendering.frames[0] as WelcomeScreen + + // Enter a name and tap login + welcomeScreen.onLogInTapped("Ada") + } + + // Expect the todo list to be rendered. Edit the first todo. + awaitNextRendering().let { rendering -> + assertEquals(2, rendering.frames.size) + assertTrue(rendering.frames[0] is WelcomeScreen) + val todoScreen = rendering.frames[1] as TodoListScreen + assertEquals(1, todoScreen.todoTitles.size) + + // Select the first todo. + todoScreen.onRowPressed(0) + } + + // Selected a todo to edit. Expect the todo edit screen. + awaitNextRendering().let { rendering -> + assertEquals(3, rendering.frames.size) + assertTrue(rendering.frames[0] is WelcomeScreen) + assertTrue(rendering.frames[1] is TodoListScreen) + val editScreen = rendering.frames[2] as TodoEditScreen + + // Enter a title and save. + editScreen.title.textValue = "New Title" + editScreen.onSavePressed() + } + + // Expect the todo list. Validate the title was updated. + awaitNextRendering().let { rendering -> + assertEquals(2, rendering.frames.size) + assertTrue(rendering.frames[0] is WelcomeScreen) + val todoScreen = rendering.frames[1] as TodoListScreen + + assertEquals(1, todoScreen.todoTitles.size) + assertEquals("New Title", todoScreen.todoTitles[0]) + } + } + } + + // endregion +} diff --git a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/RootWorkflowTest.kt b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/RootWorkflowTest.kt deleted file mode 100644 index f7446036f1..0000000000 --- a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/RootWorkflowTest.kt +++ /dev/null @@ -1,150 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.WorkflowOutput -import com.squareup.workflow1.testing.expectWorkflow -import com.squareup.workflow1.testing.launchForTestingFromStartWith -import com.squareup.workflow1.testing.testRender -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import com.squareup.workflow1.ui.backstack.BackStackScreen -import workflow.tutorial.RootWorkflow.State.Todo -import workflow.tutorial.RootWorkflow.State.Welcome -import workflow.tutorial.WelcomeWorkflow.LoggedIn -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@OptIn(WorkflowUiExperimentalApi::class) -class RootWorkflowTest { - - // region Render - - @Test fun `welcome rendering`() { - RootWorkflow - // Start in the Welcome state - .testRender(initialState = Welcome, props = Unit) - // The `WelcomeWorkflow` is expected to be started in this render. - .expectWorkflow( - workflowType = WelcomeWorkflow::class, - rendering = WelcomeScreen( - username = "Ada", - onUsernameChanged = {}, - onLoginTapped = {} - ) - ) - // Now, validate that there is a single item in the BackStackScreen, which is our welcome - // screen. - .render { rendering -> - val backstack = (rendering as BackStackScreen<*>).frames - assertEquals(1, backstack.size) - - val welcomeScreen = backstack[0] as WelcomeScreen - assertEquals("Ada", welcomeScreen.username) - } - // Assert that no action was produced during this render, meaning our state remains unchanged - .verifyActionResult { _, output -> - assertNull(output) - } - } - - @Test fun `login event`() { - RootWorkflow - // Start in the Welcome state - .testRender(initialState = Welcome, props = Unit) - // The WelcomeWorkflow is expected to be started in this render. - .expectWorkflow( - workflowType = WelcomeWorkflow::class, - rendering = WelcomeScreen( - username = "Ada", - onUsernameChanged = {}, - onLoginTapped = {} - ), - // Simulate the WelcomeWorkflow sending an output of LoggedIn as if the "log in" button - // was tapped. - output = WorkflowOutput(LoggedIn(username = "Ada")) - ) - // Now, validate that there is a single item in the BackStackScreen, which is our welcome - // screen (prior to the output). - .render { rendering -> - val backstack = rendering.frames - assertEquals(1, backstack.size) - - val welcomeScreen = backstack[0] as WelcomeScreen - assertEquals("Ada", welcomeScreen.username) - } - // Assert that the state transitioned to Todo. - .verifyActionResult { newState, _ -> - assertEquals(Todo(username = "Ada"), newState) - } - } - - // endregion - - // region Integration - - @Test fun `app flow`() { - RootWorkflow.launchForTestingFromStartWith { - // First rendering is just the welcome screen. Update the name. - awaitNextRendering().let { rendering -> - assertEquals(1, rendering.frames.size) - val welcomeScreen = rendering.frames[0] as WelcomeScreen - - // Enter a name. - welcomeScreen.onUsernameChanged("Ada") - } - - // Log in and go to the todo list. - awaitNextRendering().let { rendering -> - assertEquals(1, rendering.frames.size) - val welcomeScreen = rendering.frames[0] as WelcomeScreen - - welcomeScreen.onLoginTapped() - } - - // Expect the todo list to be rendered. Edit the first todo. - awaitNextRendering().let { rendering -> - assertEquals(2, rendering.frames.size) - assertTrue(rendering.frames[0] is WelcomeScreen) - val todoScreen = rendering.frames[1] as TodoListScreen - assertEquals(1, todoScreen.todoTitles.size) - - // Select the first todo. - todoScreen.onTodoSelected(0) - } - - // Selected a todo to edit. Expect the todo edit screen. - awaitNextRendering().let { rendering -> - assertEquals(3, rendering.frames.size) - assertTrue(rendering.frames[0] is WelcomeScreen) - assertTrue(rendering.frames[1] is TodoListScreen) - val editScreen = rendering.frames[2] as TodoEditScreen - - // Update the title. - editScreen.onTitleChanged("New Title") - } - - // Save the selected todo. - awaitNextRendering().let { rendering -> - assertEquals(3, rendering.frames.size) - assertTrue(rendering.frames[0] is WelcomeScreen) - assertTrue(rendering.frames[1] is TodoListScreen) - val editScreen = rendering.frames[2] as TodoEditScreen - - // Save the changes by tapping the save button. - editScreen.saveChanges() - } - - // Expect the todo list. Validate the title was updated. - awaitNextRendering().let { rendering -> - assertEquals(2, rendering.frames.size) - assertTrue(rendering.frames[0] is WelcomeScreen) - val todoScreen = rendering.frames[1] as TodoListScreen - - assertEquals(1, todoScreen.todoTitles.size) - assertEquals("New Title", todoScreen.todoTitles[0]) - } - } - } - - // endregion -} diff --git a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoEditWorkflowTest.kt b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoEditWorkflowTest.kt index 1572de6230..d1952f22a5 100644 --- a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoEditWorkflowTest.kt +++ b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoEditWorkflowTest.kt @@ -1,72 +1,37 @@ package workflow.tutorial -import com.squareup.workflow1.applyTo +import com.squareup.workflow1.testing.testRender import org.junit.Test +import workflow.tutorial.TodoEditWorkflow.Output.DiscardChanges +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges import workflow.tutorial.TodoEditWorkflow.EditProps -import workflow.tutorial.TodoEditWorkflow.Output.Save -import workflow.tutorial.TodoEditWorkflow.State import kotlin.test.assertEquals -import kotlin.test.assertNull +import kotlin.test.assertSame class TodoEditWorkflowTest { - - // Start with a todo of "Title" "Note" - private val startState = State(todo = TodoModel(title = "Title", note = "Note")) - - @Test fun `title is updated`() { - // These will be ignored by the action. - val props = EditProps(TodoModel(title = "", note = "")) - - // Update the title to "Updated Title" - val (newState, actionApplied) = TodoEditWorkflow.onTitleChanged("Updated Title") - .applyTo(props, startState) - - assertNull(actionApplied.output) - assertEquals(TodoModel(title = "Updated Title", note = "Note"), newState.todo) - } - - @Test fun `note is updated`() { - // These will be ignored by the action. - val props = EditProps(TodoModel(title = "", note = "")) - - // Update the note to "Updated Note" - val (newState, actionApplied) = TodoEditWorkflow.onNoteChanged("Updated Note") - .applyTo(props, startState) - - assertNull(actionApplied.output) - assertEquals(TodoModel(title = "Title", note = "Updated Note"), newState.todo) - } - @Test fun `save emits model`() { + // Start with a todo of "Title" "Note" val props = EditProps(TodoModel(title = "Title", note = "Note")) - val (_, actionApplied) = TodoEditWorkflow.onSave() - .applyTo(props, startState) - - assertEquals(Save(TodoModel(title = "Title", note = "Note")), actionApplied.output?.value) + TodoEditWorkflow.testRender(props) + .render { screen -> + screen.title.textValue = "New title" + screen.note.textValue = "New note" + screen.onSavePressed() + }.verifyActionResult { _, output -> + val expected = SaveChanges(TodoModel(title = "New title", note = "New note")) + assertEquals(expected, output?.value) + } } - @Test fun `changed props updated local state`() { - val initialProps = EditProps(initialTodo = TodoModel(title = "Title", note = "Note")) - var state = TodoEditWorkflow.initialState(initialProps, null) - - // The initial state is a copy of the provided todo: - assertEquals("Title", state.todo.title) - assertEquals("Note", state.todo.note) - - // Create a new internal state, simulating the change from actions: - state = State(TodoModel(title = "Updated Title", note = "Note")) - - // Update the workflow properties with the same value. The state should not be updated: - state = TodoEditWorkflow.onPropsChanged(initialProps, initialProps, state) - assertEquals("Updated Title", state.todo.title) - assertEquals("Note", state.todo.note) + @Test fun `back press discards`() { + val props = EditProps(TodoModel(title = "Title", note = "Note")) - // The parent provided different properties. The internal state should be updated with the - // newly-provided properties. - val updatedProps = EditProps(initialTodo = TodoModel(title = "New Title", note = "New Note")) - state = TodoEditWorkflow.onPropsChanged(initialProps, updatedProps, state) - assertEquals("New Title", state.todo.title) - assertEquals("New Note", state.todo.note) + TodoEditWorkflow.testRender(props) + .render { screen -> + screen.onBackPressed() + }.verifyActionResult { _, output -> + assertSame(DiscardChanges, output?.value) + } } } diff --git a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoNavigationWorkflowTest.kt b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoNavigationWorkflowTest.kt new file mode 100644 index 0000000000..4565cea40d --- /dev/null +++ b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoNavigationWorkflowTest.kt @@ -0,0 +1,136 @@ +package workflow.tutorial + +import com.squareup.workflow1.WorkflowOutput +import com.squareup.workflow1.testing.expectWorkflow +import com.squareup.workflow1.testing.testRender +import com.squareup.workflow1.ui.TextController +import workflow.tutorial.TodoEditWorkflow.Output.SaveChanges +import workflow.tutorial.TodoListWorkflow.Output.TodoSelected +import workflow.tutorial.TodoNavigationWorkflow.State +import workflow.tutorial.TodoNavigationWorkflow.State.Step.Edit +import workflow.tutorial.TodoNavigationWorkflow.State.Step.List +import workflow.tutorial.TodoNavigationWorkflow.TodoProps +import kotlin.test.Test +import kotlin.test.assertEquals + +class TodoNavigationWorkflowTest { + + @Test fun `selecting todo`() { + val todos = listOf(TodoModel(title = "Title", note = "Note")) + + TodoNavigationWorkflow + .testRender( + props = TodoProps(name = "Ada"), + // Start from the list step to validate selecting a todo. + initialState = State( + todos = todos, + step = List + ) + ) + // We only expect the TodoListWorkflow to be rendered. + .expectWorkflow( + workflowType = TodoListWorkflow::class, + rendering = TodoListScreen( + username = "", + todoTitles = listOf("Title"), + onRowPressed = {}, + onBackPressed = {}, + onAddPressed = {} + ), + // Simulate selecting the first todo. + output = WorkflowOutput(TodoSelected(index = 0)) + ) + .render { backstack -> + // Just validate that there is one item in the back stack. + // Additional validation could be done on the screens returned, if desired. + assertEquals(1, backstack.size) + } + // Assert that the state was updated after the render pass with the output from the + // TodoListWorkflow. + .verifyActionResult { newState, _ -> + assertEqualState( + State( + todos = listOf(TodoModel(title = "Title", note = "Note")), + step = Edit(0) + ), newState + ) + } + } + + @Test fun `saving todo`() { + val todos = listOf(TodoModel(title = "Title", note = "Note")) + + TodoNavigationWorkflow + .testRender( + props = TodoProps(name = "Ada"), + // Start from the edit step so we can simulate saving. + initialState = State( + todos = todos, + step = Edit(index = 0) + ) + ) + // We always expect the TodoListWorkflow to be rendered. + .expectWorkflow( + workflowType = TodoListWorkflow::class, + rendering = TodoListScreen( + username = "", + todoTitles = listOf("Title"), + onRowPressed = {}, + onBackPressed = {}, + onAddPressed = {} + ) + ) + // Expect the TodoEditWorkflow to be rendered as well (as we're on the edit step). + .expectWorkflow( + workflowType = TodoEditWorkflow::class, + rendering = TodoEditScreen( + title = TextController("Title"), + note = TextController("Note"), + onBackPressed = {}, + onSavePressed = {} + ), + // Simulate it emitting an output of `.save` to update the state. + output = WorkflowOutput( + SaveChanges( + TodoModel( + title = "Updated Title", + note = "Updated Note" + ) + ) + ) + ) + .render { rendering -> + // Just validate that there are two items in the back stack. + // Additional validation could be done on the screens returned, if desired. + assertEquals(2, rendering.size) + } + // Validate that the state was updated after the render pass with the output from the + // TodoEditWorkflow. + .verifyActionResult { newState, _ -> + assertEqualState( + State( + todos = listOf(TodoModel(title = "Updated Title", note = "Updated Note")), + step = List + ), + newState + ) + } + } + + private fun assertEqualState(expected: State, actual: State) { + assertEquals(expected.todos.size, actual.todos.size) + expected.todos.forEachIndexed { index, _ -> + assertEquals( + expected.todos[index].title, + actual.todos[index].title, + "todos[$index].title" + ) + assertEquals( + expected.todos[index].note, + actual.todos[index].note, + "todos[$index].note" + ) + } + assertEquals(expected.step, actual.step) + } +} diff --git a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoWorkflowTest.kt b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoWorkflowTest.kt deleted file mode 100644 index 0cddadd52b..0000000000 --- a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/TodoWorkflowTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package workflow.tutorial - -import com.squareup.workflow1.WorkflowOutput -import com.squareup.workflow1.testing.expectWorkflow -import com.squareup.workflow1.testing.testRender -import com.squareup.workflow1.ui.WorkflowUiExperimentalApi -import workflow.tutorial.TodoEditWorkflow.Output.Save -import workflow.tutorial.TodoListWorkflow.Output.SelectTodo -import workflow.tutorial.TodoWorkflow.State -import workflow.tutorial.TodoWorkflow.State.Step.Edit -import workflow.tutorial.TodoWorkflow.State.Step.List -import workflow.tutorial.TodoWorkflow.TodoProps -import kotlin.test.Test -import kotlin.test.assertEquals - -@OptIn(WorkflowUiExperimentalApi::class) -class TodoWorkflowTest { - - @Test fun `selecting todo`() { - val todos = listOf(TodoModel(title = "Title", note = "Note")) - - TodoWorkflow - .testRender( - props = TodoProps(name = "Ada"), - // Start from the list step to validate selecting a todo. - initialState = State( - todos = todos, - step = List - ) - ) - // We only expect the TodoListWorkflow to be rendered. - .expectWorkflow( - workflowType = TodoListWorkflow::class, - rendering = TodoListScreen( - username = "", - todoTitles = listOf("Title"), - onTodoSelected = {}, - onBack = {} - ), - // Simulate selecting the first todo. - output = WorkflowOutput(SelectTodo(index = 0)) - ) - .render { rendering -> - // Just validate that there is one item in the back stack. - // Additional validation could be done on the screens returned, if desired. - assertEquals(1, rendering.size) - } - // Assert that the state was updated after the render pass with the output from the - // TodoListWorkflow. - .verifyActionResult { newState, _ -> - assertEquals( - State( - todos = listOf(TodoModel(title = "Title", note = "Note")), - step = Edit(0) - ), - newState - ) - } - } - - @Test fun `saving todo`() { - val todos = listOf(TodoModel(title = "Title", note = "Note")) - - TodoWorkflow - .testRender( - props = TodoProps(name = "Ada"), - // Start from the edit step so we can simulate saving. - initialState = State( - todos = todos, - step = Edit(index = 0) - ) - ) - // We always expect the TodoListWorkflow to be rendered. - .expectWorkflow( - workflowType = TodoListWorkflow::class, - rendering = TodoListScreen( - username = "", - todoTitles = listOf("Title"), - onTodoSelected = {}, - onBack = {} - ) - ) - // Expect the TodoEditWorkflow to be rendered as well (as we're on the edit step). - .expectWorkflow( - workflowType = TodoEditWorkflow::class, - rendering = TodoEditScreen( - title = "Title", - note = "Note", - onTitleChanged = {}, - onNoteChanged = {}, - discardChanges = {}, - saveChanges = {} - ), - // Simulate it emitting an output of `.save` to update the state. - output = WorkflowOutput( - Save( - TodoModel( - title = "Updated Title", - note = "Updated Note" - ) - ) - ) - ) - .render { rendering -> - // Just validate that there are two items in the back stack. - // Additional validation could be done on the screens returned, if desired. - assertEquals(2, rendering.size) - } - // Validate that the state was updated after the render pass with the output from the - // TodoEditWorkflow. - .verifyActionResult { newState, _ -> - assertEquals( - State( - todos = listOf(TodoModel(title = "Updated Title", note = "Updated Note")), - step = List - ), - newState - ) - } - } -} diff --git a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/WelcomeWorkflowTest.kt b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/WelcomeWorkflowTest.kt index c39748c4df..b69f84ce1d 100644 --- a/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/WelcomeWorkflowTest.kt +++ b/samples/tutorial/tutorial-final/src/test/java/workflow/tutorial/WelcomeWorkflowTest.kt @@ -1,6 +1,5 @@ package workflow.tutorial -import com.squareup.workflow1.applyTo import com.squareup.workflow1.testing.testRender import org.junit.Test import workflow.tutorial.WelcomeWorkflow.LoggedIn @@ -8,89 +7,33 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class WelcomeWorkflowTest { - - // region Actions - - @Test fun `name updates`() { - val startState = WelcomeWorkflow.State("") - val action = WelcomeWorkflow.onNameChanged("myName") - val (state, actionApplied) = action.applyTo(state = startState, props = Unit) - - // No output is expected when the name changes. - assertNull(actionApplied.output) - - // The name has been updated from the action. - assertEquals("myName", state.name) - } - - @Test fun `login works`() { - val startState = WelcomeWorkflow.State("myName") - val action = WelcomeWorkflow.onLogin() - val (_, actionApplied) = action.applyTo(state = startState, props = Unit) - - // Now a LoggedIn output should be emitted when the onLogin action was received. - assertEquals(LoggedIn("myName"), actionApplied.output?.value) - } - - @Test fun `login does nothing when name is empty`() { - val startState = WelcomeWorkflow.State("") - val action = WelcomeWorkflow.onLogin() - val (state, actionApplied) = action.applyTo(state = startState, props = Unit) - - // Since the name is empty, onLogin will not emit an output. - assertNull(actionApplied.output) - // The name is empty, as was specified in the initial state. - assertEquals("", state.name) - } - - // endregion - - // region Rendering - - @Test fun `rendering initial`() { - // Use the initial state provided by the welcome workflow. - WelcomeWorkflow.testRender(props = Unit) - .render { screen -> - assertEquals("", screen.username) - - // Simulate tapping the log in button. No output will be emitted, as the name is empty. - screen.onLoginTapped() - } - .verifyActionResult { _, output -> - assertNull(output) - } - } - - @Test fun `rendering name change`() { - // Use the initial state provided by the welcome workflow. - WelcomeWorkflow.testRender(props = Unit) - // Next, simulate the name updating, expecting the state to be changed to reflect the - // updated name. - .render { screen -> - screen.onUsernameChanged("Ada") - } - .verifyActionResult { state, _ -> - // https://github.com/square/workflow-kotlin/issues/230 - assertEquals("Ada", (state as WelcomeWorkflow.State).name) - } - } - - @Test fun `rendering login`() { - // Start with a name already entered. + @Test fun `successful log in`() { WelcomeWorkflow - .testRender( - initialState = WelcomeWorkflow.State(name = "Ada"), - props = Unit - ) + .testRender(props = Unit) // Simulate a log in button tap. .render { screen -> - screen.onLoginTapped() + screen.onLogInTapped("Ada") } - // Finally, validate that LoggedIn was sent. + // Validate that LoggedIn was sent. .verifyActionResult { _, output -> assertEquals(LoggedIn("Ada"), output?.value) } } - // endregion + @Test fun `failed log in`() { + WelcomeWorkflow.testRender(props = Unit) + .render { screen -> + // Simulate a log in button tap with an empty name. + screen.onLogInTapped("") + } + .verifyActionResult { _, output -> + // No output will be emitted, as the name is empty. + assertNull(output) + } + .testNextRender() + .render { screen -> + // There is an error prompt. + assertEquals("name required to log in", screen.promptText) + } + } } diff --git a/samples/tutorial/tutorial-views/build.gradle b/samples/tutorial/tutorial-views/build.gradle index e0f4fd3598..d200b4242b 100644 --- a/samples/tutorial/tutorial-views/build.gradle +++ b/samples/tutorial/tutorial-views/build.gradle @@ -7,7 +7,7 @@ android { compileSdk = 34 defaultConfig { - minSdk = 21 + minSdk = 24 targetSdk = 33 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/samples/tutorial/tutorial-views/src/main/res/layout/todo_list_view.xml b/samples/tutorial/tutorial-views/src/main/res/layout/todo_list_view.xml index e26318102b..974e15c61e 100644 --- a/samples/tutorial/tutorial-views/src/main/res/layout/todo_list_view.xml +++ b/samples/tutorial/tutorial-views/src/main/res/layout/todo_list_view.xml @@ -1,9 +1,25 @@ - + > + +