Photo by Carl Tronders on Unsplash
If you want to check out the code, here's the repository:
https://github.com/kuroski/kmp-expense-tracker
Introduction
In the previous part, we bootstrapped our application and did some basic tweaking, if you followed along, you should have a static screen that lists our expenses.
In this part, we will be adding some "sauce" by making things dynamic with Notion's databases.
Let's start!
Configuring Notion
Creating the table
Notion will be our "database", so make sure to sign up, for the next steps:
- Create a new page (there is a link on the sidebar menu for that)
- Then type
/database
and select theDatabase - inline
option - Now, we can edit the database
properties
- Rename the column
Name
toExpense
- Remove
Tags
column - Create a new property and name it
Amount
, make sure it is aNumber
type, and select your currency in theNumber format
option
- Rename the column
Great, now we have a place to fill in our data.
In case you are having problems adding icons, you can right-click an item, and there is an
icons
property in the context menu
But, adding icons through this method is tedious 😅
To make things easier, you can create a template for new items, this way you can have an icon set by default, making new entries easier to create.
- Click on the
New
blue button arrow and selectNew template
- Then you can provide some values, in this case, just provide a random icon with some placeholder text
- From the template list, click on the "..." menu of the template you have just created and select the "Set as default" option
- Now, every time you create a new entry, the icon will be already there and make your life easier when handling new data
Generating access keys
Great, our database configuration is done, and you can already use this table to manage your expenses with Notion.
To use our database through Notion API, you will need an access key.
- Open https://developers.notion.com/
- Click on
View my integrations
menu, which is located at the top-right on the page - After logging in, you will see a "My Integrations" page
- Click on the "Create new integration" option
- Select your workspace (it should be already pre-selected)
- Give it a name
- Make sure you gave
Read/Update/Insert content
capabilities (enter in theCapabilities
menu
In the end, you should have something like this
Finally, you can find your API token on the Secrets
page, we will be using that to make the requests.
Testing out
First of all, we have created the integration, we still must "connect it" to the database.
To do that:
- Open the database we have created previously
- Then connect it to the
Expenses
integration we have created
Great, now that it is connected, we can test it by using Notion's database query endpoint.
- Open any HTTP client, like IntelliJ IDEA HTTP Client plugin or Postman
- Create a new POST HTTP request
- Set the Authorization Bearer Token to be the API token of the integration you created in previous steps
- Make sure you have the following headers set as well
Notion-Version: "2022-06-28"
Content-Type: "application/json"
- And fill in the URL
https://api.notion.com/v1/databases/<your-database-id>/query
- To find the database id, just go to the database page (like I showed on the previous step)
- You will be able to see the database ID through the URL
https://www.notion.so/<database-id>?v=non-interesting-things-here
If you got stuck, please check Notion's documentation about setting up Authorization
Finally, we have our data 🎉
Integrating Notion API with our app
To make requests to Notion's API, we will work with Ktor to instantiate an HTTP client on our app.
// composeApp/src/commonMain/kotlin/api/APIClient.kt
package api
import io.ktor.client.*
import io.ktor.client.engine.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.utils.io.core.*
import kotlinx.serialization.json.Json
/**
* Each platform has its own http client engine
* I will give more details on this later
*/
expect fun clientEngine(): HttpClientEngine
/**
* Here we have our main APIClient class.
*
* Closeable is a Ktor interface indicating something
* that must have a "close" method.
*
* We will need that since we are instantiating an httpClient
* and need to make sure it can be closed to avoid memory leak or further problems.
*/
class APIClient(
private val token: String,
) : Closeable {
/**
* Here we are defining our Ktor http client
* It has a delegated "lazy" property, to make sure
* its value is only computed on first access.
*/
private val httpClient: HttpClient by lazy {
HttpClient(clientEngine()) {
/**
* Notion API requires you to provide a "Notion-Version" header
*/
defaultRequest {
header(NOTION_HEADER, NOTION_HEADER_VERSION)
contentType(ContentType.Application.Json)
}
install(Logging) {
logger = Logger.SIMPLE
level = LogLevel.ALL
}
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
prettyPrint = true
},
)
}
install(Auth) {
bearer {
loadTokens {
BearerTokens(accessToken = token, refreshToken = token)
}
}
}
}
}
init {
require(token.isNotEmpty()) { "Notion API token is required" }
}
companion object {
const val NOTION_HEADER: String = "Notion-Version"
const val NOTION_HEADER_VERSION: String = "2022-06-28"
const val API_BASE_URL: String = "https://api.notion.com/v1"
}
override fun close() = httpClient.close()
}
Nice, we now must define the expect-actual
declaration to getting the client engine.
For more information about it, you can check it here, but to give an overview:
expect-actual
declaration allows you to access platform-specific API from KMP modules.
In our case, each platform needs a different http engine; then we can define an "expected" function to be present on the "actual" platform
This way, when we executeclientEngine()
function here, the call will be made on the respective platform the code is running in.In other words, expect defines that we need an
HttpClientEngine
.
And with actual, KMP wires this up with a platform-specific instance.No need for the equivalent of compiler directives that you may see in languages like C++ or C#. KMP handles this platform-specific implementation for you.
Let's implement the actual
functions on iOS and Desktop modules.
// composeApp/src/desktopMain/kotlin/api/APIClient.jvm.kt
package api
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.cio.CIO
actual fun clientEngine(): HttpClientEngine = CIO.create()
// composeApp/src/iosMain/kotlin/api/APIClient.ios.kt
package api
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin
actual fun clientEngine(): HttpClientEngine = Darwin.create()
This is, in my opinion, one of the coolest things about KMP.
Mostly, when working with other tooling, integration with other platforms is a pain.
But here, you can work with platform-specific code in a seamless way.
Fleet will even show us some useful information about it
Now we can finally make requests, let's first create a helper function to query Notion databases.
// composeApp/src/commonMain/kotlin/api/APIClient.kt
/**
* You can think of this like a branded type.
*
* This is not required for our use case, feel free to just handle `DatabaseId` as plain `String` if you wish, but I thought it would be interesting to bring this up.
*
* More details below.
*/
@Serializable
@JvmInline
value class DatabaseId(private val value: String) {
override fun toString(): String {
return value
}
}
// ...
class APIClient(
private val token: String,
) : Closeable {
// ...
suspend fun queryDatabaseOrThrow(
databaseId: DatabaseId,
query: QueryDatabaseRequest = QueryDatabaseRequest(),
): QueryDatabaseResponse =
httpClient
.post("$API_BASE_URL/databases/$databaseId/query") {
setBody(query)
}
.body<QueryDatabaseResponse>()
override fun close() = httpClient.close()
}
Since we used DatabaseId
as an inline value class, remember to change the ExpenseId
as well.
// composeApp/src/commonMain/kotlin/Model.kt
- typealias ExpenseId = String
+ @Serializable
+ @JvmInline
+ value class ExpenseId(private val value: String) {
+ override fun toString(): String {
+ return value
+ }
+ }
I thought it could be interesting to add This is not particularly noticeable in our use case since we are dealing with fewer entities, but I think it could be useful to have an example in case you wish to expand this concept. First of all, let's understand what branded types are: They allow us to create distinct types based on an existing underlying type. Let me illustrate better with an example. Let's say we have two functions, one to search users by their ID and another one for expenses’ ID. This is an innocent example, but normally we may have more functions to search several more entities. The main issue here is that those functions accept any At first glance, it seems like an obvious error, but along the years, I faced several problems that occurred due to cases like this. We can improve this by adding branded types. By simply creating a ℹ️ Why have DatabaseId and ExpenseId as inline value class??
DatabaseId
as a branded type, as an example of how you can narrow your application types.
This helps improve type safety, makes your code more explicit, and safe.
fun searchUser(id: String) {
// ...
}
fun searchExpense(id: String) {
// ...
}
String
parameter as the id
, meaning scenarios like the following might occur.
// somewhere in the code
val user: User = //....
val expense: Expense = // .....
searchExpense(expense.id) // success, this is a valid operation
searchExpense("Oops") // this is valid, but it will break, since "Oops" does not exist as id (I hope)
searchExpense(user.id) // this is valid as well, but we are going to return a expense that has the user id
@JvmInline
value class UserId(private val value: String)
@JvmInline
value class ExpenseId(private val value: String)
data class User(val id: UserId, val name: String)
fun searchUser(id: UserId) {
// ...
}
fun searchExpense(id: ExpenseId) {
// ...
}
init {
val authenticatedUser = getAuthenticatedUser() // returns a `User`
searchUser(authenticatedUser.id) // ok
searchExpense(authenticatedUser.id) // error
}
UserId
and ExpenseId
branded type, we now cannot send incorrect parameters.
Since we are using the Kotlin serialization library, we must map out our requests and responses.
When building client side applications, personally, I prefer to keep API requests/responses on their own data classes and not try re-using them inside my applications.
In the client, I think API shouldn't be generally shaped for a specific screen nor directly used as your app domain. I think it is nicer if we have a layer that would make sure the API result is what we expect. Furthermore, we can convert that data to the actual internal modeling of our app (even if the data is similar or equal). Having this separation is useful because I chose to use Notion for this purpose This same rule goes to screensℹ️ Why having this "intermediate" model and not just directly map API results into our app model??
name
field in the Expense class will always exist, but a name
in the form is nullable
Given that, we will have the following flow
- Receive Notion API response
- Serialize it into a "Response" data class
- Convert the response data class into our actual App state
First, let's handle the request
encoding and response
decoding.
// composeApp/src/commonMain/kotlin/api/QueryDatabaseRequest.kt
package api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://developers.notion.com/reference/post-database-query
*/
@Serializable
data class QueryDatabaseRequest(
@SerialName("start_cursor")
val startCursor: String? = null,
@SerialName("page_size")
val pageSize: Int? = 100,
) {
init {
pageSize?.let {
require(it in 1..100) { "Illegal property, pageSize must be between 1 and 100" }
}
}
}
// composeApp/src/commonMain/kotlin/api/QueryDatabaseResponse.kt
package api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class QueryDatabaseResponse(
// https://developers.notion.com/reference/page
val results: List<ExpensePageResponse>,
@SerialName("next_cursor")
val nextCursor: String? = null,
@SerialName("has_more")
val hasMore: Boolean,
)
The Notion database results
property returns a list of pages
.
Later on we will have to handle page
request/response for the new/edit screens, so we will already create a ExpensePageResponse
data class.
// composeApp/src/commonMain/kotlin/api/ExpensePageResponse.kt
package api
import ExpenseId
import api.model.ExpensePageProperties
import api.model.IconProperty
import kotlinx.serialization.Serializable
@Serializable
data class ExpensePageResponse(
val id: ExpenseId,
val icon: IconProperty? = null,
val properties: ExpensePageProperties,
)
And now comes the tricky part.
Notion Databases entries are pages, which are generic.
In our case, they contain:
id
-
icon
that is anemoji object
- and a
map of properties
.
Since we are requesting our Expenses
database, we know there are only two properties
-
Expense
which is atitle property
-
Amount
which is anumber property
// composeApp/src/commonMain/kotlin/api/model/IconProperty.kt
package api.model
import kotlinx.serialization.Serializable
/**
* https://developers.notion.com/reference/emoji-object
*/
@Serializable
data class IconProperty(
val emoji: String? = null,
val type: String? = "emoji",
)
// composeApp/src/commonMain/kotlin/api/model/ExpensePageProperties.kt
package api.model
import api.serializers.MoneySerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ExpensePageProperties(
@SerialName("Expense")
val expense: TitleProperty,
@SerialName("Amount")
val amount: NumberProperty,
)
@Serializable
class TitleProperty(val id: String, val title: List<Value>) {
@Serializable
data class Value(
@SerialName("plain_text")
val plainText: String,
)
}
@Serializable
data class NumberProperty(
@Serializable(with = MoneySerializer::class)
val number: Int,
)
To finish up, number property
from Notion uses float numbers, which is not what we want to work inside our app.
To address that, we can create a custom serializer.
// composeApp/src/commonMain/kotlin/api/serializers/MoneySerializer.kt
package api.serializers
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
object MoneySerializer : KSerializer<Int> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Money", PrimitiveKind.INT)
override fun serialize(
encoder: Encoder,
value: Int,
) = encoder.encodeFloat(value.toFloat() / 100)
override fun deserialize(decoder: Decoder): Int = (decoder.decodeFloat() * 100).toInt()
}
Unfortunately, because of Notion limitation, there are no DB Integers, so we might still face rounding issues.
But even though, I chose to do the conversion to at least work with integers inside the app.
Regarding modeling our API, we are done for now, we will have to revisit those files later on.
Injecting our client with Koin
Now it's time to use our APIClient
, we could instantiate one in our ViewModel and move on, which is totally fine for our case.
Still, we will have more places where APIClient
is needed, so we would need to instantiate it in all those places as well.
We also have to provide the authentication token for the APIClient
, which would require us to manually pass this parameter whenever necessary.
This is where Koin
can help us out, from their website, Koin is a
The pragmatic Kotlin & Kotlin Multiplatform Dependency Injection framework
Well... DI is nothing more than moving the responsibility of creating something to somewhere else, just like function params. For example, let's say we have a function called every time we want to save a user. This is simple enough, but In this case, DI is just moving this responsibility to somewhere else 💥 you just did it, no need of "fancy jargon's" and concepts. One other example is in cases where you want to write some test for this function, now we can even expand this. There are libraries that help us out do fancier stuff with that, but in general... that's it!ℹ️ What is this dependency injection (DI)??
fun saveUser(user: User) {
val service = MyApiClient(
apiKey = "abc",
logger = KotlinLogging.logger {}
)
service.saveUser(user)
// ... do some processing
}
saveUser
function must know how to build MyApiClient
instance, which can be something non-trivial and maybe requiring us to pass none relevant parameters to this function.
fun saveUser(user: User, apiKey: String, logger: KLogger) {
val service = MyApiClient(
apiKey = apiKey,
logger = logger
)
service.saveUser(user)
// ... do some processing
}
apiKey
and logger
are something that doesn't seem to belong to saveUser
function.
saveUser
might make use of more services, which would require us to send more parametersMyApiClient
, which would require us to inject apiKey
and logger
everywhere
fun saveUser(user: User, service: MyApiClient) {
service.saveUser(user)
// ... do some processing
}
interface MyApiClient {
fun saveUser(user: User): Unit
}
// --
fun saveUser(user: User, service: MyApiClient) {
service.saveUser(user)
// ... do some processing
}
// -- APP
class MyKtorApiClient(apiKey: String, logger: KLogger): MyApiClient {
// ....
}
val myClient = MyKtorApiClient(
apiKey = "abc",
logger = KotlinLogging.logger {}
)
saveUser(
user = User(),
service = myClient
)
// -- TEST
class MyInMemoryApiClient: MyApiClient {
// ....
}
val myClient = MyInMemoryApiClient()
saveUser(
user = User(),
service = myClient
)
Refactor ExpensesScreenViewModel
First of all, ExpensesScreenViewModel
will receive an APIClient
.
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreenViewModel.kt
- class ExpensesScreenViewModel : StateScreenModel<ExpensesScreenState>(
+ class ExpensesScreenViewModel(apiClient: APIClient) : StateScreenModel<ExpensesScreenState>(
ExpensesScreenState(
data = listOf(),
),
) {
Handle environment variables
We can't hardcode our Notion API Token and Database id in the application, we need to provide them through a safer way.
There are a few ways we can handle environment variables, I like dotenv
files, and we already injected them in our composeApp/build.gradle.kts
through buildkonfig
.
The neat part is that BuildKonfig
generates a file at compile time with those variables that were configured there, it happens automatically when the kotlin compile task is triggered.
In case you wish to manually generate it, run the task ./gradlew generateBuildKonfig
.
// composeApp/build/buildkonfig/commonMain/org/expense/tracker/BuildKonfig.kt
package org.expense.tracker
import kotlin.String
internal object BuildKonfig {
public val NOTION_TOKEN: String = "MY_TOKEN"
public val NOTION_DATABASE_ID: String = "MY_DB_ID"
}
A great benefit is that it will work for any platform.
Another approach would be to work with expect-actual
functions, and handle that on each platform specifically.
I like creating my own utility function to get environment variables (despite having access to BuildKonfig
).
I am doing this mostly because I am also learning about this technology, and I found little content over how people handle env variables.
At least creating this kind of utility layer would allow me to tweak how I fetch my env vars and change at one place in case I find a different approach over BuildKonfig
.
// composeApp/src/commonMain/kotlin/utils/Env.kt
package utils
import api.DatabaseId
import org.expense.tracker.BuildKonfig
object Env {
val NOTION_TOKEN: String
get() {
val notionToken = BuildKonfig.NOTION_TOKEN
require(notionToken.isNotBlank()) { "You must provide a NOTION_TOKEN env variable" }
return notionToken
}
val NOTION_DATABASE_ID: DatabaseId // hey, this is our branded type =D
get() {
val notionDatabaseId = BuildKonfig.NOTION_DATABASE_ID
require(notionDatabaseId.isNotBlank()) { "You must provide a NOTION_DATABASE_ID env variable" }
return notionDatabaseId
}
}
Don't forget to add your dotenv
files and configure gitignore.
Never ever commit them!
// .gitignore
!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
+.env
# .env.sample
NOTION_TOKEN=
NOTION_DATABASE_ID=
# .env
NOTION_TOKEN=secret_YOUR_SECRET
NOTION_DATABASE_ID=your_DB_ID
Then, we need to configure Koin itself.
// composeApp/src/commonMain/kotlin/Koin.kt
import api.APIClient
import org.koin.dsl.module
import ui.screens.expenses.ExpensesScreenViewModel
import utils.Env
object Koin {
val appModule =
module {
/**
* Here we are creating a Koin module and asking
* > Hey Koin, when someone asks for you an `ApiClient`, please provide the return of this function, and make it a singleton.
*/
single<APIClient> { APIClient(Env.NOTION_TOKEN) }
/**
* Our list screen ViewModel won't be a singleton, we will always re-create it once the user navigates to the screen
* So in this case we are asking Koin to instantiate a `ExpensesScreenViewModel` every time someone asks Koin for it.
* The interesting bit here is that `apiClient` parameter will be resolved from the singleton we defined.
* Koin relies on the type and identifies which thing to inject.
*/
factory { ExpensesScreenViewModel(apiClient = get()) }
}
}
To make it clear...
all the dependencies are resolved at compile time and based on the typing
Making an analogy if you are used to something like PHP, what we did would be like this in a Laravel app:
<?php
namespace App\Providers;
// ...imports
class MyServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(
APIClient::class,
fn (Application $app) => new APIClient(env('NOTION_TOKEN')
);
// Adds a singleton `Connection`
$this->app->bind(
ExpensesScreenViewModel::class,
fn (ContainerInterface $container) => new ExpensesScreenViewModel($container->get(APIClient::class));
);
}
}
And now we have to integrate Koin with our app
// composeApp/src/commonMain/kotlin/App.kt
@Composable
fun App() {
+ KoinApplication(
+ application = {
+ modules(Koin.appModule)
+ },
+ ) {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
Scaffold {
Navigator(ExpensesScreen) { navigator ->
SlideTransition(navigator)
}
}
}
}
}
+}
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreen.kt
- import cafe.adriel.voyager.core.model.rememberScreenModel
+ import cafe.adriel.voyager.koin.getScreenModel
object ExpensesScreen : Screen {
@Composable
override fun Content() {
- val viewModel = rememberScreenModel { ExpensesScreenViewModel() }
+ val viewModel = getScreenModel<ExpensesScreenViewModel>()
val state by viewModel.state.collectAsState()
And finally, we can refactor ExpensesScreenViewModel
to actually use the APIClient
package ui.screens.expenses
import Expense
import api.APIClient
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.launch
import utils.Env
private val logger = KotlinLogging.logger {}
data class ExpensesScreenState(
val data: List<Expense>,
) {
val avgExpenses: String
get() = data.map { it.price }.average().toString()
}
class ExpensesScreenViewModel(apiClient: APIClient) : StateScreenModel<ExpensesScreenState>(
ExpensesScreenState(
data = listOf(),
),
) {
init {
screenModelScope.launch {
logger.info { "Fetching expenses" }
val database = apiClient.queryDatabaseOrThrow(Env.NOTION_DATABASE_ID)
val expenses = database.results.map {
Expense(
id = it.id,
name = it.properties.expense.title.firstOrNull()?.plainText ?: "-",
icon = it.icon?.emoji,
price = it.properties.amount.number,
)
}
mutableState.value = ExpensesScreenState(
data = expenses
)
}
}
}
If you run the app, we finally have data coming from Notion.
We are finally fetching dynamic data, but there are quite a few things left to do in our list screen.
In the next part of this series, we will handle user feedback by handling loading/error/success states and tweaking the UI a bit to display monetary values properly.
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)