Logo

dev-resources.site

for different kinds of informations.

Building a subscription tracker Desktop and iOS app with compose multiplatform — Offline data

Published at
12/20/2024
Categories
kotlin
compose
kmp
tutorial
Author
kuroski
Categories
4 categories in total
kotlin
open
compose
open
kmp
open
tutorial
open
Author
7 person written this
kuroski
open
Building a subscription tracker Desktop and iOS app with compose multiplatform — Offline data

Photo by Jesse Schoff 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 worked on providing feedback to users when using the application.

Feedback

In this section we will build the base for offline support, making it easier on later steps to use SQLite through SQLDelight.

Offline data + Repositories

Since SQLDelight requires some configuration, we can take an "incremental step" and create an in memory storage implementation first.

We can build all the structure necessary to interact with data, and at the very end, switch it to SQLDelight.

// composeApp/src/commonMain/kotlin/ExpenseStorage.kt

import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.flow.*

private val logger = KotlinLogging.logger {}

interface ExpenseStorage {
    suspend fun saveExpenses(newExpenses: List<Expense>)

    suspend fun getExpenses(): StateFlow<List<Expense>>
}

class InMemoryExpenseStorage : ExpenseStorage {
    private val storedExpenses = MutableStateFlow(emptyList<Expense>())

    override suspend fun saveExpenses(newExpenses: List<Expense>) {
        logger.debug { "Replacing expenses on storage" }
        storedExpenses.value = newExpenses
    }

    override suspend fun getExpenses(): StateFlow<List<Expense>> = storedExpenses.asStateFlow()
}
Enter fullscreen mode Exit fullscreen mode

All right, we have a InMemoryExpenseStorage implementation, which will be in charge of being our "source of truth" for our expense list.

ℹ️ What is "StateFlow" and why we are using it here??

StateFlow creates a flow of data that can be observed by multiple collectors, and it always holds a state (the latest value).

StateFlow is also a hot observable, which means it starts producing values as soon as it is created.

Next, we can define which storage to use and delegate its creation to Koin

// composeApp/src/commonMain/kotlin/Koin.kt

object Koin {
    val appModule =
        module {
            single<SnackbarHostState> { SnackbarHostState() }
            single<APIClient> { APIClient(Env.NOTION_TOKEN) }
+           single<ExpenseStorage> { InMemoryExpenseStorage() }

            factory { ExpensesScreenViewModel(apiClient = get()) }
        }
}
Enter fullscreen mode Exit fullscreen mode

This way, when starting integrating SQLDeligt, we need to make a change in one place.

And finally, we can create a Repository to be used for all operations related to Expenses.

// composeApp/src/commonMain/kotlin/ExpenseRepository.kt

import api.APIClient
import api.QueryDatabaseRequest
import io.github.oshai.kotlinlogging.KotlinLogging
import io.ktor.utils.io.core.*
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.component.KoinComponent

private val logger = KotlinLogging.logger {}

class ExpenseRepository(
    private val databaseId: String,
    private val apiClient: APIClient,
    private val expenseStorage: ExpenseStorage,
) : KoinComponent, Closeable {
    suspend fun all(forceUpdate: Boolean = false): StateFlow<List<Expense>> {
        // get local expenses from our storage
        val expenses = expenseStorage.getExpenses()

        /**
         * We are moving the request handling from ViewModel to here
         * Now we can either "force" upgrade, handle initial request
         * or even just grab data that is already stored
         */
        if (forceUpdate || expenses.value.isEmpty()) {
            logger.debug { "Refreshing expenses" }
            val response = apiClient.queryDatabaseOrThrow(databaseId, request)
            val newExpenses = response.results.map {
                Expense(
                    id = it.id,
                    name = it.properties.expense.title.firstOrNull()?.plainText ?: "-",
                    icon = it.icon?.emoji,
                    price = it.properties.amount.number,
                )
            }
            expenseStorage.saveExpenses(newExpenses)
        }

        logger.debug { "Loading all expenses" }
        return expenses
    }

    override fun close() {
        apiClient.close()
    }
}
Enter fullscreen mode Exit fullscreen mode

Since now we have to deal with requests and storage handling, I am placing this logic in a "Repository" class to avoid overcomplicating the ViewModel, especially since we will add more operations when reaching other screens.

You can choose to work with interfaces like we did with the ExpenseStorage if you like it, for this case, I have chosen not to do it.

Here we also have to deal with the data that is stored in our app.

From what I have found, there are tons of ways to handle this.

I have chosen to force local data update through the forceUpdate parameter.

Now we have to integrate this repository with our application.
First, we can upgrade our ViewModel to actually use it instead of our APIClient.

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

- class ExpensesScreenViewModel(private val apiClient: APIClient) : StateScreenModel<ExpensesScreenState>(
+ class ExpensesScreenViewModel(private val expenseRepository: ExpenseRepository) : StateScreenModel<ExpensesScreenState>(
    ExpensesScreenState(
        data = RemoteData.NotAsked,
    ),
) {
    init {
        fetchExpenses()
    }

-    fun fetchExpenses() {
+    fun fetchExpenses(forceUpdate: Boolean = false) {
        mutableState.value = mutableState.value.copy(data = RemoteData.Loading)

        screenModelScope.launch {
            try {
-                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(
-                        lastSuccessData = expenses,
-                        data = RemoteData.success(expenses),
-                    )

+               expenseRepository.all(forceUpdate).collect { expenses ->
+                   logger.info { "Expenses list was updated" }
+                   mutableState.value =
+                       ExpensesScreenState(
+                           lastSuccessData = expenses,
+                           data = RemoteData.success(expenses),
+                       )
                }
            } catch (cause: Throwable) {
                logger.error { "Cause ${cause.message}" }
                cause.printStackTrace()
                mutableState.value = mutableState.value.copy(data = RemoteData.failure(cause))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We will take advantage of Koin and create a singleton of our Repository.

// composeApp/src/commonMain/kotlin/Koin.kt

// .....
object Koin {
    val appModule =
        module {
            single<SnackbarHostState> { SnackbarHostState() }
            single<APIClient> { APIClient(Env.NOTION_TOKEN) }
            single<ExpenseStorage> { InMemoryExpenseStorage() }
            single { ExpenseRepository(Env.NOTION_DATABASE_ID, get(), get()) }

            factory { ExpensesScreenViewModel(apiClient = get()) }
        }
}
Enter fullscreen mode Exit fullscreen mode

Then we have to upgrade our screen refresh button to force upgrading the list items.

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

navigationIcon = {
                        IconButton(
                            enabled = state.data !is RemoteData.Loading,
-                            onClick = { viewModel.fetchExpenses() },
+                            onClick = { viewModel.fetchExpenses(forceUpdate = true) },
                        ) {
                            Icon(Icons.Default.Refresh, contentDescription = null)
                        }
                    },

Enter fullscreen mode Exit fullscreen mode

And that's it.

We have shifted the responsibility of fetching/storing Expenses to a repository, which later we will also use it for other operations.

By running the app, things should look the same as before.

Application list screen with in memory data

Helper functions to convert API responses to Domain

This is optional, but we can also write some helper functions to help translate API responses into our application internal model (our domain).

// composeApp/src/commonMain/kotlin/api/ExpensePageResponse.kt

//.....

@Serializable
data class ExpensePageResponse(
    val id: ExpenseId,
    val icon: IconProperty? = null,
    val properties: ExpensePageProperties,
)

fun ExpensePageResponse.toDomain(): Expense = Expense(
    id = id,
    name = properties.expense.title.firstOrNull()?.plainText ?: "-",
    icon = icon?.emoji,
    price = properties.amount.number,
)

// composeApp/src/commonMain/kotlin/api/QueryDatabaseResponse.kt

// ....
@Serializable
data class QueryDatabaseResponse(
    val results: List<ExpensePageResponse>,
    @SerialName("next_cursor")
    val nextCursor: String? = null,
    @SerialName("has_more")
    val hasMore: Boolean,
)

fun QueryDatabaseResponse.toDomain(): List<Expense> =
    results.map { it.toDomain() }
Enter fullscreen mode Exit fullscreen mode

Placing those functions here or even as companion objects (factory-function-like) in our Expense model is mostly a design choice.

For this one, I place it "closer" to the API/Network layer, where data is decoded from the API directly.

I find it a bit easier to search for it, and maybe to reason about "your model shouldn't know about the external API model".

Now we can clean up our repository and remove the responsibility of mapping API models to our domain.

// composeApp/src/commonMain/kotlin/ExpenseRepository.kt

// .......
    suspend fun all(forceUpdate: Boolean = false): StateFlow<List<Expense>> {
        val expenses = expenseStorage.getExpenses()

        if (forceUpdate || expenses.value.isEmpty()) {
            logger.debug { "Refreshing expenses" }
            val response = apiClient.queryDatabaseOrThrow(databaseId, request)
-            val newExpenses = response.results.map {
-                Expense(
-                    id = it.id,
-                    name = it.properties.expense.title.firstOrNull()?.plainText ?: "-",
-                    icon = it.icon?.emoji,
-                    price = it.properties.amount.number,
-                )
-            }
-            expenseStorage.saveExpenses(newExpenses)
+            expenseStorage.saveExpenses(response.toDomain())
        }

        logger.debug { "Loading all expenses" }
        return expenses
    }

    override fun close() {
        apiClient.close()
    }
}
Enter fullscreen mode Exit fullscreen mode

In the next part of this series, we will handle editing the expenses.

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.

compose Article's
30 articles in total
Favicon
Getting Started with Android Testing: Building Reliable Apps with Confidence (Part 3/3)
Favicon
Getting Started with Android Testing: Building Reliable Apps with Confidence (Part 2/3)
Favicon
Our experience becoming a Compose-first app
Favicon
Building a subscription tracker Desktop and iOS app with compose multiplatform — Offline data
Favicon
Mastering @Stable in Jetpack Compose for Better UI Performance
Favicon
Do you still use version in Docker compose?
Favicon
Building a subscription tracker Desktop and iOS app with compose multiplatform—Providing feedbacks
Favicon
Self-host - Part 2 - Zero-Downtime Deployment using Docker Swarm
Favicon
Building a subscription tracker Desktop and iOS app with compose multiplatform - Configuring Notion
Favicon
How to create LazyColumn with drag and drop elements in Jetpack Compose. Part 1.
Favicon
Retro on "Docker Compose for Developers"
Favicon
CountryCodePicker in Compose Multiplatform for Android and iOS
Favicon
Coil and Ktor in Kotlin Multiplatform Compose project
Favicon
Adaptar Kotlin 2.0 en aplicaciones Android
Favicon
Building a subscription tracker Desktop and iOS app with compose multiplatform - Setup
Favicon
30-days plan to master Jetpack Compose with resources and three practice projects
Favicon
CMPPreference - Compose Multiplatform
Favicon
Introducing Compose Multiplatform Media Player: Your Go-To Solution for Seamless Media Playback
Favicon
QRKit — QRCode Scanning in Compose Multiplatform for Android and iOS
Favicon
SDP-SSP-Compose-Multiplatform
Favicon
Using docker compose watch with Node.js
Favicon
SharedPreferences Debugging: Unlocking the Developer Mode with Prefixer
Favicon
Navigation in Compose Multiplatform with Animations
Favicon
Introducing Backgroundable - Your Wallpaper Companion! 🌟
Favicon
Kilua - new Kotlin/Wasm web framework
Favicon
I've just open sourced N8
Favicon
One Minute: Compose
Favicon
State using Jetpack Compose
Favicon
compose middleware
Favicon
How to create my first Jetpack Compose Android App

Featured ones: