Photo by Luzelle Cockburn 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 configured a Notion database and listed its contents in our application.
It is nice that we are getting dynamic results, but we still have some work to do on the list page:
- Monetary values aren't properly formatted
- The screen is unresponsive, no loading/error state is displayed
In this section, we will address those problems and give some minimal information about its status, let's start!
UI feedbacks
When dealing with plain screens that are fetching data, there are a few possible states we can list:
- We haven't requested anything yet
- We've requested something, but we haven't got a response yet
- We got a response, but it was an error
- We got a response, and it was the data we wanted
This can be represented in the following image
There are several ways we can handle feedbacks based on a request state.
One common way to manage this is by using flags or individual variables, like:
data class State<E, T>(val data: T?, val error: E?, val isLoading: Boolean)
Then you can if-else
your way in the screen to display the desired state.
This is a common approach in JS land, and there are plenty of libraries that help abstract the logic and make sure the state is consistent, like swr
or TanStack Query
.
Some drawbacks to this are:
-
We tend to ignore handling some scenarios
Who needs to provide feedback if something went wrong or indicate progress for async operations anyway, right?
-
Managing those cases by hand can be pretty verbose, and still... it is possible to achieve inconsistent states
- In the
data class
example, it is possible to haveisLoading = true
and anerror
at the same time
- In the
-
This is a
n!
issue- If you are handling
data
,error
andisLoading
cases, there are3! = 6
different possibilities to cover - If you need to add one scenario (like the
Not Asked
), then it is4! = 24
possibilities
- If you are handling
A different solution that I particularly enjoy is RemoteData
ADT (algebraic data type), which will be used to represent the current request state (or any "promise-like" case).
Where RemoteData
shines the most is that it helps make "impossible states impossible", contrary to the first example, it is impossible to have "loading + error" states (for example), or any other invalid case.
I have first heard about it when I was working with Elm [1] through a popular blog post (at the time) How elm slays a UI antipattern.
There are implementations already written for multiple languages [1] [2] [3], but since our app is not so complex, we can create a simpler version ourselves.
Creating a simple RemoteData implementation
// shared/src/utils/RemoteData.kt
package utils
// There are two generics, one that represents the type of the "Error" and a second one that represents the "Success" type
sealed class RemoteData<out E, out A> {
data object NotAsked : RemoteData<Nothing, Nothing>()
data object Loading : RemoteData<Nothing, Nothing>()
data class Success<out E, out A>(val data: A) : RemoteData<E, A>()
data class Failure<out E, out A>(val error: E) : RemoteData<E, A>()
companion object {
// We need to define constructors for "Success" and "Failure" cases given they are `data class` and not `data object`
fun <A> success(data: A): RemoteData<Nothing, A> = Success(data)
fun <E> failure(error: E): RemoteData<E, Nothing> = Failure(error)
}
}
// For operators, we only need `getOrElse`
// This is used to remove boilerplate for cases where you need to access data value directly (which you will see a case in the section below)
// Normally you would find other things like `fold`, `fold3`, `map`, `chain`, etc...
// But for our case, only `getOrElse` is enough
fun <A, E> RemoteData<E, A>.getOrElse(otherData: A): A =
when (this) {
is RemoteData.Success -> data
else -> otherData
}
Now we need to integrate RemoteData
into our application.
Refactoring ViewModel
The first step is to refactor our main model representation.
Previously we had a List<Expense>
, which was initially empty, and after we had a result, we populated it.
Now, we will wrap our state data
property it in a RemoteData
.
In the end, we have a RemoteData<Throwable, List<Expense>>
, this means that:
data
property is a structure that might
- Not have started any request
- Be pending (waiting for some request)
- Be a successful request that will be a
List<Expense>
- Be a failed request that is a
Throwable
Because we wrap it in a RemoteData
, we cannot access List<Expense>
without unwrapping it first.
We will also need to set the state accordingly depending if is NotAsked
, Pending
, Failure<Throwable>
or Success<List<Expense>>
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreenViewModel.kt
data class ExpensesScreenState(
- val data: List<Expense>,
+ val data: RemoteData<Throwable, List<Expense>>,
) {
val avgExpenses: String
- get() = data.map { it.price }.average().toString()
+ get() = data.getOrElse(emptyList()).map { it.price }.average().toString()
}
class ExpensesScreenViewModel(apiClient: APIClient) : StateScreenModel<ExpensesScreenState>(
ExpensesScreenState(
- data = listOf(),
+ data = RemoteData.NotAsked,
),
) {
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
- )
- }
+ fetchExpenses()
}
+ fun fetchExpenses() {
+ 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(
+ data = RemoteData.success(expenses),
+ )
+ } catch (cause: Throwable) {
+ logger.error { "Cause ${cause.message}" }
+ cause.printStackTrace()
+ mutableState.value = mutableState.value.copy(data = RemoteData.failure(cause))
+ }
+ }
+ }
}
In the end ExpensesScreenViewModel.kt
should look like this:
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreenViewModel.kt
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
import utils.RemoteData
import utils.getOrElse
private val logger = KotlinLogging.logger {}
data class ExpensesScreenState(
val data: RemoteData<Throwable, List<Expense>>,
) {
val avgExpenses: String
get() = data.getOrElse(emptyList()).map { it.price }.average().toString()
}
class ExpensesScreenViewModel(private val apiClient: APIClient) : StateScreenModel<ExpensesScreenState>(
ExpensesScreenState(
data = RemoteData.NotAsked,
),
) {
init {
fetchExpenses()
}
fun fetchExpenses() {
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(
data = RemoteData.success(expenses),
)
} catch (cause: Throwable) {
logger.error { "Cause ${cause.message}" }
cause.printStackTrace()
mutableState.value = mutableState.value.copy(data = RemoteData.failure(cause))
}
}
}
}
Great, now we need to upgrade our screen.
Refactoring ExpensesScreen
The same thing as before, now our state.data
will be a RemoteData<Throwable, List<Expense>>
, and not a List<Expense>
directly.
Here we also
- Are checking for failures
if (state.data is RemoteData.Failure)
, and logging the error - Added a "Refresh button", allowing to re-fetch the Notion database for new entries
- Unwrapping
state.data
with awhen
expression, and printing out something based on the current state
package ui.screens.expenses
import Expense
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import io.github.oshai.kotlinlogging.KotlinLogging
import ui.theme.BorderRadius
import ui.theme.IconSize
import ui.theme.Spacing
import ui.theme.Width
import utils.RemoteData
private val logger = KotlinLogging.logger {}
object ExpensesScreen : Screen {
@Composable
override fun Content() {
val viewModel = getScreenModel<ExpensesScreenViewModel>()
val state by viewModel.state.collectAsState()
val onExpenseClicked: (Expense) -> Unit = {
logger.info { "Redirect to edit screen" }
}
// [1]
// here every time `data` changes, we can check for failures and handle its result
// maybe by showing a toast or by tracking the error
LaunchedEffect(state.data) {
val remoteData = state.data
if (remoteData is RemoteData.Failure) {
logger.error { remoteData.error.message ?: "Something went wrong" }
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
// [2]
// We can now a button to refresh the list
navigationIcon = {
IconButton(
enabled = state.data !is RemoteData.Loading,
onClick = { viewModel.fetchExpenses() },
) {
Icon(Icons.Default.Refresh, contentDescription = null)
}
},
title = {
Text("My subscriptions", style = MaterialTheme.typography.titleMedium)
},
)
},
bottomBar = {
BottomAppBar(
contentPadding = PaddingValues(horizontal = Spacing.Large),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(
"Average expenses",
style = MaterialTheme.typography.bodyLarge,
)
Text(
"Per month".uppercase(),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
state.avgExpenses,
style = MaterialTheme.typography.labelLarge,
)
}
}
},
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
// [3]
// We don't need to use if-else conditions
// We need only to unwrap `state.data` and handle each scenario
// A nice thing is that now we have exaustive chekings!
when (val remoteData = state.data) {
is RemoteData.NotAsked, is RemoteData.Loading -> {
Column {
Column(
modifier = Modifier.fillMaxWidth().padding(Spacing.Small_100),
verticalArrangement = Arrangement.spacedBy(Spacing.Small_100),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
modifier = Modifier.width(Width.Medium),
)
}
}
}
is RemoteData.Failure -> {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
Spacing.Small,
alignment = Alignment.CenterVertically
),
) {
Text("Oops, something went wrong", style = MaterialTheme.typography.titleMedium)
Text("Try refreshing")
FilledIconButton(
onClick = { viewModel.fetchExpenses() },
) {
Icon(Icons.Default.Refresh, contentDescription = null)
}
}
}
is RemoteData.Success -> {
ExpenseList(remoteData.data, onExpenseClicked)
}
}
}
}
}
}
// ....
If you run the application, you should finally have UI feedback!!
Persisting list entries once the first load is complete
You might notice when trying to click on the refresh button that the list is swapped with a spinner.
This might not be ideal in some cases, so... what to do if we want to keep the previously computed list?
Using RemoteData
might force you to think some scenarios differently.
As a simple solution to this problem, we can "cache" the last successful list entries.
It can be done directly on the screen, or you can store it as a state in ViewModel.
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreenViewModel.kt
data class ExpensesScreenState(
+ val lastSuccessData: List<Expense> = emptyList(),
val data: RemoteData<Throwable, List<Expense>>,
) {
val avgExpenses: String
get() = data.getOrElse(emptyList()).map { it.price }.average().toString()
}
// ......
fun fetchExpenses() {
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),
)
} catch (cause: Throwable) {
logger.error { "Cause ${cause.message}" }
cause.printStackTrace()
mutableState.value = mutableState.value.copy(data = RemoteData.failure(cause))
}
}
}
And now you can render them on screen when needed
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreen.kt
when (val remoteData = state.data) {
is RemoteData.NotAsked, is RemoteData.Loading -> {
Column {
Column(
modifier = Modifier.fillMaxWidth().padding(Spacing.Small_100),
verticalArrangement = Arrangement.spacedBy(Spacing.Small_100),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator(
modifier = Modifier.width(Width.Medium),
)
}
+ ExpenseList(
+ state.lastSuccessData,
+ onExpenseClicked,
+ )
}
}
is RemoteData.Failure -> {
+ if (state.lastSuccessData.isNotEmpty()) {
+ ExpenseList(
+ state.lastSuccessData,
+ onExpenseClicked,
+ )
+ } else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(
Spacing.Small,
alignment = Alignment.CenterVertically
),
) {
Text("Oops, something went wrong", style = MaterialTheme.typography.titleMedium)
Text("Try refreshing")
FilledIconButton(
onClick = { viewModel.fetchExpenses() },
) {
Icon(Icons.Default.Refresh, contentDescription = null)
}
}
+ }
}
is RemoteData.Success -> {
ExpenseList(remoteData.data, onExpenseClicked)
}
}
Adding toasts for errors
As an extra touch, let's add a toast for errors
// composeApp/src/commonMain/kotlin/Koin.kt
import androidx.compose.material3.SnackbarHostState
import api.APIClient
import org.koin.dsl.module
import ui.screens.expenses.ExpensesScreenViewModel
import utils.Env
object Koin {
val appModule =
module {
+ single<SnackbarHostState> { SnackbarHostState() }
single<APIClient> { APIClient(Env.NOTION_TOKEN) }
factory { ExpensesScreenViewModel(apiClient = get()) }
}
}
// composeApp/src/commonMain/kotlin/App.kt
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.SlideTransition
import org.koin.compose.KoinApplication
import org.koin.compose.koinInject
import ui.screens.expenses.ExpensesScreen
import ui.theme.AppTheme
@Composable
fun App() {
KoinApplication(
application = {
modules(Koin.appModule)
},
) {
AppTheme {
+ val snackbarHostState = koinInject<SnackbarHostState>()
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
- Scaffold {
+ Scaffold(
+ snackbarHost = {
+ SnackbarHost(hostState = snackbarHostState)
+ },
+ ) {
Navigator(ExpensesScreen) { navigator ->
SlideTransition(navigator)
}
}
}
}
}
}
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreen.kt
// ......
@Composable
override fun Content() {
+ val snackbarHostState = koinInject<SnackbarHostState>()
val viewModel = getScreenModel<ExpensesScreenViewModel>()
val state by viewModel.state.collectAsState()
val onExpenseClicked: (Expense) -> Unit = {
logger.info { "Redirect to edit screen" }
}
LaunchedEffect(state.data) {
val remoteData = state.data
if (remoteData is RemoteData.Failure) {
- logger.error { remoteData.error.message ?: "Something went wrong" }
+
snackbarHostState.showSnackbar(remoteData.error.message ?: "Something went wrong")
}
}
Formatting money
Let's finally address the money formatting.
First, let's add a computed property for formatting our prices.
// composeApp/src/commonMain/kotlin/Model.kt
import kotlinx.serialization.Serializable
+expect fun formatPrice(amount: Int): String
typealias ExpenseId = String
@Serializable
data class Expense(
val id: ExpenseId,
val name: String,
val icon: String?,
val price: Int,
-)
+) {
+ val formattedPrice: String
+ get() = formatPrice(price)
+}
Since formatting numbers are handled differently depending on the platform, we are using a expect-actual
function.
Let's provide the platform-specific implementations.
// composeApp/src/desktopMain/kotlin/Model.jvm.kt
import java.text.NumberFormat
import java.util.Currency
actual fun formatPrice(amount: Int): String =
(
NumberFormat.getCurrencyInstance().apply {
currency = Currency.getInstance("EUR")
}
).format(amount.toFloat() / 100)
// composeApp/src/iosMain/kotlin/Model.ios.kt
import platform.Foundation.NSNumber
import platform.Foundation.NSNumberFormatter
import platform.Foundation.NSNumberFormatterCurrencyStyle
actual fun formatPrice(amount: Int): String {
val formatter = NSNumberFormatter()
formatter.minimumFractionDigits = 2u
formatter.maximumFractionDigits = 2u
formatter.numberStyle = NSNumberFormatterCurrencyStyle
formatter.currencyCode = "EUR"
return formatter.stringFromNumber(NSNumber(amount.toFloat() / 100))!!
}
Then we can use it on our screen and ViewModel
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreenViewModel.kt
data class ExpensesScreenState(
val lastSuccessData: List<Expense> = emptyList(),
val data: RemoteData<Throwable, List<Expense>>,
) {
val avgExpenses: String
- get() = data.getOrElse(emptyList()).map { it.price }.average().toString()
+ get() = formatPrice(lastSuccessData.map { it.price }.average().toInt())
}
// composeApp/src/commonMain/kotlin/ui/screens/expenses/ExpensesScreen.kt
Text(
- text = (expense.price).toString(),
+ text = (expense.formattedPrice),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant),
)
Now you should have pretty formatted values.
We are now fetching dynamic data, providing some feedback in our list screen.
In the next part of this series, we will finally store our data locally and make our application work offline.
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)