DEV Community

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

Posted on • Updated on

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

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:

  • 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

RemoteData state chart

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)
Enter fullscreen mode Exit fullscreen mode

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

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
    }
Enter fullscreen mode Exit fullscreen mode

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))
+           }
+       }
+   }
}
Enter fullscreen mode Exit fullscreen mode

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))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Are checking for failures if (state.data is RemoteData.Failure), and logging the error
  2. Added a "Refresh button", allowing to re-fetch the Notion database for new entries
  3. Unwrapping state.data with a when 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)
                    }
                }
            }
        }
    }
}

// ....
Enter fullscreen mode Exit fullscreen mode

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))
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

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)
                    }
                }
Enter fullscreen mode Exit fullscreen mode

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()) }
        }
}
Enter fullscreen mode Exit fullscreen mode
// 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)
                    }
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
// 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")
            }
        }
Enter fullscreen mode Exit fullscreen mode

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)
+}
Enter fullscreen mode Exit fullscreen mode

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))!!
}
Enter fullscreen mode Exit fullscreen mode

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),
)
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)