Building a subscription tracker Desktop and iOS app with compose multiplatform—Providing feedbacks



This content originally appeared on DEV Community and was authored by Daniel Kuroski

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.

List screen result

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:

  • There’s the initial state (before making any request)
  • We have a “loading/pending” state while waiting for the request to complete
  • The request might succeed or fail

One solution that I particularly like is to use RemoteData ADT (algebraic data type).

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.

The main idea is to have a structure that represents all states for a regular request (or any “promise-like” case).

RemoteData state chart

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 approach 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 have isLoading = true and an error at the same time
  • This is a n! issue

    • If you are handling data, error and isLoading cases, there are 3! = 6 different possibilities to cover
    • If you need to add one scenario (like the Not Asked), then it is 4! = 24 possibilities

This is where RemoteData shines, it helps make “impossible states impossible”.

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`
// 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

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

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

Failure state example

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.

List with formatted monetary 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.

Final meme


This content originally appeared on DEV Community and was authored by Daniel Kuroski