DEV Community

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

Posted on

Building a subscription tracker Desktop and iOS app with compose multiplatform - Configuring Notion

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.

List screen result

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) Create new page image
  • Then type /database and select the Database - inline option Notion database-inline command
  • Now, we can edit the database properties Notion properties menu
    • Rename the column Name to Expense
    • Remove Tags column
    • Create a new property and name it Amount, make sure it is a Number type, and select your currency in the Number format option Amount column options

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
Notion, adding icon through 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 select New template Notion
    • Then you can provide some values, in this case, just provide a random icon with some placeholder text Notion template form
  • From the template list, click on the "..." menu of the template you have just created and select the "Set as default" option Setting our template as default
  • Now, every time you create a new entry, the icon will be already there and make your life easier when handling new data Creating a new entry with our new template

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 View my integrations menu
  • 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 the Capabilities menu

In the end, you should have something like this
Notion integration

Capabilities menu

Finally, you can find your API token on the Secrets page, we will be using that to make the requests.

Secrets page

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 Expenses database
  • Then connect it to the Expenses integration we have created Connecting the integration into the database

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

Querying databases with Postman

Finally, we have our data 🎉

Database query result

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()
}


Enter fullscreen mode Exit fullscreen mode

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 execute clientEngine() 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()
Enter fullscreen mode Exit fullscreen mode

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
Fleet info on expect-actual

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()
}
Enter fullscreen mode Exit fullscreen mode

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
+     }
+ }
Enter fullscreen mode Exit fullscreen mode

ℹ️ Why have DatabaseId and ExpenseId as inline value class??

I thought it could be interesting to add DatabaseId as a branded type, as an example of how you can narrow your application types.

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.
This helps improve type safety, makes your code more explicit, and safe.

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.

fun searchUser(id: String) {
    // ...
}

fun searchExpense(id: String) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Auto completion for searchUser and searchExpense

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 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
Enter fullscreen mode Exit fullscreen mode

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.

@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
}
Enter fullscreen mode Exit fullscreen mode

Branded types example

By simply creating a 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.

ℹ️ Why having this "intermediate" model and not just directly map API results into our app model??

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

  • Your endpoint shouldn't be necessarily shaped FOR a specific screen
    • I had several cases where one endpoint was built for screen X, but later on it was also used on screens Y and Z
    • Every screen has its own requirements, the endpoint and|or screen are likely to change
  • If the endpoint changes, you don't have to refactor your screen, do whatever you need, and pass down properties to follow the defined screen contract

I chose to use Notion for this purpose

  • their API is far from ideal
  • it is verbose
  • you don't have control over it
  • it was built to be generic, and for our case it makes little sense to try creating a "generic" model to comport Notion's API
  • they might change it, and you only have to adapt one thing from the application

This same rule goes to screens

  • If you have an endpoint that returns an "Expense" with its details, it doesn't mean you should use this model to shape the "Edit expense" screen
  • Form field values are nullable
  • The form might touch different entities other than the expense
  • The shape of the form generally is different from your app modeling, a name field in the Expense class will always exist, but a name in the form is nullable
  • Trying to generalise this might give you a hard time instead of having this separation

Given that, we will have the following flow

  1. Receive Notion API response
  2. Serialize it into a "Response" data class
  3. 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,
)
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

And now comes the tricky part.

Notion Databases entries are pages, which are generic.

In our case, they contain:

Since we are requesting our Expenses database, we know there are only two properties

Database properties

// 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,
)

Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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

ℹ️ What is this dependency injection (DI)??

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.

fun saveUser(user: User) {
    val service = MyApiClient(
        apiKey = "abc", 
        logger = KotlinLogging.logger {}
    )
    service.saveUser(user)
    // ... do some processing
}
Enter fullscreen mode Exit fullscreen mode

This is simple enough, but 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
}
Enter fullscreen mode Exit fullscreen mode

In this case, apiKey and logger are something that doesn't seem to belong to saveUser function.

  • imagine that maybe saveUser might make use of more services, which would require us to send more parameters
  • Or other functions might also want to use MyApiClient, which would require us to inject apiKey and logger everywhere

DI is just moving this responsibility to somewhere else

fun saveUser(user: User, service: MyApiClient) {
    service.saveUser(user)
    // ... do some processing
}
Enter fullscreen mode Exit fullscreen mode

💥 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.

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
)
Enter fullscreen mode Exit fullscreen mode

There are libraries that help us out do fancier stuff with that, but in general... that's it!


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(),
    ),
) {
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
        }
}
Enter fullscreen mode Exit fullscreen mode

Don't forget to add your dotenv files and configure gitignore.

Never ever commit them!

// .gitignore

!*.xcworkspace/contents.xcworkspacedata
**/xcshareddata/WorkspaceSettings.xcsettings
+.env
Enter fullscreen mode Exit fullscreen mode
# .env.sample
NOTION_TOKEN=
NOTION_DATABASE_ID=

# .env
NOTION_TOKEN=secret_YOUR_SECRET
NOTION_DATABASE_ID=your_DB_ID
Enter fullscreen mode Exit fullscreen mode

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()) }
        }
}

Enter fullscreen mode Exit fullscreen mode

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));
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                }
            }
        }
    }
+}
Enter fullscreen mode Exit fullscreen mode

// 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()
Enter fullscreen mode Exit fullscreen mode

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
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If you run the app, we finally have data coming from Notion.
Dynamic list

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.

Time to write the third part

Top comments (0)