Building Server-Driven Mobile Apps With Nimbus SDUI

A to-do app driven by the backend with Nimbus SDUI

Tiago Peres França
Better Programming

--

A window with the code to the backend with a screenshot of the result on both iOS (left) and Android (right).

Last time, we gave an overall on how Nimbus can help with creating server-driven content for your mobile application. This time, we’re gonna get our hands dirty and use some actual code to build a To Do App where the user interface (UI) is completely driven by the backend.

The application we will build can be seen in the video below:

The application is a simple list of notes grouped by date. Each note has a title, description, and date. It can also be "done" or "undone." We can toggle the done status of a note by clicking the small circle on the left of each note. We can edit the note by clicking the title and remove it by clicking the trash bin icon. We can also add new notes by clicking the floating button labeled "+" in the bottom right corner of the screen.

This is a long article, and I divided it into four checkpoints. Once you reach a checkpoint, it might be time to give it a pause, grab a snack, and come back later. This is a coding tutorial, so it's important that you're able to compare your source code with the expected source code. For this reason, we have a repository with four different branches. At checkpoint 1, you can validate your code with the branch “part 1,” at checkpoint 2, you can validate it with the branch “part 2,” and so on.

This article will always present one “how to” for Android and another for iOS. If you're interested in only one of these platforms, you can safely skip the sections for the other.

Creating the Android Project

  1. Use Android Studio to create a new Android Project with Jetpack Compose.
Empty Compose Activity

2. Name your project, set the minimum Android API, and click finish.

Name: To Do App. Package name: com.todoapp; Minimum SDK: API 21.

3. To use all Nimbus dependencies, add the following code to your app/build.gradle file:

plugins {
// Support for auto-deserialization in Nimbus
id 'com.google.devtools.ksp' version "1.7.0-1.0.6" // use the latest version of KSP according to your Kotlin version
}

dependencies {
// Compose navigation
implementation ("androidx.navigation:navigation-compose:2.5.1")
// Nimbus compose base library
implementation 'br.com.zup.nimbus:nimbus-compose:1.0.0-beta1'
// Layout components for Nimbus
implementation 'br.com.zup.nimbus:nimbus-layout-compose:1.0.0-beta1'
// Support for auto-deserialization
implementation 'br.com.zup.nimbus:nimbus-compose-annotation:1.0.0-beta1'
ksp 'br.com.zup.nimbus:nimbus-compose-processor:1.0.0-beta1'
}

// Support for auto-deserialization
kotlin {
sourceSets.debug {
kotlin.srcDir("build/generated/ksp/debug/kotlin")
}
sourceSets.release {
kotlin.srcDir("build/generated/ksp/release/kotlin")
}
sourceSets.test {}
}

As of the time of writing, all dependencies are at version 1.0.0-beta1, you should always use the latest version.

4. Sync your project.

Gradle’s elephant icon in the toolbar

5. Add internet permissions by including the following code to your manifest file (app/src/main/AndroidManifest.xml):

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

6. Now, we need to create an instance of Nimbus. For this, create a new source code file called Nimbus.kt and add the following code to it:

package com.todoapp

import br.com.zup.nimbus.compose.Nimbus
import br.com.zup.nimbus.compose.NimbusMode
import br.com.zup.nimbus.compose.layout.layoutUI

const val BASE_URL = "https://gist.githubusercontent.com/Tiagoperes/da38171c94be043c3e5b81cbb835a0e5/raw/ab832ba276764f4de12552f02c4f56e20cd83b0b"

val nimbus = Nimbus(
baseUrl = BASE_URL,
ui = listOf(layoutUI),
mode = if (BuildConfig.DEBUG) NimbusMode.Development else NimbusMode.Release,
)
  • Nimbus is the class that provides server-driven UI to the application. It holds all configurations and internal services required by Nimbus.
  • baseUrl is the url for the backend server providing the server-driven screens. Since we don’t have a backend yet, we'll use a JSON file hosted on Github (Gist).
  • ui is the list of UI libraries we intend to use in our application. A UI Library consists mainly of components (composable functions) and can also contain Nimbus actions and Nimbus operations. Since we don’t have any customization yet, we'll just use the layout library.
  • mode is a configuration to tell Nimbus which environment the app is on: if in development mode, Nimbus will render errors to the screen, otherwise, it will just not render the component with an error. Since BuildConfig is a generated file, you need to build the project before Android Studio recognizes it.

7. Use Jetpack Compose to provide the nimbus instance you just created to the UI tree. In MainActivity.kt:

// ...
import br.com.zup.nimbus.compose.ProvideNimbus

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ToDoAppTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
// Providing the nimbus configuration to the UI tree
ProvideNimbus(nimbus) {
// your content goes here. Every node from here and on will use the provided nimbus instance
Greeting("Android")
}
}
}
}
}
}

// ...

Above, we used ProvideNimbus to enable SDUI in our UI tree.

8. Now, to load a view from the backend, we just need to replace the current content (Greeting("Android")) with the NimbusNavigator.

// ...
import br.com.zup.nimbus.compose.NimbusNavigator
import br.com.zup.nimbus.compose.ProvideNimbus
import br.com.zup.nimbus.core.network.ViewRequest

// ...

ProvideNimbus(nimbus) {
// your content goes here. Every node from here and on will use the provided nimbus instance
NimbusNavigator(ViewRequest("/hello-world.json"))
}

// ...

The Nimbus Navigator will render the UI described by the JSON at “$baseUrl/hello-world.json,” i.e., “https://gist.githubusercontent.com/Tiagoperes/da38171c94be043c3e5b81cbb835a0e5/raw/ab832ba276764f4de12552f02c4f56e20cd83b0b/hello-world.json.”

9. Run the project, you should see a text saying “Hello World.”

Creating the iOS Project

  1. Use XCode to create a new project with SwiftUI.
iOS > App
Product Name: To Do App. Organization Identifier: todoapp. Interface: SwiftUI. Language: Swift.

2. Choose a location for the project and click the button to finish.

3. Select the menu option File > Add Packages. Choose the option to load the package via GitHub. Use the address “https://github.com/ZupIT/nimbus-layout-swiftui.git,” and, for the version, use “exact” typing the latest beta build (1.0.0-beta.1 at the time of writing).

Adding the dependency nimbus-layout-swiftui at 1.0.0-beta.1.

4. Now that Nimbus is installed, let’s use it to create our UI tree. Replace the content of ContentView.swift with the following code:

import SwiftUI
import NimbusSwiftUI
import NimbusLayoutSwiftUI

let baseUrl = "https://gist.githubusercontent.com/Tiagoperes/da38171c94be043c3e5b81cbb835a0e5/raw/ab832ba276764f4de12552f02c4f56e20cd83b0b"

struct ContentView: View {
var body: some View {
Nimbus(baseUrl: baseUrl) {
// construct your view hierarchy containing a navigator here
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundColor(.accentColor)
Text("Hello, world!")
}
.padding()
}
.ui([layout])
}
}
  • Nimbus is a component that provides Server Driven UI to the application. It holds all configurations and internal services required by Nimbus. Every child of this component will use this server-driven UI configuration. Each configuration other than the baseUrl is passed to Nimbus the same way we add modifiers to a SwiftUI component, i.e., with the dot operator.
  • baseUrl is the url for the backend server providing the server-driven screens. Since we don’t have a backend yet, we’ll use a JSON file hosted in Github (Gist).
  • ui is the list of UI libraries we intend to use in our application. A UI Library consists mainly of components (View structs) and can also contain Nimbus actions and Nimbus operations. Since we don’t have any customization yet, we'll just use the layout library.

5. Replace the default content with the NimbusNavigator, which loads a server-driven UI from the backend.

Nimbus(baseUrl: baseUrl) {
NimbusNavigator(url: "/hello-world.json")
}

The Nimbus Navigator will render the UI described by the JSON at “$baseUrl/hello-world.json”, i.e.,

https://gist.githubusercontent.com/Tiagoperes/da38171c94be043c3e5b81cbb835a0e5/raw/ab832ba276764f4de12552f02c4f56e20cd83b0b/hello-world.json.

6. Run the project, you should see a text saying “Hello World.”

Creating the Backend Project

The backend can either be done with plain JSONs or with a backend server that generates the JSONs. In this tutorial, we'll go with the latter. This section shows how to create a backend project for Nimbus using Node.js and TypeScript.

  1. If you don’t have Node installed, install it. Nimbus requires at least version 13.2.
  2. If you don’t have Yarn installed, install it. Yarn is a dependency management tool used by the Nimbus CLI.
npm install yarn --global

3. Install the Nimbus backend CLI:

yarn global add @zup-it/nimbus-backend-cli

4. With the CLI tool installed, change to the directory where you want the project to be created and type:

nimbus-ts new todo-app-backend

5. The project will be created under /todo-app-backend. Change to this directory and run:

yarn start

6. The backend will be running at http://localhost:3000. This address provides a default JSON with a welcome message.

Linking the Client Applications to the Backend

It suffices to alter the baseUrl of the Nimbus configuration to change the backend. Since we want to load the root address as a server-driven view, we also need to alter the navigator’s address to /.

Android: Nimbus.kt

const val BASE_URL = "http://10.0.2.2:3000"

Android: MainActivity.kt

NimbusNavigator(ViewRequest("/"))

iOS: ContentView.swift

let baseUrl = "http://127.0.0.1:3000"

struct ContentView: View {
var body: some View {
Nimbus(baseUrl: baseUrl) {
NimbusNavigator(url: "/")
}
.ui([layout])
}
}

Rerun both apps. They should now show the UI provided by our backend. This is a simple screen with a welcome message and simple navigation between two pages.

Android result on the left. iOS result on the right.

You can now alter your backend as you wish and change the app's content without rebuilding the application!

Every time you need the content to be server driven, you can add a new NimbusNavigator. If you need two different Server Driven UI configurations, you can use multiple NimbusProviders.

Editing the Backend Application To List All To Do Notes

We recommend using VS Code to edit the backend code. If you don't have it installed yet, please do so.

1. Open the backend project in VSCode

2. Since we're going to be dealing with To Do Notes, we need to first understand their structure. If you didn’t watch the video with the final app, go back and watch it. Notice that we don't simply list the notes, instead, we also group them by date. For this reason, there are two data structures we need to type: the Note itself and the NoteSection, which represents a date and the notes in it. The service to list the notes will always return a list of NoteSections and not a list of Notes. To write these types, create the file src/types.ts with the following content:

export interface NoteSection {
date: number,
items: Note[],
}

export interface Note {
id: number,
title: string,
description: string,
date: number,
isDone: boolean,
}

3. Let’s write the screen now: create the file src/screens/todo-list.tsx with the following content:

import { createState, NimbusJSX, If, Then, Else, ForEach } from '@zup-it/nimbus-backend-core'
import { sendRequest, log } from '@zup-it/nimbus-backend-core/actions'
import { Screen } from '@zup-it/nimbus-backend-express'
import { Lifecycle, Text, Column } from '@zup-it/nimbus-backend-layout'
import { NoteSection } from '../types'

export const ToDoList: Screen = () => {
const isLoading = createState('isLoading', true)
const notes = createState<NoteSection[]>('notes', [])

const loadItems = sendRequest<NoteSection[]>({
url: "https://gist.githubusercontent.com/Tiagoperes/3888902b98494708202fa05569444451/raw/dbc705192e2f87233da2f1eec35936aef8125545/todo.json",
onSuccess: response => notes.set(response.get('data')),
onError: response => log({ level: 'error', message: response.get('message') }),
onFinish: isLoading.set(false),
})

return (
<Lifecycle onInit={loadItems} state={[isLoading, notes]}>
<If condition={isLoading}>
<Then><Text>Loading</Text></Then>
<Else>
<Column>
<ForEach items={notes} key="date">
{(section) => (
<Column marginTop={5}>
<Text weight="bold">{section.get('date')}</Text>
<ForEach items={section.get('items')} key="id">
{(item) => <Text>{item.get('title')}</Text>}
</ForEach>
</Column>
)}
</ForEach>
</Column>
</Else>
</If>
</Lifecycle>
)
}

We’re not going into every detail of the backend code here, for this, we recommend the documentation. Instead, we’ll make a general explanation and link the generated JSON.

In this screen, we have two states: isLoading and notes. isLoading indicates the status of the request to the API of Notes while notes stores the result of the request.

With the action sendRequest attached to the event onInit of the component Lifecycle, we can send the request to list the notes as soon as the view is loaded. We'll not use the notes API yet. Instead, we'll load the notes from a static JSON hosted in GitHub. Once the request succeeds, we set the state notes with the response's data (body). If the request fails, we log an error message, and when the request finishes, we set the state isLoading to false.

Since this is a Screen, it returns the UI tree. For now, we have a very simple UI that checks the value of isLoading and either displays the text "Loading" or displays a List with every NoteSection and its notes. To iterate over a List, we use the component ForEach, which needs the items to iterate over and a key. The key must be the property that identifies each item. The content of a ForEach component will always be a function that receives the current item as a parameter and returns the UI for it.

4. To make this screen accessible, replace the contents of src/screens/index.ts with:

import { RouteMap } from '@zup-it/nimbus-backend-express'
import { ToDoList } from './todo-list'

export const routes: RouteMap = {
'': ToDoList,
}

5. You can now remove the boilerplate files src/screens/home.tsx and src/screens/welcome.tsx.

6. Access http://localhost:3000, it should load the JSON for the screen you just created.

7. Rerun the client app. You should see a list of To Do Notes grouped by date. The dates are still unformatted (Longs), but we'll deal with this later.

Attention: if you get a network error on Android, it’s probably because Android is complaining about using HTTP instead of HTTPS. To solve this, create the file res/xml/network_security_config.xml and add android:networkSecurityConfig="@xml/network_security_config to the tag “application” of your AndroidManifest.xml.

Checkpoint 1

You just reached the first checkpoint! You can verify your current code with the branch “part 1” of the GitHub repository. Next, we'll focus on transforming this raw data into a beautiful UI.

Check the image below:

We can use the default layout library to implement most of this UI, but there are some special components that depend on the design system of the App and must have custom implementations. In the screen above, we can notice the following custom components:

  • An icon, to show the search and delete icons.
  • A text input for the search feature.
  • A selection component to choose between the options “All,” “To Do, and “Done.”
  • A button to make the circular button with a “plus” sign.

Now that we identified which components we need to implement, we must define their contract, i.e., namespace, name, and properties. As the namespace, we’ll use todoapp. See the list below for the name and properties of each component:

icon

  • name: a string containing the identifier for the icon.
  • color: an optional string telling the color of the icon.
  • size: an optional integer telling the size of the icon.

textInput

  • label: a string containing the label of the input.
  • value: an optional string with the current value of the input.
  • color: an optional string with the color of the input.
  • onChange: an event, i.e., a property that must receive a list of Nimbus Actions, which will be run once the event is triggered. This event is always triggered with the current value as a parameter.

selectionGroup

  • options: a list of strings with every option available.
  • value: a string with the current value must be one of the options.
  • onChange: an event, i.e., a property that must receive a list of Nimbus Actions, which will be run once the event is triggered. This event is always triggered with the current value as a parameter.

circularButton

  • icon: a string containing the identifier for the icon to show inside the button.
  • onPress: an event, i.e., a property that must receive a list of Nimbus Actions, which will be run once the event is triggered.

Now that we have defined every component to build the main screen of our To Do App, let’s implement them in each one of the projects! Keep in mind that it is not the goal of this tutorial to teach SwiftUI, Compose, or JSX, so we'll only focus on the code regarding Nimbus.

Custom Components on Android

  1. Create a package called component and, under it, create the files: Icon.kt, TextInput.kt, SelectionGroup.kt, and CircularButton.kt.
  2. All components follow the same implementation logic. Create the composable function just like any other composable component and then annotate it with @AutoDeserialize. A difference to common composable functions is that you shouldn't use default values other than null in the function parameters (this is due to the inability of an annotation processor to see the default value of a parameter).
  3. Let’s see the implementation for the TextInput:
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import br.com.zup.nimbus.annotation.AutoDeserialize

@Composable
@AutoDeserialize
fun TextInput(
label: String,
value: String? = null,
onChange: (value: String) -> Unit,
color: Color? = null,
) {
val textFieldColor = color ?: Color.DarkGray
TextField(
value = value ?: "",
onValueChange = onChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
textColor = textFieldColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedLabelColor = textFieldColor,
unfocusedLabelColor = textFieldColor,
cursorColor = textFieldColor,
)
)
}

As you can see, besides the annotation, we don't need anything special to make this component compatible with server-driven views. Nimbus annotation processor will check every parameter and, in build time, generate an intermediary composable that is able to deserialize all properties from the JSON into the types required by the composable function you implemented.

Notice that, for events, we'll always have lambdas. In this case, where the event provides a value, the lambda must have a parameter.

If you try to run this code, you'll get an error from the annotation processor regarding the type Color. Nimbus can figure out how to deserialize primitive types and classes consisting of properties with primitive types. This is not the case for Color, so we must create a custom deserializer for it.

4. To create a custom deserializer for Color, add the file ColorDeserializer.kt to a new package called deserialization. This file must contain the following code:

import androidx.compose.ui.graphics.Color
import br.com.zup.nimbus.annotation.Deserializer
import br.com.zup.nimbus.compose.layout.extensions.color
import br.com.zup.nimbus.core.deserialization.AnyServerDrivenData

@Deserializer
fun deserializeColor(data: AnyServerDrivenData): Color? = data.asStringOrNull()?.color

A deserializer is a function annotated with @Deserializer that receives an object of unknown type wrapped in the class AnyServerDrivenData. This object can be read into the expected type and converted. In this case, we just used an extension function of the layout package that transforms a string into a Color object.

Now that the deserializer is created, we can use the type Color? whenever we need it!

5. In Nimbus.kt, create a new UI Library to register the newly created component (TextInput):

import androidx.compose.runtime.Composable
import br.com.zup.nimbus.compose.Nimbus
import br.com.zup.nimbus.compose.NimbusMode
import br.com.zup.nimbus.compose.layout.layoutUI
import br.com.zup.nimbus.compose.ui.NimbusComposeUILibrary
import com.todoapp.component.TextInput

const val BASE_URL = "http://10.0.2.2:3000"

private val todoAppUI = NimbusComposeUILibrary("todoapp")
.addComponent("textInput") @Composable { TextInput(it) }

val nimbus = Nimbus(
baseUrl = BASE_URL,
ui = listOf(layoutUI, todoAppUI),
mode = if (BuildConfig.DEBUG) NimbusMode.Development else NimbusMode.Release,
)

Notice that we created a new UI library with the namespace todoapp. We’re going to add every component, operation, and action we create to this UI Library. We also added it to the Nimbus Configuration.

Attention: TextInput(it) calls a code that is generated at build time. For the IDE to recognize it, rebuild the project.

6. The TextInput is the most complex of the components we need to implement, mainly because it requires a custom deserializer. All other components are straightforward and won't be explained here. Use the links below to copy and paste the code to your project.

Custom Components on iOS

  1. Create a new folder called Components and, under it, create the files Icon.swift, TextInput.swift, SelectionGroup.swift, and CircularButton.swift.
  2. All components follow the same implementation logic. Create the SwiftUI component just like any other View struct, make it conform to Decodable, and add Property Wrappers to events if they exist in the component. The only difference to common SwiftUI components is that you should not use default values for the properties of the struct.
  3. Let’s see the implementation for the TextInput:
import SwiftUI
import NimbusSwiftUI

private let defaultColor = Color(red: 97/255, green: 107/255, blue: 118/255)

struct TextInput: View, Decodable {
var label: String
var value: String?
var color: Color?
@StatefulEvent var onChange: (String) -> Void

var body: some View {
let binding = Binding(
get: { value ?? "" },
set: {
onChange($0)
}
)
VStack(alignment: .leading) {
if (color == nil) {
TextField(label, text: binding).foregroundColor(defaultColor)
} else {
ZStack(alignment: .leading) {
if (value?.isEmpty != false) { Text(label).foregroundColor(color) }
TextField("", text: binding)
.foregroundColor(color)
.accentColor(color)
}
}
}.padding(16)
}
}

A TextField from SwiftUI expects a binding. Nimbus doesn't work with the concept of bindings, but a binding is nothing more than the combination of a readable value and a function to alter the next value to read, i.e., the properties value and onChange of our component. To comply with SwiftUI's TextField, we create a binding from these properties.

Another interesting part of the code is the property wrapper @StatefulEvent. Whenever we have a property that represents an event, we must annotate it with @Event or @StatefulEvent depending if the event is parameterized or not.

If you’re also checking the code for Android, you'll notice that we didn't create a special deserializer for the type Color. This is because the layout package already handles this type for us.

4. In ContentView.swift creates a new UI Library to register the newly created component (TextInput):

import SwiftUI
import NimbusSwiftUI
import NimbusLayoutSwiftUI

let baseUrl = "http://127.0.0.1:3000"

let todoAppUI = NimbusSwiftUILibrary("todoapp")
.addComponent("textInput", TextInput.self)

struct ContentView: View {
var body: some View {
Nimbus(baseUrl: baseUrl) {
NimbusNavigator(url: "/")
}
.ui([layout, todoAppUI])
}
}

Notice that we created a new UI library with the namespace todoapp. We’re going to add every component, operation, and action we create to this UI Library. We also added it to the Nimbus Configuration.

The TextInput is the most complex of the components we need to implement. All other components are straightforward and won’t be explained here. Use the links below to copy and paste the code to your project.

Custom Components for the Backend

If you're not using any backend tool for writing the JSON, then you don’t need to do anything, you can refer to the components we just implemented by using the format namespace:name, e.g., todoapp:textInput.

Since we're using a Node Backend, we need to use the Nimbus Backend Typescript library to declare these components as JSX tags.

Create the file TextInput.tsx under a new directory called components. Use the following code for its content:

import { NimbusJSX, FC, Actions, Expression, State, createStateNode } from '@zup-it/nimbus-backend-core'
import { namespace } from '../constants'

interface Props {
label: Expression<string>,
value?: Expression<string>,
color?: Expression<string>,
onChange: (value: State<string>) => Actions,
}

export const TextInput: FC<Props> = ({ id, state, onChange, ...props }) => <component
namespace={namespace}
name="textInput"
id={id}
state={state}
properties={{ onChange: onChange(createStateNode('onChange')), ...props }}
/>

If you ever worked with React, this is not too different. We first declare the component's type, and then we declare a Functional Component (FC) that builds the UI for it.

The interface, Props, includes some new types. Let's see what they mean:

  • Expression<T>: this value can be either of type T or an expression in Nimbus Script that results in something of type T.
  • State<T>: this value must be a state that holds a value of type T.
  • Actions: a Nimbus Action or a list of Nimbus Actions.

If you're not sure what are states, actions, or expressions, please, check our documentation.

In HTML, we have a lot of intrinsic elements: div, p, table, span, etc. In Nimbus JSX, on the other hand, we have just one: component, which is the main tag for Nimbus and is transformed into a server-driven component when serialized into JSON. Example of a serialized component: _component: todoapp:textInput.

namespace is a constant imported from another file. Please create the file src/constants.ts and set the value of namespace to todoapp.

The function createStateNode(name: string) just creates a State object that refers to the name passed as a parameter. When you use the State object created with this function, it gets serialized into @{name} in the JSON.

All other components are created using the same logic: create the component's contract with a type definition and declare its namespace and name. Use the links below to copy and paste the code for each component of your project.

Creating the UI for the Header in the Backend

Now that we have all the components we need implemented, we can use them to build the UI in the backend. Open the file src/screens/todo-list.tsx and let's start by creating the header:

Here we have a Row with three items centered in the vertical axis: an Icon, a TextInput, and a SelectionGroup. After placing the icon and selection group, the text input expands in the horizontal axis to fill up the remaining space.

We need two new states: one to keep track of the search term typed into the search field and another to store the visibility option (all, to do, or done). We'll name these states searchTerm and doneFilter, respectively. Check the code below for creating these states:

const searchTerm = createState('searchTerm', '')
const doneFilter = createState<'All' | 'To do' | 'Done'>('doneFilter', 'All')

The first parameter of createState is the state name, and the second is the initial value. The generics (<>) tells the state type and must be explicitly declared only when inferring it from the second parameter is impossible.

We use the default layout components plus our custom components to declare the UI for the header:

const header = (
<Row backgroundColor="#5F72C0" crossAxisAlignment="center" paddingHorizontal={20} height={65}>
<Icon name="search" color="#FFFFFF" size={28} />
<Row width="expand">
<TextInput color="#FFFFFF" label="Search" value={searchTerm} onChange={value => searchTerm.set(value)} />
</Row>
<SelectionGroup options={["All", "To do", "Done"]} value={doneFilter} onChange={value => doneFilter.set(value)} />
</Row>
)

To learn more about the layout components and their properties, please check this link.

Now that we declared the states and header, we can use them in the return value of the function:

return (
<ScreenComponent safeAreaTopBackground="#5F72C0" statusBarColorScheme="dark">
<Lifecycle onInit={loadItems} state={[isLoading, notes, searchTerm, doneFilter]}>
<If condition={isLoading}>
<Then><Text>Loading</Text></Then>
<Else>
<Column height="expand" width="expand" backgroundColor="#F1F3F5">
{header}
<ForEach items={notes} key="date">
{(section) => (
<Column marginTop={5}>
<Text weight="bold">{section.get('date')}</Text>
<ForEach items={section.get('items')} key="id">
{(item) => <Text>{item.get('title')}</Text>}
</ForEach>
</Column>
)}
</ForEach>
</Column>
</Else>
</If>
</Lifecycle>
</ScreenComponent>
)

Notice that, besides adding the header and states, we’ve also wrapped our content in a ScreenComponent and added some style to the first column under the component Else.

Save the backend files and run the client apps. They should now show the header you just created.

iOS result on the left. Android result on the right.

If you want to check the JSON version of the backend for this screen, please, visit this link.

Creating the UI for Each To Do Note

Let’s now transform this ugly list into something nice to look at!

Each note must look like this:

This is a row with three elements vertically centered:

  • A circle indicates whether the note is done or not. If the note is done, it should be filled with a background. Otherwise, it should only have a border. Clicking the circle toggles the note status.
  • A column with two texts: the title and the note's description. This column expands in the horizontal axis to fill the space left after the other two elements are placed.
  • A red icon with a trash bin.

Instead of writing this UI directly in todo-list.tsx, let’s create a new JSX Element (composite component) called NoteCard. We are not required to separate this component from the rest of the code, but since this is a very well-defined piece of the UI, we'll use it to demonstrate how to compose multiple JSX components into a single one, making the code easier to read and reuse. This is not a new server-driven component, it's just a composition of the server-driven components we already have. For this reason, let's place it under a new directory called fragments.

fragments/NoteCard.tsx

import { NimbusJSX, FC, State, condition, not, If, Then, isEmpty, Actions } from '@zup-it/nimbus-backend-core'
import { Column, Row, Text, Touchable } from '@zup-it/nimbus-backend-layout'
import { Icon } from '../components/Icon'
import { Note } from '../types'

interface Props {
value: State<Note>,
}

export const NoteCard: FC<Props> = ({ value }) => (
<Row crossAxisAlignment="center" paddingVertical={12} paddingHorizontal={20} backgroundColor="#FFFFFF" minHeight={60}>
<Touchable onPress={value.get('isDone').set(not(value.get('isDone')))}>
<Column
borderColor={condition(value.get('isDone'), '#5F7260', '#E0E4E9')}
backgroundColor={condition(value.get('isDone'), '#CDD3EB', '#FFFFFF')}
borderWidth={2}
cornerRadius={14}
width={22}
height={22}
/>
</Touchable>
<Column marginHorizontal={20} width="expand">
<Text weight="bold" color="#616B76">{value.get('title')}</Text>
<If condition={not(isEmpty(value.get('description')))}>
<Then>
<Column marginTop={8}><Text color="#85919C">{value.get('description')}</Text></Column>
</Then>
</If>
</Column>
<Icon name="delete" size={20} color="#F00000" />
</Row>
)

We declare a composite component just like any other component, but instead of using the tag component, we use components that we have previously defined.

In the code above, it is important to notice the use of Nimbus Operations. We used the operations not, condition, and isEmpty to create the toggle (done/undone) and to check if the note has a description or not. Operations, in Nimbus, are part of the Nimbus Script specification, and they're used to transform the value of a state.

Now that we created the UI for the NoteCard, we can replace the text with the Note's title on the screen for listing the notes:

<ForEach items={section.get('items')} key="id">
{(item) => <NoteCard value={item} />}
</ForEach>

To run the app with these changes, reload the app so it can get the latest JSON from the backend.

We need something to separate one note from another. To do so, let's create another composite component that renders a grey line and use it between each list item.

fragments/separator.tsx

import { NimbusJSX } from "@zup-it/nimbus-backend-core"
import { Column } from "@zup-it/nimbus-backend-layout"

export const Separator = () => <Column height={1} width="expand" backgroundColor="#E0E4E9" />

screens/todo-list.tsx

<Text weight="bold">{section.get('date')}</Text>
<Separator />
<ForEach items={section.get('items')} key="id">
{(item) => (
<>
<NoteCard value={item} />
<Separator />
</>
)}
</ForEach>

Much better!

Now that the notes occupy a much larger vertical space, we should add a ScrollView to make the content scrollable:

<Column height="expand" width="expand" backgroundColor="#F1F3F5">
{header}
<ScrollView>
<ForEach items={notes} key="date">
{ /* ... */ }
</ForEach>
</ScrollView>
</Column>

The only problem now is the date; it should be formatted into something readable.

Formatting the Date

To format the date, we need to use a custom operation. This operation must receive the date as a Long (Unix Timestamp in milliseconds) and return it as a String. We will use the format dd/MM/yyyy as the string representation for the date.

The contract for the custom operation is formatDate(Long): String, i.e., it's named formatDate, receives a Long as a parameter and returns a String.

Implementing formatDate on Android

Create the file formatDate.kt under a new package called operation:

import br.com.zup.nimbus.annotation.AutoDeserialize
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone

const val DATE_FORMAT = "dd/MM/yyyy"

@AutoDeserialize
internal fun formatDate(dateInMilliseconds: Long): String {
val formatter = SimpleDateFormat(DATE_FORMAT, Locale.ENGLISH)
formatter.timeZone = TimeZone.getTimeZone("UTC")
val dateTime = Date(dateInMilliseconds)
return formatter.format(dateTime)
}

To make the function formatDate available as an operation to Nimbus, we annotate it with @AutoDeserialize and register it to the UI Library:

Nimbus.kt

private val todoAppUI = NimbusComposeUILibrary("todoapp")
// ...
.addOperation("formatDate") { formatDate(it) }

The @AutoDeserialize annotation is responsible for generating the code called by the line above. For this reason, just like the components, this will be available only after the project is built.

Implementing formatDate on iOS

Create the file FormatDate.swift under a new directory called Operations.

import Foundation
import NimbusSwiftUI

struct FormatDate: OperationDecodable {
static var properties = ["timeMillis"]

var timeMillis: Int

func execute() -> String? {
let date = Date(timeIntervalSince1970: TimeInterval(timeMillis / 1000))
let formatter = DateFormatter()
formatter.dateFormat = "dd/MM/yyyy"
formatter.timeZone = TimeZone(abbreviation: "UTC")
return formatter.string(from: date)
}
}

An operation is implemented on iOS with a struct that conforms to OperationDecodable. The required array properties must contain the identifiers for each parameter received by the operation, in order. The method, execute, is what actually runs when the operation is called by Nimbus. In this case, it must return a string.

Just like components, we need to register it to a UI Library:

ContentView.swift

let todoAppUI = NimbusSwiftUILibrary("todoapp")
// ...
.addOperation("formatDate", FormatDate.self)

Declaring formatDate for the backend

If you're using only JSON, you can just call the operation inside an expression. Example: @{formatDate(date)}. In the backend for Node, we need to declare this operation. For this, create the file format-date.ts under the new directory src/operations.

import { Expression, Operation } from "@zup-it/nimbus-backend-core"

export const formatDate = (time: Expression<number>) =>
new Operation<string>('formatDate', [time])

To declare an operation in the backend, we create a function that receives the parameters for the operation and returns an object of type Operation<T>, where T is the operation's return type. The constructor for Operation receives the name of the operation (1st) and its parameters in a list (2nd). When serializing the UI into JSON, an Operation is transformed into a string in the format @{operationName(param1, param2, …)}.

Using formatDate

Now we can just use formatDate whenever we need to display a Long as a Date string in the backend.

screens/todo-list.tsx

<Column paddingVertical={12} paddingHorizontal={20}>
<Text size={16} color="#616B76">{formatDate(section.get('date'))}</Text>
</Column>

Note that, in addition to formatting the date, we added some spacing for the text by wrapping it in a column. We also added some styling by setting a font size and a color. The dates should be formatted if you rerun the client app (as seen in the images below).

Android result on the left. iOS result on the right.

Checkpoint 2

Cool! You just reached the second checkpoint! You can validate your code against this branch in GitHub. Next, we'll create the filters and the modal to edit a To Do Note.

The Search Filter

We previously implemented the UI for the header, which includes the search bar and its state. Unfortunately, it doesn’t do anything yet. Let’s fix this!

The text we type into the TextInput component will always be reflected in the value of the state searchTerm. To filter the notes by this text, we can just apply some kind of filter using this state.

To create this filter, we simply use the component If to render the notes conditionally. If the note contains the value of searchTerm, it gets rendered. Otherwise, it doesn't.

First, let's create a function that receives both the note and the search term and decides whether it should be rendered. In screens/todo-list.tsx (backend), create a new function called shouldRender:

function shouldRender(note: State<Note>, searchTerm: State<string>) {
const lowerSearchTerm = lowercase(searchTerm)
const lowerTitle = lowercase(note.get('title'))
const lowerDescription = lowercase(note.get('description'))
const titleMatches = contains(lowerTitle, lowerSearchTerm)
const descriptionMatches = contains(lowerDescription, lowerSearchTerm)
return or(titleMatches, descriptionMatches)
}

This is a very easy-to-read function. All Operations(lowercase, contains, or) are shipped with Nimbus and can be imported from @zup-it/nimbus-backend-core. We first transform all strings to lowercase to make a case-insensitive comparison. We test both the title and the description because we want to find the search term in any of them.

Now we can use this function to verify if the note should be rendered or not:

<ForEach items={section.get('items')} key="id">
{(item) => (
<If condition={shouldRender(item, searchTerm)}>
<NoteCard value={item} />
<Separator />
</If>
)}
</ForEach>

Here, we just replaced the previous <> with the pair <If><Then>. Since we don’t want to render anything if the condition is not met, we don't need an Else.

Reload your app. The search bar should be working now!

The “All,” “To Do,” and “Done” Filters

Another part that doesn’t do anything yet is the component SelectionGroup. It should show only tasks that are done when "Done" is selected and only tasks that are not done when "To Do" is selected. "All" is the default option and doesn't filter anything.

With the implementation of the search field in mind, it becomes quite clear what we need to do to make it work. The filter's value is at the state doneFilter, we just need to change the function shouldRender so it takes into account the new filter.

function shouldRender(note: State<Note>, searchTerm: State<string>, doneFilter: State<'All' | 'To do' | 'Done'>) {
const lowerSearchTerm = lowercase(searchTerm)
const lowerTitle = lowercase(note.get('title'))
const lowerDescription = lowercase(note.get('description'))
const titleMatches = contains(lowerTitle, lowerSearchTerm)
const descriptionMatches = contains(lowerDescription, lowerSearchTerm)
const matchesText = or(titleMatches, descriptionMatches)
const matchesDone = and(eq(doneFilter, 'Done'), note.get('isDone'))
const matchesToDo = and(eq(doneFilter, 'To do'), not(note.get('isDone')))
const matchesDoneFilter = or(eq(doneFilter, 'All'), matchesDone, matchesToDo)
return and(matchesDoneFilter, matchesText)
}

Don't forget to fix the call to shouldRender:

<If condition={shouldRender(item, searchTerm, doneFilter)}>

Now, the condition to render is also linked to the state doneFilter. If you reload the app, all filters will apply correctly.

Opening a Modal To Edit a Note

We need to be able to edit a note. For this, we must make the title and description of a NoteCard clickable. Once the note is clicked, a modal for editing should be opened.

First, let's create a new property in NoteCard: onShowEditModal. This property is of type Actions and represents the actions to run when the edit modal is supposed to appear.

fragments/NoteCard.tsx

interface Props {
value: State<Note>,
onShowEditModal: Actions,
}

export const NoteCard: FC<Props> = ({ value, onShowEditModal }) => (
{ /* ... */ }
)

Now, we must make it so. When the text in a note is clicked, it calls the actions in onShowEditModal. To do this, we wrap the text components in a Touchable.

<Column marginHorizontal={20} width="expand">
<Touchable onPress={onShowEditModal}>
<Text weight="bold" color="#616B76">{value.get('title')}</Text>
<If condition={not(isEmpty(value.get('description')))}>
<Then>
<Column marginTop={8}><Text color="#85919C">{value.get('description')}</Text></Column>
</Then>
</If>
</Touchable>
</Column>

The NoteCard is now ready to receive an action to run when the text is clicked.

In the end, we want to open a modal when the NoteCard is clicked. So, let's create the modal in the new file: screens/edit-note.tsx:

import { NimbusJSX } from '@zup-it/nimbus-backend-core'
import { Screen, ScreenRequest } from '@zup-it/nimbus-backend-express'
import { Column, Text, Touchable } from '@zup-it/nimbus-backend-layout'
import { Note } from '../types'

interface EditNoteScreenRequest extends ScreenRequest {
state: {
note: Note,
},
}

export const EditNote: Screen<EditNoteScreenRequest> = ({ getViewState, navigator }) => {
const note = getViewState('note')

return (
<Column width="expand" height="expand" backgroundColor="#F1F3F5" mainAxisAlignment="center" crossAxisAlignment="center">
<Text color="#616B76" size={18} weight="bold">Editing "{note.get('title')}"</Text>
<Touchable onPress={navigator.dismiss()}>
<Text>Cancel</Text>
</Touchable>
</Column>
)
}

This is a very simple modal that prints the title of the note we are editing and a button to close the modal.

The edit screen accepts a state parameter. For this reason, we must correctly type it with generics. Once a screen accepts a state parameter, one can use the function getViewState to access it.

Besides creating the screen file, we must also register it in screens/index.tsx.

import { RouteMap } from '@zup-it/nimbus-backend-express'
import { EditNote } from './edit-note'
import { ToDoList } from './todo-list'

export const routes: RouteMap = {
'': ToDoList,
'/edit-note': EditNote,
}

Let's edit the ToDoList (screens/todo-list.tsx) screen to open the modal every time a note is clicked. First, we need to import the EditNote screen and inject the navigator.

import { EditNote } from '../screens/edit-note'

export const ToDoList: Screen = ({ navigator }) => {
// ...
}

Now, in the call to NoteCard, we tell the component to navigate to EditNote on the event onShowEditModal.

<NoteCard value={item} onShowEditModal={navigator.present(EditNote, { state: { note: item } })} />

There are two ways of navigating forward in Nimbus: present and push. The first opens the next view on top of the current, like a modal. The second replaces the current view with the next.

The first parameter of forward navigation will always be the screen, while the second is an options object. If the next screen expects one or more states, the values of these states must be informed.

If you restart the app, it will now open the modal when the text in a note is clicked.

Creating the UI for the EditNote Screen

This step is very similar to what we did when we built the main screen (ToDoList). Instead of explaining every step, I will focus on the differences between implementing the two screens.

EditNote screen on iOS (left) and Android (right).

The screen is composed of the custom components:

textInput

datePicker (new)

  • value: a long with the input's current value in Unix time format (milliseconds, UTC).
  • onChange: an event, i.e., a property that must receive a list of Nimbus Actions, which will be run once the event is triggered. This event is always triggered with the current value as a parameter.

button (new)

  • text: a string with the text that goes inside the button.
  • primary: an optional boolean that tells if this button is the most important on the screen.
  • onPress: an event, i.e., a property that must receive a list of Nimbus Actions, which will be run once the event is triggered.

The textInput, although not new, is very different than the one we already have. It has different margins and also a label on iOS. There are two approaches to solve this problem, and both are correct:

  1. Create two textInput components, one for the search field and another for forms.
  2. Create some properties in the textInput to allow its style to attend both scenarios.

We chose the second option in this project and created a new boolean property called header.

Besides editing the textInput with the new option, we must create and register the component’s datePicker and button.

We won't go into the details on implementing any of these components because they're all analogous to everything we’ve done for the components in the first screen. Here are the source codes so you can copy and paste them into your project:

Android

iOS

Backend

Once we have all custom components implemented, we need to write the code for the screen.

screens/edit-note.tsx

import { createState, NimbusJSX } from '@zup-it/nimbus-backend-core'
import { Screen, ScreenRequest } from '@zup-it/nimbus-backend-express'
import { Column, Row, Text } from '@zup-it/nimbus-backend-layout'
import { DatePicker } from '../components/DatePicker'
import { Button } from '../components/Button'
import { TextInput } from '../components/TextInput'
import { Note } from '../types'

interface EditNoteScreenRequest extends ScreenRequest {
state: {
note: Note,
}
}

export const EditNote: Screen<EditNoteScreenRequest> = ({ getViewState, navigator }) => {
const note = getViewState('note')
const title = createState('title', note.get('title'))
const description = createState('description', note.get('description'))
const date = createState('date', note.get('date'))

return (
<Column state={[title, description, date]} width="expand" height="expand" backgroundColor="#F1F3F5" mainAxisAlignment="center" crossAxisAlignment="center">
<Text color="#616B76" size={18} weight="bold">Editing note</Text>
<Column margin={30} padding={20} backgroundColor="#FFFFFF" borderColor="#E0E4E9" borderWidth={1} cornerRadius={8}>
<TextInput label="Title" value={title} onChange={value => title.set(value)} />
<Column marginVertical={10}>
<TextInput label="Description" value={description} onChange={value => description.set(value)} />
</Column>
<DatePicker label="Date" value={date} onChange={(newValue) => date.set(newValue)} />
<Row marginTop={20} width="expand" mainAxisAlignment="center">
<Column marginEnd={20}>
<Button onPress={navigator.dismiss()}>Cancel</Button>
</Column>
<Button primary onPress={navigator.dismiss()}>
Save
</Button>
</Row>
</Column>
</Column>
)
}

The most important part of this code are the states and what we do with them. We declare three different states, one for each field in the form, and initialize them with their respective values in the note received as parameter. We then link each state to a form element using the properties value and onChange. This way, when interacting with the form, we don't directly alter the note. So if we press "cancel," no change is persisted.

Both buttons do the same thing: they dismiss the modal. There are two ways of performing back navigation in Nimbus: dismiss() dismisses the modal view while pop() removes the last screen added to the stack via push(screen).

The UI for Creating a New Note

First, let's add a floating button with a plus icon on the bottom right corner of the ToDoList screen.

Screenshot of the button to create a new note

We've already created this component in the last part of the tutorial: it's called circularButton. To place it in this stacked position, we must use the layout components "stack" and "positioned". The stack will contain the header and the body as the background layer and the circular button as the foreground layer, positioned at the bottom right. See the code below:

screens/todo-list.tsx

<Stack height="expand" width="expand" backgroundColor="#F1F3F5">
<Positioned>
{header}
{body}
</Positioned>
<Positioned alignment="bottomEnd" margin={28}>
<CircularButton
icon="plus"
onPress={navigator.present(EditNote, { state: { note: emptyNote() } })}
/>
</Positioned>
</Stack>

We replaced a Column with the pair Stack-Positioned and added the CircularButton. We also moved the whole body of the screen to a new variable called body.

We can reuse the modal to edit a note to implement the "create a note" feature. The only difference is that instead of passing an existing note to the modal, we'll create a new one. The implementation of emptyNote() is as follows:

function emptyNote(): Note {
return {
id: 0,
date: new Date().setUTCHours(0, 0, 0, 0),
title: '',
description: '',
isDone: false,
}
}

If you rerun the app, there should be a new button to create a note, and it should open the EditNote screen with an empty form.

Checkpoint 3

You've just reached the third checkpoint! You can check your progress with the source code here. Next, we'll connect our UI with the REST API.

The To-Do Note API

Before connecting the app to the API, let's get to know it!

  • POST /notes/auth: creates a unique key so you can start using the API. It also bootstraps the list of notes with the example we've been using so far. Every other endpoint in this API requires the key returned in the body of this request to be passed as the value of the header key.
  • GET /notes: lists all notes grouped by date (NoteSection). The Note Sections are ordered by date (descending). We'll use this to replace the gist file (JSON) we've been using.
  • PUT /notes: updates the note passed in the request's body (JSON). The response's body is the same as GET /notes, but updated.
  • POST /notes: creates the note passed in the request's body (JSON). The response's body is the same as GET /notes, but updated.
  • DELETE /notes/{id}: removes the note with the id informed in the route. The body of the response is the same as GET /notes, but updated.

Important: this API should only be used for testing purposes. The notes are not encrypted and can be wiped at any time.

The API is located at "https://beagle-playground.continuousplatform.com/notes." Let’s first use a Rest Client or the terminal to create a key. Here's the cURL you need:

curl --request POST \
--url https://beagle-playground.continuousplatform.com/notes/auth

Now, copy the key returned in the response and save it somewhere you can recover later.

{
"key": "copy the value of this string"
}

Listing the Notes in the App via the API

First, we need a safe way to store our API key in the backend project. For this, we'll use the dependency dotenv. In a terminal window, go to the directory of your backend project and install it.

yarn add dotenv

Open the file src/index.ts, import dotenv, and call dotenv.config().

import * as dotenv from 'dotenv'

dotenv.config()

Now, let's create our env file in the root directory. It must be called ".env" and have the following content:

TODO_API_KEY="the key you copied in the last section"

A .env file should never be published. Your .gitignore file is already set up to ignore it, so there is no need to worry.

Open the file src/constants.ts and add the following code to it:

export const todoAPIUrl = 'https://beagle-playground.continuousplatform.com/notes'
export const todoAPIKey = () => process.env.TODO_API_KEY ?? ''

todoAPIKey is a function that gets the value specified for the variable TODO_API_KEY in the .env file.

Now, let's replace the request to GitHub with a request to our API. Open the file src/screens/todo-list.tsx and edit the properties url and headers of the action sendRequest:

import { todoAPIKey, todoAPIUrl } from '../constants'

// ...

const loadItems = sendRequest<NoteSection[]>({
url: todoAPIUrl,
headers: { key: todoAPIKey() },
onSuccess: response => notes.set(response.get('data')),
onError: response => log({ level: 'error', message: response.get('message') }),
onFinish: isLoading.set(false),
})

If your server was already running, you must restart it.

Rerun the client app. It should work just like before, but it's now accessing a real API!

Editing a Note

Although we have the UI for editing a to-do note, it doesn’t work yet. We have to make it so that when the user presses the "save" button, a request is made to the API to update it. Moreover, we need to update the list of notes on the screen behind the modal and then close the modal.

Since the request to update a to-do note also returns the updated list of notes, we can use its response to update the UI.

In summary, here's the dynamic between the main screen (note list) and the edit modal:

  • The main screen navigates to the modal, passing the note that must be edited as a state parameter.
  • The edit screen presents a form capable of editing the note's title, description, and date.
  • When the user saves the edited note, a request is made to the API.
  • The response to the request must be returned to the main screen, which must use to update the list.

This is a two-way communication between the screens. The first part (main screen to modal) is already implemented via state parameters. For the second part, we'll use a feature of Nimbus Navigation called "Screen events." While state parameters on navigation allow communication from the source screen to the target screen, screen events allow communication in the opposite direction.

A Screen event is an event that can be triggered by the next screen, and just like any event, it accepts Nimbus Actions.

Open the file screens/edit-note.tsx, and let's edit the screen's interface by adding a new event:

interface EditNoteScreenRequest extends ScreenRequest {
state: {
note: Note,
},
events: {
onSaveNote: NoteSection[],
}
}

This means the screen EditNote can trigger the event onSaveNote. It also means the event onSaveNote receives a parameter of type NoteSection[], i.e., the updated list of notes.

The idea is to, when the request to save a note succeeds, trigger the screen event onSaveNote with the response of the request. Let's implement this!

First, add some imports to edit-note.tsx:

import { log, sendRequest } from '@zup-it/nimbus-backend-core/actions'
import { Note, NoteSection } from '../types'
import { todoAPIKey, todoAPIUrl } from '../constants'

Now, inject the function triggerViewEvent into the screen:

export const EditNote: Screen<EditNoteScreenRequest> = ({ getViewState, navigator, triggerViewEvent }) => {
// ...
}

Create the action to save a note, i.e., to send the request and trigger onSaveNote once it succeeds.

const save = sendRequest<NoteSection[]>({
url: todoAPIUrl,
method: 'Put',
headers: { key: todoAPIKey() },
data: { id: note.get('id'), title, description, date, isDone: note.get('isDone') },
onSuccess: (response) => [
triggerViewEvent('onSaveNote', response.get('data')),
navigator.dismiss(),
],
onError: (error) => log({ level: 'error', message: error.get('message') }),
})

Just like we used sendRequest before to fetch the notes, we use it here to save a note. In data, we pass the request body, which consists of the data of the note we’re editing. Since id and isDone can't be edited through the form; we get their original value. All other values we get from the states are linked to the form. When the request fails, we log the error message. When the request succeeds, we trigger the screen event onSaveNote with the response body of the request, i.e., the list of notes after the update. We also dismiss the modal on successful requests.

Now, make the modal's primary button call the action we just created:

<Button primary onPress={save}>Save</Button>

Open screens/todo-list.tsx to make the navigation to EditNote respond to the new event onSaveNote.

<NoteCard
value={item}
onShowEditModal={navigator.present(EditNote, {
state: { note: item },
events: { onSaveNote: updatedNotes => notes.set(updatedNotes) },
})}
/>

We used the event parameter to update the state notes, which rerenders the list of notes with the newest value.

If you reload the client app now, the edit note feature will work as intended! It's nice to notice that we've made no changes to the Android and iOS apps!

Removing a Note

Open the NoteCard component (fragments/NoteCard.tsx). Add a new event property called onRemove:

interface Props {
value: State<Note>,
onShowEditModal: Actions,
onRemove: Actions,
}

export const NoteCard: FC<Props> = ({ value, onShowEditModal, onRemove }) => (
// ...
)

Make the remove icon call this event when clicked:

<Touchable onPress={onRemove}>
<Icon name="delete" size={20} color="#F00000" />
</Touchable>

Open the ToDoList screen (screens/todo-list.tsx) and create the action to remove a note by calling the API:

const removeNote = (id: Expression<number>) => sendRequest<NoteSection[]>({
url: `${todoAPIUrl}/${id}`,
method: 'Delete',
headers: { key: todoAPIKey() },
onSuccess: response => notes.set(response.get('data')),
onError: response => log({ level: 'error', message: response.get('message') }),
})

Considering this action depends on the id of the note we want to delete, we created a function that returns an action instead of creating the action directly.

Now, use the new property on NoteCard (onRemove) to call this action:

<NoteCard
value={item}
onRemove={removeNote(item.get('id'))}
onShowEditModal={navigator.present(EditNote, {
state: { note: item },
events: { onSaveNote: updatedNotes => notes.set(updatedNotes) },
})}
/>

Reload the client app. The remove feature should now be working!

Checkpoint 4

Congratulations, you made it to the final checkpoint! Use this code base to verify your progress.

Other Features

Creating a note is analogous to editing a note while persisting its "done/undone" status is similar to removing it. I leave both as exercises for the reader.

Other good exercises to enhance the experience of the app are:

  • Replace the text "loading" when loading the list of notes with a graphical indicator, like a spinner.
  • Show floating error messages when a request fails.

These exercises can be implemented with what we've learned throughout this tutorial.

The main branch of the repository for this tutorial contains these features implemented if you want to check them out.

We’ve finally completed the To Do app! As you’ve seen, most of the work for the application lies on the backend, while in the frontend, all we do is declare our components, actions, and operations.

I hope you enjoyed this first step of working with Nimbus. Nimbus is an open source project, and we’d love your feedback! Please, feel free to open new issues in our repository and help us with our backlog!

Acknowledgements

I’d like to thank Daniel Tes, Hernand Dos Santos Azevedo and Arthur Bleil. They are all developers of Nimbus SDUI, and they gave me a lot of help on build this sample app.

--

--