DEV Community

Cover image for Building a subscription tracker Desktop and iOS app with compose multiplatform - Part 1
Daniel Kuroski
Daniel Kuroski

Posted on

Building a subscription tracker Desktop and iOS app with compose multiplatform - Part 1

Cover photo by Andrew Schultz on Unsplash

If you want to check out the code, here's the repository:
https://github.com/kuroski/kmp-expense-tracker

Introduction

I made a significant shift in my career from mainly working with JS to fully committing to Kotlin some time ago.

It was quite a task to familiarize myself with this vast ecosystem without coming from the "Java world".

However, Kotlin has become my favorite programming language to work with to this date.

This series is aimed mainly for developers that are moving away from the JS ecosystem and those who want a more detailed explanation of Java/Kotlin tooling, while building something more than a "Hello World" application.

More explanations are included in collapsed sections, always prefixed with a ℹ️

What are we building?

You probably are paying for services like Netflix, Disney+, Spotify, a VPN service, and may other subscriptions.

Or even have monthly expenses like your rent, maybe an insurance, or you might be sending some money to a savings account.

In this series, we'll build a subscription tracker app for Desktop and iOS from scratch using Kotlin with Compose multiplatform.

With it, you can keep track of what services you are using, to keep an eye on the budget and to identify which services you may no longer need.

By the end, you'll have a fully functional Desktop and iOS app that communicates with an external API, works offline, while learning some concepts of Kotlin and its ecosystem if you're not familiar with them.

Final application screenshot

Please note that this is not a full-fledged app like Bobby, but it should be enough for something functional and useful.

We'll build this application entirely with Kotlin, compose multiplatform, and the data will be stored in a Notion database.

ℹ️ What is Kotlin, Kotlin Multiplatform (KMP) and Compose Multiplatform?

Kotlin is a programming language designed to be concise, safe, interoperable with Java (and other languages) and facilitates code reuse across multiple platforms.

Kotlin Multiplatform (KMP) is an Open-source technology by JetBrains for flexible multiplatform development

In the Android development world, there is a "UI framework" called Jetpack Compose.

Compose multiplatform is a framework based on Jetpack Compose, but that works for multiple platforms

Setup

Before anything, please make sure you have your environment properly configured, and for that, follow Kotlin Multiplatform Development documentation on "Set up an environment".

Since we are not building an Android app, you don't actually need to install Android Studio, so it is completely fine if by the end of the documentation you have a kdoctor result like

kdoctor output

Finally, let's begin with our project.

KMP wizard

  • You can select as many platforms as you want, but for this tutorial, we will build an iOS and Desktop app
  • For the wizard field values, I am using
    • Project Name ExpenseTracker
    • Project ID org.expense.tracker
    • Make sure you have selected iOS and Desktop options through the checkbox
    • And since we want to write everything with Kotlin and share as may components as we want, make sure on selecting Share UI (with Compose Multiplatform UI framework)
  • Click the "Download" button, and a new project will be generated for you
  • This will download a project with some boilerplate, feel free to open it with your favorite editor

To this date, there is a list of Recommended IDEs to use for development, in this tutorial, I am going to use Fleet given it is free to use for hobby and educational projects.

This is a Gradle project, and if you editor (in my case, Fleet), it will automatically handle the importation, download dependencies, and provide me a nice UI to run the project.

When you open the project, you'll see a "Gradle project" import happening.

Wait for the tasks to finish (it might take a while), and we're good to go.

Gradle import

Import output

ℹ️ What is Gradle?

From their website:

Gradle is a modern automation tool that helps build and manage projects written in Java, Kotlin, Scala, and other JVM-based languages.

Basically, we use it to handle project settings, dependency management, tasks execution, and builds.

You can think of it as a combination of npm and webpack/vite/gulp/etc.

A basic Gradle project structure consists of

.
├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
Enter fullscreen mode Exit fullscreen mode
  • build.gradle is the main file that specifies a Gradle project, in here we define project metadata, dependencies, tasks, and plugins
  • settings.gradle file is used to define the project structure by specifying which modules should be included when building the project
  • gradle-wrapper.jar, gradle-wrapper.properties, gradlew and gradlew.bat belong to Gradle Wrapper, which allows you to run Gradle without its manual installation (their "binaries")

To give a glimpse on the tool, you can open the project folder we just downloaded on terminal, and we can play a bit with some tasks.

  • List all available tasks
# ./gradlew.bat in case you are using Windows
./gradlew tasks --all
Enter fullscreen mode Exit fullscreen mode

./gradlew tasks --all result

  • Run your app
# ./gradlew.bat in case you are using Windows
./gradlew composeApp:run
Enter fullscreen mode Exit fullscreen mode

./gradlew composeApp:run result

  • Build the project (this is what Fleet is doing for us behind the scenes)
# ./gradlew.bat in case you are using Windows
./gradlew build
Enter fullscreen mode Exit fullscreen mode

./gradlew build result

In case you are using an IDE, usually things are handled for you automatically + there is usually some UI to help you execute those tasks.

For Fleet, you have the run button

Run button

Run button popup


Project walkthrough

You can refer to JetBrains Get started with Compose Multiplatform — tutorial for a basic walkthrough.

It contains a good general overview and explanation about the project initial setup.

The only difference is that we are building an iOS + desktop app instead of iOS + Android, so we will have a desktopMain module instead of an androidMain.

I am including some explanations below of some topics that are not covered there.

ℹ️ I am not familiar with compose, what should I do?

As I mentioned before, compose is a modern toolkit for building native Android UI, and compose multiplatform extends this beyond Android land.

For this article most things should look "self-explanatory", and you can assume a lot of the components we will be using.

But in case you are having a hard time following along, I recommend you going through the Jetpack Compose for Android Developers pathways.

Compose can be straightforward at first glance. However, when reaching the "good stuff", like how to manage state, requests handling, structuring your app, there is quite some mindset change if you are coming from JS, and it might feel a bit weird.

In either way, please make sure you at least read it through Jetpack Compose for Android Developers pathways at some point in time if this is the first time you are working with this tool.

ℹ️ Walkthrough "build.gradle.kts"
/**
 * this is very important, the rootProject name will be
 * directly translated to imports of some resources, for example, in
 * `composeApp/src/commonMain/kotlin/App.kt` we are importing
 * - import expensetrackerapp.composeapp.generated.resources.Res
 * - import expensetrackerapp.composeapp.generated.resources.compose_multiplatform
 *
 * Which then is used to import a resource (our image).
 * The `ExpenseTrackerApp` is translated into `expensetrackerapp`
 */
rootProject.name = "ExpenseTrackerApp"

/**
 * This feature makes it safer and more idiomatic to access 'elements' of a Gradle project object from Kotlin build scripts.
 */
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

/**
 * Here we are defining which repositories
 * Gradle will look into when trying to resolve a plugin dependency
 */
pluginManagement {
    repositories {
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
        google()
        gradlePluginPortal()
        mavenCentral()
    }
}

/**
 * Same thing as `pluginManagement` section, but those are
 * for plain project dependencies
 */
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
    }
}

/**
 * Like it was explained on the Gradle section of this tutorial.
 * > `settings.gradle.kts` file is used to define the project structure by specifying which modules should be included when building the project
 *
 * And here we are including `composeApp` modile in the root project.
 */
include(":composeApp")
Enter fullscreen mode Exit fullscreen mode

ℹ️ Walkthrough "composeApp/build.gradle.kts"

The previous build.gradle.kts was our root project build configuration, this one is specific to composeApp project.

import org.jetbrains.compose.desktop.application.dsl.TargetFormat

/**
 * Gradle plugins definition, think of them like webpack/vite/gulp plugins
 * they will extend our project and add new capabilities, new tasks, etc.
 *
 * Here, kotlinMultiplatform and jetbrainsCompose are used to add capabilities for
 * Kotlin multiplatform projects and Jetpack Compose UI, respectively.
 */
plugins {
    alias(libs.plugins.kotlinMultiplatform)

    alias(libs.plugins.jetbrainsCompose)
}

kotlin {
    /**
     * Here, we are specifying an additional JVM target named "desktop",
     * and we have to do the same for other platforms
     */
    jvm("desktop")

    /**
     * This section defines targets for different iOS architectures.
     * For each target, a static framework named ComposeApp is created.
     * When building the app, the compiled code will be accessible through Swift code, since the base name is "ComposeApp", you can see in `iosApp/iosApp/ContentView.swift` that we have an `import ComposeApp`, and we are using `MainViewControllerKt.MainViewController()` which is generated from `composeApp/src/iosMain/kotlin/MainViewController.kt` 
     *
     * The name is not required to be the same as our project name, but this will be the name of the generated binary artifact.
     * If you change it, please go through `iosApp` folder and make sure
     * you update the `import ComposeApp` statement to wathever you have named here.
     */
    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "ComposeApp"
            isStatic = true
        }
    }

    /**
     * In here, we defined the dependencies for different source sets (modules).
     */
    sourceSets {
        /**
         * This is a [delegated property](https://kotlinlang.org/docs/delegated-properties.html) and we are retrieving a named configuration object named "desktopMain" (this is coming from kotlin multiplatform plugin), we will use it to add specific dependencies to the desktop/JVM target of the project.
         * It might feel odd that we don't have to do that to "commonMain" for example, but this is a special property coming from the plugin that groups desktop targets (`linuxX64`, `mingwX64`, and `macosX64`).
         * 
         * Instead of adding 
         * - linuxX64.dependencies { implementation("a") }
         * - mingwX64.dependencies { implementation("a") }
         * - macosX64.dependencies { implementation("a") }
         * 
         * We can just
         * - val desktopMain by getting
         * - desktopMain.dependencies { implementation("a") }
         */
        val desktopMain by getting

        /**
         * Every dependency in `commonMain` are going to be available on every platform,
         * if the third party dependency you are adding is not compatible with some platform
         * you are targeting, you will get a compile time error.
         * In here, we are just adding the core compose libraries.
         */
        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
        }

        /**
         * Here we have the desktop specific dependencies definitions
         * In case of compose multiplatform, there are things that only
         * desktop targets can do (e.g.: context menu)
         */
        desktopMain.dependencies {
            implementation(compose.desktop.currentOs)
        }
    }
}

/**
 * Remember when we have added the "alias(libs.plugins.jetbrainsCompose)" plugin?
 * We can access some compose configuration for specific platforms.
 * In here, we are configuring the Desktop application.
 *
 * We are defining the `mainClass` to be `MainKt`, since the desktop app
 * entry point is located at `composeApp/src/desktopMain/kotlin/main.kt`
 * this file will be compiled, and the main class will be named as `MainKt`.
 * If you change your entry point name, make sure to upgrade it here.
 *
 * Here we also define the targets for our final build, in this case, we will
 * be building for Mac, Windows and Linux (Debian distros).
 */
compose.desktop {
    application {
        mainClass = "MainKt"

        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "org.expense.tracker"
            packageVersion = "1.0.0"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding dependencies

We will need a few dependencies to help us build our app.

Luckily, the project already comes configured with Gradle version catalog which makes things easier.

ℹ️ What is version catalog?

From Gradle documentation

version catalog is a list of dependencies, represented as dependency coordinates, that a user can pick from when declaring dependencies in a build script.

In a Gradle project, we can create a gradle/libs.versions.toml file and define in one place the dependencies and their version meta information.

We can even create groups of dependencies and refer to them as one alias in our build.gradle.kts.

For example:

[versions]
my-lib-version = "1.1.0"
another-lib-version = "3.1.0"
my-plugin-version = "2.1.0"

[libraries]
my-lib-core = { module = "org.my.lib:core", version.ref = "my-lib-version" }
my-lib-test = { module = "org.my.lib:test", version.ref = "my-lib-version" }
my-lib-bla = { module = "org.my.lib:bla", version.ref = "my-lib-version" }
another-lib = { module = "org.another.lib:bla", version.ref = "another-lib-version" }

[bundles]
my-lib = ["my-lib-core", "my-lib-test", "my-lib-bla"]

[plugins]
myPlugin = { id = "org.my.plugin", version.ref = "my-plugin-version" }
Enter fullscreen mode Exit fullscreen mode

Then each dependency can be referred as

// composeApp/build.gradle.kts

plugins {
    alias(libs.plugins.myPlugin)
}

dependencies {
    implementation(libs.another.lib)
    // this will inject "my-lib-core", "my-lib-test" and "my-lib-bla"
    implementation(libs.bundles.my.lib)
}
Enter fullscreen mode Exit fullscreen mode

There is so much more towards version catalogs, feel free to read more about them here.


Let's change our version catalog to the following

[versions]
compose = "1.6.2"
compose-plugin = "1.6.1"
junit = "4.13.2"
kotlin = "1.9.23"
# custom
logback = "1.5.3"
koin = "3.5.3"
koin-compose = "1.1.2"
ktor = "2.3.10"
kotlin-logging = "6.0.4"
voyager = "1.0.0"
coroutines-swing = "1.8.0"
stately-common = "2.0.7"
buildkonfig = "0.15.1"
dotenvgradle = "4.0.0"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
# custom
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin-compose" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
kotlin-logging = { module = "io.github.oshai:kotlin-logging", version.ref = "kotlin-logging" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenModel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "coroutines-swing" }
stately-common = { module = "co.touchlab:stately-common", version.ref = "stately-common" }
buildkonfig-gradle-plugin = { module = "com.codingfeline.buildkonfig:buildkonfig-gradle-plugin", version.ref = "buildkonfig" }

[plugins]
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
buildKonfig = { id = "com.codingfeline.buildkonfig", version.ref = "buildkonfig" }
dotenvgradle = { id = "co.uzzu.dotenv.gradle", version.ref = "dotenvgradle" }
Enter fullscreen mode Exit fullscreen mode

There are a few dependencies needed to run our project, so aside from the ones that already come with the boilerplate, we have:

  • Koin to manage dependency injection (more explanation about that later)
  • Ktor client to manage HTTP requests
  • Logback we will use mostly with Ktor, this dependency is not required, but it is nice to see logs of requests + it will get rid of some annoying warnings while running the project
  • BuildKonfig + dotenv-gradle we are going to use those plugins to inject our environment variables
  • Voyager this library help us handle screen navigation, Voyager works in multi platforms, and it has integration with libraries like Koin, think of it something like react-router or TanStack Router
  • kotlinx-coroutines-swing and stately-common are here since I will show how to write specific code to Desktop and iOS, and we will need those libraries to use a custom Desktop feature, the context menu.

And now, it's a matter of including the dependencies on the project.

// build.gradle.kts

plugins {
    // this is necessary to avoid the plugins to be loaded multiple times
    // in each subproject's classloader
    alias(libs.plugins.jetbrainsCompose) apply false
    alias(libs.plugins.kotlinMultiplatform) apply false
    alias(libs.plugins.buildKonfig) apply false

    // dotenvgradle must be loaded at the project root
    alias(libs.plugins.dotenvgradle)
}

// In case you get an error from buildkonfig, just uncomment the lines below
//buildscript {
//    dependencies {
//        classpath(libs.buildkonfig.gradle.plugin)
//    }
//}

// composeApp/build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.jetbrainsCompose)
    alias(libs.plugins.kotlinSerialization)
    alias(libs.plugins.buildKonfig)
}

// ...

sourceSets {
        val desktopMain by getting

        commonMain.dependencies {
            implementation(compose.runtime)
            implementation(compose.foundation)
            implementation(compose.material3)
            implementation(compose.ui)
            implementation(compose.components.resources)
            implementation(compose.components.uiToolingPreview)
            implementation(compose.animation)
            implementation(compose.materialIconsExtended)
            implementation(libs.ktor.client.core)
            implementation(libs.ktor.client.cio)
            implementation(libs.ktor.client.logging)
            implementation(libs.ktor.client.auth)
            implementation(libs.ktor.client.content.negotiation)
            implementation(libs.ktor.serialization.kotlinx.json)
            implementation(libs.koin.core)
            implementation(libs.koin.compose)
            implementation(libs.logback.classic)
            implementation(libs.kotlin.logging)
            implementation(libs.voyager.navigator)
            implementation(libs.voyager.screenModel)
            implementation(libs.voyager.transitions)
            implementation(libs.voyager.koin)
        }
        desktopMain.dependencies {
            implementation(compose.desktop.currentOs)
            implementation(libs.dotenv.kotlin)
            implementation(libs.kotlinx.coroutines.swing)
        }
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
            implementation(libs.stately.common)
        }
    }

// ...
// ...

compose.desktop {
    application {
        mainClass = "MainKt"

        nativeDistributions {
            targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
            packageName = "org.expense.tracker"
            packageVersion = "1.0.0"
        }
    }
}

/**
 * BuildKonfig that will export environment variables to our application
 */
buildkonfig {
    packageName = "org.expense.tracker"

    defaultConfigs {
        buildConfigField(STRING, "NOTION_TOKEN", env.fetchOrNull("NOTION_TOKEN") ?: "")
        buildConfigField(STRING, "NOTION_DATABASE_ID", env.fetchOrNull("NOTION_DATABASE_ID") ?: "")
    }
}
Enter fullscreen mode Exit fullscreen mode

Please note that:

  • We are going to use material 3, so remember to change the dependency from compose.material to compose.material3
  • With compose plugin installed, you have access to extra functionalities, we are including here compose.animation and compose.materialIconsExtended
  • dotenv-gradle actually generates autocompletion for your dotenv files, but since we are not going to need it for now, we are using fetchOrNull with a hard-coded string

Taking advantage we are here, feel free to include a language setting opt in ExperimentalMaterial3Api, since for some functionalities of our app we are relying on experimental APIs.

This is entirely optional, and you can add the "Experimental annotations" directly in code when Fleet suggests it, but in case you want to avoid typing annotations all over the place, we can add this following option.

// composeApp/build.gradle.kts

// ....
        iosMain.dependencies {
            implementation(libs.ktor.client.darwin)
            implementation(libs.stately.common)
        }

            all {
                languageSettings.optIn("androidx.compose.material3.ExperimentalMaterial3Api")
        }
} // end of `sourceSets`
Enter fullscreen mode Exit fullscreen mode

Modeling our data

We are building a subscription tracking app, and in our case only one data class is needed.

// composeApp/src/commonMain/kotlin/Model.kt

typealias ExpenseId = String

@Serializable
data class Expense(
    /**
     * You can type id with "String" directly, but it is nice
     * to add some thin "layer of type safety" here
     */
    val id: ExpenseId, 
    val name: String,
    val icon: String?,
    val price: Int,
)
Enter fullscreen mode Exit fullscreen mode

Since we are dealing with money, please make sure to at least work with integer values.

ℹ️ Why not use float values for handling money values??

If we work with floats (like € 9.10), normally this value would be stored in the same way in the database.

Whenever we need to show the price, we would need to format it like:

val eurFormatter = NumberFormat
        .getCurrencyInstance()
        .apply {
            currency = Currency.getInstance("EUR")
        }
val price = eurFormatter.format(9.1)
println(price) // €9.10
Enter fullscreen mode Exit fullscreen mode

So, the result would be shown with two digits.

In most cases, this should be fine, as long as we are just working with one currency only, and not doing a lot of calculations with those fields.

The main problem is that we might encounter rounding problems, meaning we might have incorrect calculations by 0.01 if the wrong roundings add up [1].

A better way to handle money values as float/decimal fields like 9.13, we can create an integer field with the value 913.

Meaning, we can do simple conversions when manipulating this value:

  • 913 / 100 = 9.13
  • 9.13 * 100 = 913

Please be aware that there are a few exceptions:

  • Not all world currencies have exact two digits
  • There are countries that have no decimal digits at all
  • You might want/need to store more than two decimal digits

Saving money values as integers works quite well.

In case you need to deal with one of those exceptions, there's always some library to help you out dealing with those extra cases.

We won't need that in our case, but in case you need, you might want to check


Configuring routing

Compose multiplatform is the "UI toolkit", we need something extra to manage our routes.

Fortunately, there is a cool library called Voyager, from their website

Voyager is a multiplatform navigation library built for, and seamlessly integrated with, Jetpack Compose.

  • Feel free to delete
    • composeApp/src/commonMain/kotlin/Greeting.kt
    • composeApp/src/commonMain/kotlin/Platform.kt
    • composeApp/src/desktopMain/kotlin/Platform.jvm.kt
    • composeApp/src/iosMain/kotlin/Platform.ios.kt
  • And let's add a basic application setup
// composeApp/src/commonMain/kotlin/App.kt

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.SlideTransition

@Composable
fun App() {
    MaterialTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background,
        ) {
            Scaffold {
                Navigator(HelloWorldScreen) { navigator ->
                    SlideTransition(navigator)
                }
            }
        }
    }
}

object HelloWorldScreen : Screen {

    @Composable
    override fun Content() {
        Column {
            Text("Hello World!")
            Button(onClick = {}) {
                Text("Click here!")
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And that's it.

Voyager expects you to create a

  • data class (if you need to send params)
  • class (if no param is required)
  • or even object (useful for tabs)

They must extend from the Screen interface, which provides a Content method where you can put your composables (your components).

If you run the app, you should see something like this.

Hello world application

Configuring material theme

Since we are using compose multiplatform, this means we "inherit" almost everything from Jetpack Compose (Android UI toolkit), which naturally uses Material theming.

If you are not a fan of Material, there are definite ways to style compose to make it look exactly the way you want.

As some examples

  • compose-cupertino which provides compose multiplatform components for iOS
  • Jewel which provides IntelliJ look and feels in Compose for Desktop

You can also translate almost anything from Jetpack Compose (even docs and tutorials) to build something interesting with Compose multiplatform.

For this app, we will keep with Material and do some basic theming.

Customising a Material theme can be made though Material theme builder app, which generates the files below (include them in the project).

// shared/src/ui/theme/Color.kt

package ui.theme

import androidx.compose.ui.graphics.Color

val md_theme_light_primary = Color(0xFFA1401A)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDBCF)
val md_theme_light_onPrimaryContainer = Color(0xFF390C00)
val md_theme_light_secondary = Color(0xFFAA2E5C)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFFD9E1)
val md_theme_light_onSecondaryContainer = Color(0xFF3F001A)
val md_theme_light_tertiary = Color(0xFF7146B5)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFFECDCFF)
val md_theme_light_onTertiaryContainer = Color(0xFF270057)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFFAFCFF)
val md_theme_light_onBackground = Color(0xFF001F2A)
val md_theme_light_surface = Color(0xFFFAFCFF)
val md_theme_light_onSurface = Color(0xFF001F2A)
val md_theme_light_surfaceVariant = Color(0xFFF5DED7)
val md_theme_light_onSurfaceVariant = Color(0xFF53433F)
val md_theme_light_outline = Color(0xFF85736E)
val md_theme_light_inverseOnSurface = Color(0xFFE1F4FF)
val md_theme_light_inverseSurface = Color(0xFF003547)
val md_theme_light_inversePrimary = Color(0xFFFFB59C)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFFA1401A)
val md_theme_light_outlineVariant = Color(0xFFD8C2BB)
val md_theme_light_scrim = Color(0xFF000000)

val md_theme_dark_primary = Color(0xFFFFB59C)
val md_theme_dark_onPrimary = Color(0xFF5C1900)
val md_theme_dark_primaryContainer = Color(0xFF812903)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDBCF)
val md_theme_dark_secondary = Color(0xFFFFB1C4)
val md_theme_dark_onSecondary = Color(0xFF65002E)
val md_theme_dark_secondaryContainer = Color(0xFF8A1244)
val md_theme_dark_onSecondaryContainer = Color(0xFFFFD9E1)
val md_theme_dark_tertiary = Color(0xFFD6BAFF)
val md_theme_dark_onTertiary = Color(0xFF410984)
val md_theme_dark_tertiaryContainer = Color(0xFF592B9B)
val md_theme_dark_onTertiaryContainer = Color(0xFFECDCFF)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF001F2A)
val md_theme_dark_onBackground = Color(0xFFBFE9FF)
val md_theme_dark_surface = Color(0xFF001F2A)
val md_theme_dark_onSurface = Color(0xFFBFE9FF)
val md_theme_dark_surfaceVariant = Color(0xFF53433F)
val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BB)
val md_theme_dark_outline = Color(0xFFA08D87)
val md_theme_dark_inverseOnSurface = Color(0xFF001F2A)
val md_theme_dark_inverseSurface = Color(0xFFBFE9FF)
val md_theme_dark_inversePrimary = Color(0xFFA1401A)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFFFFB59C)
val md_theme_dark_outlineVariant = Color(0xFF53433F)
val md_theme_dark_scrim = Color(0xFF000000)

val seed = Color(0xFFFF865B)

// shared/src/ui/theme/Theme.kt

package ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

private val LightColors =
    lightColorScheme(
        primary = md_theme_light_primary,
        onPrimary = md_theme_light_onPrimary,
        primaryContainer = md_theme_light_primaryContainer,
        onPrimaryContainer = md_theme_light_onPrimaryContainer,
        secondary = md_theme_light_secondary,
        onSecondary = md_theme_light_onSecondary,
        secondaryContainer = md_theme_light_secondaryContainer,
        onSecondaryContainer = md_theme_light_onSecondaryContainer,
        tertiary = md_theme_light_tertiary,
        onTertiary = md_theme_light_onTertiary,
        tertiaryContainer = md_theme_light_tertiaryContainer,
        onTertiaryContainer = md_theme_light_onTertiaryContainer,
        error = md_theme_light_error,
        errorContainer = md_theme_light_errorContainer,
        onError = md_theme_light_onError,
        onErrorContainer = md_theme_light_onErrorContainer,
        background = md_theme_light_background,
        onBackground = md_theme_light_onBackground,
        surface = md_theme_light_surface,
        onSurface = md_theme_light_onSurface,
        surfaceVariant = md_theme_light_surfaceVariant,
        onSurfaceVariant = md_theme_light_onSurfaceVariant,
        outline = md_theme_light_outline,
        inverseOnSurface = md_theme_light_inverseOnSurface,
        inverseSurface = md_theme_light_inverseSurface,
        inversePrimary = md_theme_light_inversePrimary,
        surfaceTint = md_theme_light_surfaceTint,
        outlineVariant = md_theme_light_outlineVariant,
        scrim = md_theme_light_scrim,
    )

private val DarkColors =
    darkColorScheme(
        primary = md_theme_dark_primary,
        onPrimary = md_theme_dark_onPrimary,
        primaryContainer = md_theme_dark_primaryContainer,
        onPrimaryContainer = md_theme_dark_onPrimaryContainer,
        secondary = md_theme_dark_secondary,
        onSecondary = md_theme_dark_onSecondary,
        secondaryContainer = md_theme_dark_secondaryContainer,
        onSecondaryContainer = md_theme_dark_onSecondaryContainer,
        tertiary = md_theme_dark_tertiary,
        onTertiary = md_theme_dark_onTertiary,
        tertiaryContainer = md_theme_dark_tertiaryContainer,
        onTertiaryContainer = md_theme_dark_onTertiaryContainer,
        error = md_theme_dark_error,
        errorContainer = md_theme_dark_errorContainer,
        onError = md_theme_dark_onError,
        onErrorContainer = md_theme_dark_onErrorContainer,
        background = md_theme_dark_background,
        onBackground = md_theme_dark_onBackground,
        surface = md_theme_dark_surface,
        onSurface = md_theme_dark_onSurface,
        surfaceVariant = md_theme_dark_surfaceVariant,
        onSurfaceVariant = md_theme_dark_onSurfaceVariant,
        outline = md_theme_dark_outline,
        inverseOnSurface = md_theme_dark_inverseOnSurface,
        inverseSurface = md_theme_dark_inverseSurface,
        inversePrimary = md_theme_dark_inversePrimary,
        surfaceTint = md_theme_dark_surfaceTint,
        outlineVariant = md_theme_dark_outlineVariant,
        scrim = md_theme_dark_scrim,
    )

@Composable
fun AppTheme(
    useDarkTheme: Boolean = isSystemInDarkTheme(),
    content:
    @Composable()
        () -> Unit,
) {
    val colors =
        if (!useDarkTheme) {
            LightColors
        } else {
            DarkColors
        }

    MaterialTheme(
        colorScheme = colors,
        content = content,
    )
}

Enter fullscreen mode Exit fullscreen mode

Since we are here, we can also add some UI constants to avoid adding arbitrary numbers to spacings, fonts, etc.


// shared/src/ui/theme/Theme.kt

// .....

object Spacing {
    val Small = 4.dp
    val Small_100 = 8.dp
    val Medium = 12.dp
    val Medium_100 = 16.dp
    val Large = 20.dp
    val Large_100 = 24.dp
    val ExtraLarge = 32.dp
}

object BorderRadius {
    val small = 4.dp
}

object Width {
    val Small = 20.dp
    val Medium = 32.dp
}

object IconSize {
    val Medium = 24.sp
}

@Composable
fun AppTheme(

// .....
Enter fullscreen mode Exit fullscreen mode

To apply the custom theme, we just need to swap MaterialTheme with our new AppTheme composable

// composeApp/src/commonMain/kotlin/App.kt

@Composable
fun App() {
-    MaterialTheme {
+    AppTheme {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = MaterialTheme.colorScheme.background,
        ) {
            Scaffold {
                Navigator(HelloWorldScreen) { navigator ->
                    SlideTransition(navigator)
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

By running the app, you should see our custom theme.

Custom theme screenshot

Expenses list screen

Great, we have our model and theming in place, let's create the first screen.

In the end, it should look like this.

List screen result

// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreenViewModel.kt

package ui.screens.expenses

import Expense
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private val logger = KotlinLogging.logger {}

/**
 * Base state definition for our screen
 */
data class ExpensesScreenState(
    val data: List<Expense>,
) {
    /**
     * Computed property to get the avg price of the expenses
     */
    val avgExpenses: String
        get() = data.map { it.price }.average().toString()
}

/**
 * View model of our screen
 * More about ViewModels below
 */
class ExpensesScreenViewModel : StateScreenModel<ExpensesScreenState>(
    ExpensesScreenState(
        data = listOf(),
    ),
) {
    init {
        /**
         * Simulating the "API request" by adding some latency
         * and fake data
         */
        screenModelScope.launch {
            logger.info { "Fetching expenses" }
            delay(3000)
            mutableState.value = ExpensesScreenState(
                data = listOf(
                    Expense(
                        id = "1",
                        name = "Rent",
                        icon = "🏠",
                        price = 102573,
                    ),
                    Expense(
                        id = "2",
                        name = "Apple one",
                        icon = "🍎",
                        price = 2595,
                    ),
                    Expense(
                        id = "3",
                        name = "Netflix",
                        icon = "📺",
                        price = 1299,
                    ),
                )
            )
        }
    }
}

// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreen.kt

package ui.screens.expenses

import Expense
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import io.github.oshai.kotlinlogging.KotlinLogging
import ui.theme.BorderRadius
import ui.theme.IconSize
import ui.theme.Spacing

private val logger = KotlinLogging.logger {}

/**
 * Voyager screen, since there are no params
 * we can define it as a plain `object`
 */
object ExpensesScreen : Screen {
    @Composable
    override fun Content() {
        /**
         * Instantiating our ViewModel
         * https://voyager.adriel.cafe/screenmodel
         */
        val viewModel = rememberScreenModel { ExpensesScreenViewModel() }

        /**
         * More about this below, but for now, differently than JS
         * we handle values over time with Kotlin coroutine `Flow's` (in this case, `StateFlow`)
         * you can think of it as something similar to `Observables` in reactive programming
         */
        val state by viewModel.state.collectAsState()
        val onExpenseClicked: (Expense) -> Unit = {
            logger.info { "Redirect to edit screen" }
        }

        Scaffold(
            topBar = {
                CenterAlignedTopAppBar(
                    title = {
                        Text("My subscriptions", style = MaterialTheme.typography.titleMedium)
                    },
                )
            },
            bottomBar = {
                BottomAppBar(
                    contentPadding = PaddingValues(horizontal = Spacing.Large),
                ) {
                    Row(
                        modifier = Modifier.fillMaxWidth(),
                        verticalAlignment = Alignment.CenterVertically,
                        horizontalArrangement = Arrangement.SpaceBetween,
                    ) {
                        Column {
                            Text(
                                "Average expenses",
                                style = MaterialTheme.typography.bodyLarge,
                            )
                            Text(
                                "Per month".uppercase(),
                                style = MaterialTheme.typography.bodyMedium,
                            )
                        }
                        Text(
                            state.avgExpenses,
                            style = MaterialTheme.typography.labelLarge,
                        )
                    }
                }
            },
        ) { paddingValues ->
            Box(modifier = Modifier.padding(paddingValues)) {
                ExpenseList(state.data, onExpenseClicked)
            }
        }
    }
}

@Composable
private fun ExpenseList(
    expenses: List<Expense>,
    onClick: (expense: Expense) -> Unit,
) {
    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(Spacing.Small_100),
    ) {
        items(
            items = expenses,
            key = { it.id },
        ) { expense ->
            ExpenseListItem(
                expense = expense,
                onClick = {
                    logger.info { "Clicked on ${expense.name}" }
                    onClick(expense)
                },
            )
        }

        item {
            Spacer(Modifier.height(Spacing.Medium))
        }
    }
}

@Composable
private fun ExpenseListItem(
    expense: Expense,
    onClick: () -> Unit = {},
) {
    Surface(
        modifier =
        Modifier
            .fillMaxWidth()
            .padding(horizontal = Spacing.Medium)
            .defaultMinSize(minHeight = 56.dp),
        onClick = onClick,
        shape = RoundedCornerShape(BorderRadius.small),
        color = MaterialTheme.colorScheme.surfaceVariant,
    ) {
        Row(
            modifier =
            Modifier
                .padding(
                    horizontal = Spacing.Medium_100,
                    vertical = Spacing.Small_100,
                ),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.spacedBy(Spacing.Large),
        ) {
            Text(
                text = expense.icon ?: "",
                fontSize = IconSize.Medium,
                modifier = Modifier.defaultMinSize(minWidth = 24.dp),
            )
            Text(
                text = expense.name,
                style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
                modifier = Modifier.weight(1f),
            )
            Text(
                text = (expense.price).toString(),
                style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • First of all, we have a ViewModel, which for now contains some basic states for our screen
  • Next we have a set of plain boring composables

ℹ️ What is this "ViewModel"??

From the documentation definition, ViewModel

Since ViewModel class is specific to the Android platform (at the time I am writing this series), Voyager library provides a ScreenModel class, which can be used for the same purposes.

There are plenty of different approaches on how you can handle state on a screen, you can even go with a simpler approach and keep your state local, use some LaunchedEffect and might be good to go (at least for the kind of application we are building).

But in case things start to get messy, you can rely on ViewModel's as a way to organise your application and separate better the responsibilities.

ℹ️ What is this "StateFlow"??

From the documentation

StateFlow is a state-holder observable flow that emits the current and new state updates to its collectors.

When working with compose, there are multiple ways to handle state.

We can make use of something that might "feel" more familiar, like the State and MutableState classes to manage simple things.

But generally, state is handled with "observable-like" structures.

Reactivity works differently here compared to JS, there's no Promises nor Proxies, so it might not be something so familiar at first sight.

To handle async operations, we work with coroutines.

For example, we have to make API requests to Notion API, so we have to deal with something that can emit multiple values sequentially like Flow's.

The differences between a regular Flow and StateFlow are:

  • Flow is a cold stream (starts producing data only when observed)
  • StateFlow is a hot flow (produces data regardless of subscribers) variant that represents state-holding observable

All right, by running the application you should be presented with a "working" list screen.

List screen result

Still, there are quite a lot of issues here:

  • We are mocking our data
  • There is no feedback to the user, we are not handling Loading/Error/Success states
  • We have to display the pricing properly

In the next part of this series, we will configure and integrate Notion in our application.

Thank you so much for reading, any feedback is welcome, and please if you find any incorrect/unclear information, I would be thankful if you try reaching out.

See you all soon.

Meme

Top comments (0)