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