DEV Community

Cover image for Building a subscription tracker Desktop and iOS app with compose multiplatform — Offline data
Daniel Kuroski
Daniel Kuroski

Posted on

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.

Top comments (0)