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.
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.
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ℹ️ What is Kotlin, Kotlin Multiplatform (KMP) and Compose Multiplatform?
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
Finally, let's begin with our project.
- The easiest way to create a project is to navigate to Kotlin Multiplatform Wizard https://kmp.jetbrains.com/
- 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
andDesktop
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.
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 A basic Gradle project structure consists of 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. 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ℹ️ What is Gradle?
npm
and webpack/vite/gulp/etc
..
├── build.gradle
├── gradle
│ └── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle
build.gradle
is the main file that specifies a Gradle project, in here we define project metadata, dependencies, tasks, and pluginssettings.gradle
file is used to define the project structure by specifying which modules should be included when building the projectgradle-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")
# ./gradlew.bat in case you are using Windows
./gradlew tasks --all
# ./gradlew.bat in case you are using Windows
./gradlew composeApp:run
# ./gradlew.bat in case you are using Windows
./gradlew build
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.
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.ℹ️ I am not familiar with compose, what should I do?
ℹ️ 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")
The previous ℹ️ Walkthrough "composeApp/build.gradle.kts"
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"
}
}
}
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.
From Gradle documentation A 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 We can even create groups of dependencies and refer to them as one alias in our For example: Then each dependency can be referred as There is so much more towards version catalogs, feel free to read more about them here.ℹ️ What is version catalog?
gradle/libs.versions.toml
file and define in one place the dependencies and their version meta information.build.gradle.kts
.[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" }
// 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)
}
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" }
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
andstately-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") ?: "")
}
}
Please note that:
- We are going to use material 3, so remember to change the dependency from
compose.material
tocompose.material3
- With compose plugin installed, you have access to extra functionalities, we are including here
compose.animation
andcompose.materialIconsExtended
-
dotenv-gradle
actually generates autocompletion for your dotenv files, but since we are not going to need it for now, we are usingfetchOrNull
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`
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,
)
Since we are dealing with money, please make sure to at least work with integer 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: 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 Meaning, we can do simple conversions when manipulating this value: Please be aware that there are a few exceptions: 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ℹ️ Why not use float values for handling money values??
val eurFormatter = NumberFormat
.getCurrencyInstance()
.apply {
currency = Currency.getInstance("EUR")
}
val price = eurFormatter.format(9.1)
println(price) // €9.10
9.13
, we can create an integer field with the value 913
.
913 / 100 = 9.13
9.13 * 100 = 913
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!")
}
}
}
}
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.
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,
)
}
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(
// .....
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)
}
}
}
}
}
By running the app, you should see our custom theme.
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.
// 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),
)
}
}
}
- 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
From the documentation definition, ViewModel Since 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 But in case things start to get messy, you can rely on ℹ️ What is this "ViewModel"??
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.LaunchedEffect
and might be good to go (at least for the kind of application we are building).ViewModel's
as a way to organise your application and separate better the responsibilities.
From the documentation When working with compose, there are multiple ways to handle state. We can make use of something that might "feel" more familiar, like the 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 The differences between a regular ℹ️ What is this "StateFlow"??
StateFlow
is a state-holder observable flow that emits the current and new state updates to its collectors.State
and MutableState
classes to manage simple things.Flow's
.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.
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.
Top comments (0)