Lädt...


🔧 Building a subscription tracker Desktop and iOS app with compose multiplatform — Offline data


Nachrichtenbereich: 🔧 Programmierung
🔗 Quelle: dev.to

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

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.

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

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.

...

🔧 Building a subscription tracker Desktop and iOS app with compose multiplatform — Offline data


📈 77.21 Punkte
🔧 Programmierung

🔧 Building a subscription tracker Desktop and iOS app with compose multiplatform - Part 1


📈 66.23 Punkte
🔧 Programmierung

🔧 Building a subscription tracker Desktop and iOS app with compose multiplatform - Configuring Notion


📈 66.23 Punkte
🔧 Programmierung

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


📈 66.23 Punkte
🔧 Programmierung

🔧 QRKit — QRCode Generator in Compose Multiplatform for Android and iOS


📈 33.94 Punkte
🔧 Programmierung

🔧 QRKit — QRCode Scanning in Compose Multiplatform for Android and iOS


📈 33.94 Punkte
🔧 Programmierung

🔧 CountryCodePicker in Compose Multiplatform for Android and iOS


📈 33.94 Punkte
🔧 Programmierung

🔧 Making Compose Multiplatform apps feel at home: removing ripple animation on iOS


📈 32.72 Punkte
🔧 Programmierung

🔧 Coil and Ktor in Kotlin Multiplatform Compose project


📈 30.76 Punkte
🔧 Programmierung

📰 Cross-Plattform-Entwicklung: Compose Multiplatform 1.2 verkleinert Bundle-Größen


📈 29.54 Punkte
📰 IT Nachrichten

🔧 ScreenCapture -Compose Multiplatform(KMP)


📈 29.54 Punkte
🔧 Programmierung

🔧 SDP-SSP-Compose-Multiplatform


📈 29.54 Punkte
🔧 Programmierung

🔧 CMPPreference - Compose Multiplatform


📈 29.54 Punkte
🔧 Programmierung

🔧 Easy WheelDateTimePicker — Compose Multiplatform(KMP)


📈 29.54 Punkte
🔧 Programmierung

🔧 Introducing Compose Multiplatform Media Player: Your Go-To Solution for Seamless Media Playback


📈 29.54 Punkte
🔧 Programmierung

🔧 First Impressions of Compose Multiplatform


📈 29.54 Punkte
🔧 Programmierung

🔧 Cross-Platform UI Development with Jetpack Compose Multiplatform


📈 29.54 Punkte
🔧 Programmierung

🔧 A primer into native interactions in Compose Multiplatform apps


📈 29.54 Punkte
🔧 Programmierung

🔧 Using SVGs on Canvas with Compose Multiplatform


📈 29.54 Punkte
🔧 Programmierung

🔧 Using SVGs on Canvas with Compose Multiplatform


📈 29.54 Punkte
🔧 Programmierung

🔧 Jetpack Compose Mastery Part 1: A Comprehensive Guide to Building Your First Compose Application


📈 28.64 Punkte
🔧 Programmierung

🔧 Trying Kotlin Multiplatform for the First Time: Step by Step Building an App with KMP


📈 26.17 Punkte
🔧 Programmierung

🔧 Building an E-commerce Admin Panel with Laravel, Livewire, and Kotlin Multiplatform


📈 24.88 Punkte
🔧 Programmierung

🐧 Using Podman and Docker Compose - Podman 3.0 now supports Docker Compose


📈 24.24 Punkte
🐧 Linux Tipps

🎥 Now in Android: 69 - Compose Camp, MAD Skills Compose, Android Studio Dolphin, and more!


📈 24.24 Punkte
🎥 Video | Youtube

🔧 Jetpack Compose Mastery Part 2: Advanced Tools and Resources for Mastering Compose UI


📈 24.24 Punkte
🔧 Programmierung

🎥 Building multiplatform games with Flutter


📈 23.65 Punkte
🎥 Video | Youtube

🕵️ Medium CVE-2020-7606: Docker-compose-remote-api project Docker-compose-remote-api


📈 23.02 Punkte
🕵️ Sicherheitslücken

📰 Compose for Wear OS is now 1.0: time to build wearable apps with Compose!


📈 23.02 Punkte
🤖 Android Tipps

📰 Learn Jetpack Compose at a Compose Camp near you!


📈 23.02 Punkte
🤖 Android Tipps

🎥 Learn Jetpack Compose at Compose Camp


📈 23.02 Punkte
🎥 Video | Youtube

matomo