Getting Started with Kotlin Multiplatform[3/n]: Using TouchLab SKIE and SQLDelight for Local Persistence & Flow in SwiftUI

Debanshu Datta
Level Up Coding
Published in
11 min readFeb 7, 2024

--

Photo by freestocks on Unsplash

After successfully implementing the basic Kotlin multiplatform app and building upon it to integrate Koin and implement a clean architecture with Moko-MVVM. We will now focus on following a simple Local Persistence & handling Kotlin Flow with SQLDelight and SKIE by Touchlab.

We will build on what we built in the first two blogs. We will follow the clean architecture we discussed, in our last part and try to add to it by adding the feature to add and remove Anime from local persistence. And how to handle Kotlin flow in SwiftUI. [Preview]

What is SQLDelight?

SQLDelight generates typesafe Kotlin APIs from your SQL statements. It verifies your schema, statements, and migrations at compile-time and provides IDE features like autocomplete and refactoring which make writing and maintaining SQL simple.

What is SKIE?

SKIE significantly improves the API exported from Kotlin to Swift. It is a critical tool for any iOS developer or team using KMP. This guide will walk you through the basics.

Code Preview

MR for code diff:

Whole Codebase:

Dependencies Setup

We will start by adding the required dependencies to the shared module in build.gradle. When the gradle sync is done properly, you will find a new folder generated shared/src/commonMain/sqldelight/com.debanshu.animax

/*
File Name: build.gradle.kts
Loaction: shared/build.gradle.kts
*/

plugins {
....
id("app.cash.sqldelight") version "2.0.1"
id("co.touchlab.skie") version "0.6.0"
}

val commonMain by getting {
dependencies {
/* This override dependency and configurations exclude is required
for fixing some error in SKIE dependency. May not be need in
future.

dependencies {
implementation("co.touchlab:stately-common:2.0.5")
implementation("co.touchlab:stately-collections:2.0.5")
implementation("co.touchlab:stately-concurrency:2.0.5")
implementation("co.touchlab:stately-isolate:2.0.0-rc3")
}
configurations.all {
exclude(group = "co.touchlab", module = "stately-strict-jvm")
}

*/

implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
implementation("app.cash.sqldelight:coroutines-extensions:2.0.1")
}
}

//Adding Android Specific dependency
val androidMain by getting {
dependencies {
.......
implementation("app.cash.sqldelight:android-driver:2.0.1")
}
}

//Adding iOS Specific dependency
val iosX64Main by getting {
dependencies {
......
implementation("app.cash.sqldelight:native-driver:2.0.1")
}
}

/* This is crutial for generation of typesafe Kotlin APIs
from your SQL statements. This defines the location of
.sq file. */
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.debanshu.animax")
}
}
}

Setup SQLDelight

Writing SQL

Inside shared/src/commonMain/sqldelight/com.debanshu.animax we will create the Anime.sq file. For us, it will be a simple structure with all the required utility functions and entity definitions. getAllAnime, getAnimeById,insertAnime,deleteAnimeById and removeAllAnime are all the function names that we provide, we can directly access them from database queries later explained in the repository layer. [Important Step] We will now build the project and generate all the required Kotlin files, and now you can access them.

/*
File Name: Anime.sq
Loaction: shared/src/commonMain/sqldelight/com.debanshu.animax/Anime.sq
*/

CREATE TABLE animeEntity (
id INTEGER NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
imageUrl TEXT NOT NULL,
created Integer NOT NULL
);

getAllAnime:
SELECT * FROM animeEntity;

getAnimeById:
SELECT * FROM animeEntity WHERE id = ?;

insertAnime:
INSERT OR REPLACE
INTO animeEntity(
id,
name,
imageUrl,
created
) VALUES (?,?,?,?);

deleteAnimeById:
DELETE FROM animeEntity WHERE id = ?;

removeAllAnime:
DELETE FROM animeEntity;

Making Database Driver Factory

We will be using separate SQLDelight drivers for both iOS and Android, for this we're using the expect and actual keywords discussed before. In Android, we will be using AndroidSqliteDriver while in iOS we will be using NativeSqliteDriver. AppDatabase.Schema will be coming from the generated code from SQLDelight, AppDatabase is the name we gave in the build.gradle.

/*
File Name: DatabaseDriverFactory.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/DatabaseDriverFactory.kt
*/
package com.debanshu.animax.data

import app.cash.sqldelight.db.SqlDriver

expect class DatabaseDriverFactory {
fun createDriver():SqlDriver
}


/*
File Name: DatabaseDriverFactory.kt
Loaction: shared/src/androidMain/kotlin/com/debanshu/animax/data/DatabaseDriverFactory.kt
*/
package com.debanshu.animax.data

import android.content.Context
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import com.debanshu.animax.AppDatabase

actual class DatabaseDriverFactory(private val context:Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(AppDatabase.Schema,context,"anime.db")
}
}


/*
File Name: DatabaseDriverFactory.kt
Loaction: shared/src/iosMain/kotlin/com/debanshu/animax/data/DatabaseDriverFactory.kt
*/
package com.debanshu.animax.data

import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
import com.debanshu.animax.AppDatabase

actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema,"anime.db")
}
}

[OPTIONAL] Setting Kotlinx-Datetime

We will use kotlinx-datetime library to add a multiplatform date-time library, this is optional to use and can be removed it is used to sort the data later. For this, we need to add a new dependency.

/*
File Name: build.gradle.kts
Loaction: shared/build.gradle.kts
*/
val commonMain by getting {
dependencies {
....
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
....
}
}

Also, need to add a utility class just in case to have proper formatting.

/*
File Name: DateTimeUtil.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/domain/DateTimeUtil.kt
*/

package com.debanshu.animax.data.domain

import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime

object DateTimeUtil {
fun now(): LocalDateTime =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())

fun toEpochMillis(dateTime: LocalDateTime): Long =
dateTime.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds()

fun formatAnimeDate(dateTime: LocalDateTime): String {
val month = dateTime.month.name.lowercase().take(3).replaceFirstChar { it.uppercase() }
val day = if(dateTime.dayOfMonth < 10) "0${dateTime.dayOfMonth}" else dateTime.dayOfMonth
val year = dateTime.year
val hour = if(dateTime.hour < 10) "0${dateTime.hour}" else dateTime.hour
val minute = if(dateTime.minute < 10) "0${dateTime.minute}" else dateTime.minute

return buildString {
append(month)
append(" ")
append(day)
append(" ")
append(year)
append(", ")
append(hour)
append(":")
append(minute)
}
}
}

Setting Database Entity and Repository Layer

We will now set the Entity layer which will map our database entity to our local DTO/Model which we will use app-wide. AnimeEntity is the database entity we defined in the .sqfile above.

created parameter can be skipped just in case you are not using kotlinx-datetime

/*
File Name: LocalAnimeEntity.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/domain/LocalAnimeEntity.kt
*/
package com.debanshu.animax.data.domain

import comdebanshuanimax.AnimeEntity
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime

data class LocalAnimeEntity(
val id: Long?,
val title: String,
val imageUrl: String,
val created: LocalDateTime
)

fun AnimeEntity.toLocalAnimeEntity(): LocalAnimeEntity {
return LocalAnimeEntity(
id = id,
title = name,
imageUrl = imageUrl,
created = Instant.fromEpochMilliseconds(created)
.toLocalDateTime(TimeZone.currentSystemDefault())
)
}

The repository is quite simple and similar to using Android Room Database, or other databases. A few pointers to notice in the below snippet are:
AppDatabase is the name we gave in the build.gradle i.e., our database as previously mentioned.
getAllAnime is returning a Flow<List<LocalAnimeEntity>>
— We are using database.animeQueries to access the functions that were written in the .sq file.

/*
File Name: LocalDataRepository.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/repository/LocalDataRepository.kt
*/

package com.debanshu.animax.data.repository

import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import com.debanshu.animax.AppDatabase
import com.debanshu.animax.data.DatabaseDriverFactory
import com.debanshu.animax.data.domain.DateTimeUtil
import com.debanshu.animax.data.domain.LocalAnimeEntity
import com.debanshu.animax.data.domain.toLocalAnimeEntity
import com.debanshu.animax.utils.Dispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.koin.core.component.inject

class LocalDataRepository(database: AppDatabase) {
private val queries = database.animeQueries
fun insertAnime(anime: LocalAnimeEntity) {
queries.transaction {
queries.insertAnime(
anime.id,
anime.title,
anime.imageUrl,
DateTimeUtil.toEpochMillis(anime.created)
)
}
}

fun getAnimeById(id: Long): LocalAnimeEntity? {
return queries.getAnimeById(id).executeAsOneOrNull()?.toLocalAnimeEntity()
}

fun getAllAnime(): Flow<List<LocalAnimeEntity>> {
return queries.getAllAnime()
.asFlow()
.mapToList(Dispatchers.IO).map { list ->
list.map { it.toLocalAnimeEntity() }
}

}

fun deleteAnimeById(id: Long) {
queries.transaction {
queries.deleteAnimeById(id)
}
}

fun clearDatabase() {
queries.transaction {
queries.removeAllAnime()
}
}
}

Making Usecase & View Model Layer

Usecase Layer

We will be keeping things simple, and for that, we will only have three use cases namely, AddLocalAnimeUseCase, DeleteLocalAnimeByIdUsecase and GetLocalAnimeUseCase. We are also using KoinComponent and by inject(), A class implementation KoinComponent is similar to a Spring @Component. It has a link to the global Koininstance and serves as an entry point to the object tree encoded in the modules. by inject()lazily inject instance from Koin. In this case, we will be injecting LocalRepository & dispatcher to our use case.

/*
File Name: AddLocalAnimeUseCase.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/usecase/AddLocalAnimeUseCase.kt
*/

package com.debanshu.animax.data.usecase

import com.debanshu.animax.data.domain.DateTimeUtil
import com.debanshu.animax.data.domain.LocalAnimeEntity
import com.debanshu.animax.data.repository.LocalDataRepository
import com.debanshu.animax.utils.Dispatcher
import kotlinx.coroutines.withContext
import kotlinx.datetime.LocalDateTime
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.random.Random

class AddLocalAnimeUseCase : KoinComponent {
private val localDataRepository: LocalDataRepository by inject()
private val dispatcher: Dispatcher by inject()
suspend fun execute(id: Long, title: String, imageUrl: String) = withContext(dispatcher.io) {
localDataRepository.insertAnime(LocalAnimeEntity(id, title, imageUrl, DateTimeUtil.now()))
}
}
/*
File Name: GetLocalAnimeUseCase.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/usecase/GetLocalAnimeUseCase.kt
*/

package com.debanshu.animax.data.usecase

import com.debanshu.animax.data.domain.LocalAnimeEntity
import com.debanshu.animax.data.repository.LocalDataRepository
import com.debanshu.animax.utils.Dispatcher
import dev.icerock.moko.mvvm.flow.cFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

class GetLocalAnimeUseCase : KoinComponent {
private val localDataRepository: LocalDataRepository by inject()
private val dispatcher: Dispatcher by inject()
suspend fun execute(): Flow<List<LocalAnimeEntity>> =
withContext(dispatcher.io) {
localDataRepository.getAllAnime()
}
}
/*
File Name: DeleteLocalAnimeByIdUsecase.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/usecase/DeleteLocalAnimeByIdUsecase.kt
*/

package com.debanshu.animax.data.usecase

import com.debanshu.animax.data.repository.LocalDataRepository
import com.debanshu.animax.utils.Dispatcher
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

class DeleteLocalAnimeByIdUseCase : KoinComponent {
private val localDataRepository: LocalDataRepository by inject()
private val dispatcher: Dispatcher by inject()
suspend fun execute(id: Long) = withContext(dispatcher.io) {
localDataRepository.deleteAnimeById(id)
}
}

View Model Layer

We will be using moko-viewModel which provides us with ViewModel as we use it in Android. The MVVM approach we follow is the same. We will be consuming this ViewModel in our view, while we will be emitting AnimeLocalState from ViewModel. We will be injecting the AddLocalAnimeUseCase, DeleteLocalAnimeByIdUsecase and GetLocalAnimeUseCase to ViewModel and consuming and triggering in our loadLocalAnime().

/*
File Name: AppViewModel.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/data/AppViewModel.kt
*/

package com.debanshu.animax.data

import com.debanshu.animax.data.domain.LocalAnimeEntity
import com.debanshu.animax.data.model.Anime
import com.debanshu.animax.data.usecase.AddLocalAnimeUseCase
import com.debanshu.animax.data.usecase.DeleteLocalAnimeByIdUseCase
import com.debanshu.animax.data.usecase.GetLocalAnimeUseCase
import com.debanshu.animax.data.usecase.GetTopAnimeUseCase
import dev.icerock.moko.mvvm.flow.cStateFlow
import dev.icerock.moko.mvvm.viewmodel.ViewModel
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

class AppViewModel(
private val getTopAnimeUseCase: GetTopAnimeUseCase,
private val getLocalAnimeUseCase: GetLocalAnimeUseCase,
private val addLocalAnimeUseCase: AddLocalAnimeUseCase,
private val deleteLocalAnimeByIdUseCase: DeleteLocalAnimeByIdUseCase,
) : ViewModel() {
private val animeMutable =
MutableStateFlow<AnimeListState>(AnimeListState.Uninitialized)
private val animeLocalMutable =
MutableStateFlow<AnimeLocalState>(AnimeLocalState.Uninitialized)

val animeState = animeMutable.asStateFlow().cStateFlow()
val animeLocalState = animeLocalMutable.asStateFlow().cStateFlow()

init {
loadRemoteAnime()
loadLocalAnime()
}

fun insertAnime(id: Long, title:String, imageUrl:String){
viewModelScope.launch {
addLocalAnimeUseCase.execute(id,title,imageUrl)
}
}

fun deleteAnimeById(id:Long){
viewModelScope.launch {
deleteLocalAnimeByIdUseCase.execute(id)
}
}
private fun loadRemoteAnime() {
animeMutable.value = AnimeListState.Loading
viewModelScope.launch {
try {
animeMutable.value = AnimeListState.Success(getTopAnimeUseCase.execute(Unit).data)
} catch (e: Exception) {
e.printStackTrace()
animeMutable.value = AnimeListState.Error(e.message.orEmpty())
}
}
}

fun loadLocalAnime() {
animeLocalMutable.value = AnimeLocalState.Loading
viewModelScope.launch {
try {
animeLocalMutable.value = AnimeLocalState.Success(getLocalAnimeUseCase.execute())
} catch (e: Exception) {
e.printStackTrace()
animeLocalMutable.value = AnimeLocalState.Error(e.message.orEmpty())
}
}
}

override fun onCleared() {
viewModelScope.cancel()
super.onCleared()
}
}

sealed interface AnimeListState {
data class Success(val data: List<Anime>) : AnimeListState
data class Error(val exceptionMessage: String) : AnimeListState
data object Loading : AnimeListState
data object Uninitialized : AnimeListState
}

sealed interface AnimeLocalState {
data class Success(val data: Flow<List<LocalAnimeEntity>>) : AnimeLocalState
data class Error(val exceptionMessage: String) : AnimeLocalState
data object Loading : AnimeLocalState
data object Uninitialized : AnimeLocalState
}

Dependency with Koin

This will continue what was discussed in our previous blog about how to integrate Koin in KMP.

We will start by setting up our Database Driver based on platforms like Android and iOS. DatabaseDriverFactory needs application context in Android.

/*
File Name: platformModules.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/di/platformModules.kt
*/

package com.debanshu.animax.di

import org.koin.core.module.Module

internal expect fun getViewModelByPlatform(): Module

internal expect fun getDataDriverFactory():Module

/*
File Name: platformModules.kt
Loaction: shared/src/androidMain/kotlin/com/debanshu/animax/di/platformModules.kt
*/

package com.debanshu.animax.di

import com.debanshu.animax.data.AppViewModel
import com.debanshu.animax.data.DatabaseDriverFactory
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module

actual fun getViewModelByPlatform() = module {
viewModel {
AppViewModel(get(),get(),get(),get())
}
}

actual fun getDataDriverFactory() = module {
single { DatabaseDriverFactory(get()) }
}


/*
File Name: platformModules.kt
Loaction: shared/src/iosMain/kotlin/com/debanshu/animax/di/platformModules.kt
*/

package com.debanshu.animax.di

import com.debanshu.animax.data.AppViewModel
import com.debanshu.animax.data.DatabaseDriverFactory
import org.koin.dsl.module
actual fun getViewModelByPlatform() = module {
single {
AppViewModel(get(),get(),get(),get())
}
}

actual fun getDataDriverFactory() = module {
single { DatabaseDriverFactory() }
}

We will add the platform module to other modules and set up other required dependencies for the use cases.

/*
File Name: sharedModules.kt
Loaction: shared/src/commonMain/kotlin/com/debanshu/animax/di/sharedModules.kt
*/

package com.debanshu.animax.di

import app.cash.sqldelight.db.SqlDriver
import com.debanshu.animax.AppDatabase
import com.debanshu.animax.data.DatabaseDriverFactory
import com.debanshu.animax.data.repository.LocalDataRepository
import com.debanshu.animax.data.repository.RemoteDataRepository
import com.debanshu.animax.data.usecase.AddLocalAnimeUseCase
import com.debanshu.animax.data.usecase.DeleteLocalAnimeByIdUseCase
import com.debanshu.animax.data.usecase.GetLocalAnimeUseCase
import com.debanshu.animax.data.usecase.GetTopAnimeUseCase
import com.debanshu.animax.utils.provideDispatcher
import org.koin.dsl.module

private val dataModule = module {
single { RemoteDataRepository() }
factory { GetTopAnimeUseCase() }
factory { GetLocalAnimeUseCase() }
factory { AddLocalAnimeUseCase() }
factory { DeleteLocalAnimeByIdUseCase() }
single { AppDatabase((get() as DatabaseDriverFactory).createDriver()) }
single { LocalDataRepository(get()) }
}

private val utilityModule = module {
factory { provideDispatcher() }
}

private val sharedModules = listOf(
dataModule, utilityModule, getViewModelByPlatform(), getDataDriverFactory()
)

fun getSharedModules() = sharedModules

Consuming ViewModel In Android

Not going into many details on how we are consuming data in the Android project. Simple, first add a dependency to consume the Flow data we are releasing from the database. Take note of the usage of .coolectAsStateWithLifecycle() this helps to consume the view model state and collect flow in the AnimeLocalState.Success.

/*
File Name: build.gradle.kts
Loaction: androidApp/build.gradle.kts
*/

implementation(“androidx.lifecycle:lifecycle-runtime-compose:2.6.2”)
/*
Example code snippet to add new data to local persistance
File Name: RemoteGridScreen.kt
Loaction: androidApp/src/main/java/com/debanshu/animax/android/screens/RemoteGridScreen.kt
*/
anime.malId?.let { id ->
viewModel.createDummyData(
id.toLong(),
anime.titleEnglish!!,
anime.images.jpg.imageUrl,
)
}


/*
Example code snippet to remove new data from local persistance
File Name: LocalGridScreen.kt
Loaction: androidApp/src/main/java/com/debanshu/animax/android/screens/LocalGridScreen.kt
*/

@Composable
fun LocalViewAnime(viewModel: AppViewModel) {
val animeState by viewModel.animeLocalState.collectAsStateWithLifecycle()

Surface {
when (animeState) {
is AnimeLocalState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}

is AnimeLocalState.Success -> {
val animeList by (animeState as AnimeLocalState.Success).data.collectAsStateWithLifecycle(
emptyList()
)
LazyVerticalGrid(
columns = GridCells.Adaptive(170.dp)
) {
items(animeList) { anime ->
Column {
AsyncImage(
model = anime.imageUrl,
contentDescription = null,
modifier = Modifier
.padding(top = 20.dp, start = 20.dp, end = 20.dp)
.height(220.dp)
.width(170.dp)
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.FillBounds
)
Row(
modifier = Modifier.width(170.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
){
Text(
modifier = Modifier.padding(
start = 20.dp,
end = 20.dp
),
text = anime.title, fontSize = 12.sp,
)
Icon(
modifier = Modifier.size(20.dp).clickable {
anime.id?.let { // To remove from local storage
viewModel.deleteAnimeById(
it
)
}
},
imageVector = Icons.Filled.Favorite,
contentDescription = anime.title,
)
}
}
}
}
}

is AnimeLocalState.Error -> {
Text(text = "Error Occurred ${(animeState as AnimeLocalState.Error).exceptionMessage} ")
}

is AnimeLocalState.Uninitialized -> {
Text(text = "Error Occurred in initialization")
}
}
}
}

Consuming ViewModel & Flow in SwiftUI with SKIE

We will be using SKIE to consume, the flow that is emitted in the success state and from our shared module we will also emit the latest data in case of success. If you look closely SuccessView you will find that the data that we are getting as Flow from shared the module is already converted SkieSwiftFlow<[LocalAnimeEntity]> which is already done by SKIE inherently with no additional effort. We are consuming that SuccessView by using .task{} where we map over the SkieSwiftFlow<[LocalAnimeEntity]> and convert to a normal list, and consume in the view finally.

SkieSwiftFlow<T> an implementation of Swift's AsyncSequence protocol, you can consume it the same way. SwiftUI's ForEach component isn't supposed to be used with async data structures, but instead just an Array. It then runs the view builder block on each of the items.

/*
File Name: LocalGridView.swift
Loaction: iosApp/iosApp/Views/LocalGridView.swift
*/

import SwiftUI
import MultiPlatformLibrary
import mokoMvvmFlowSwiftUI

struct LocalGridView: View {
@EnvironmentObject var viewModel: AppViewModel
@State var uiState: AnimeLocalState = AnimeLocalStateUninitialized()

var body: some View {
let appUiState = viewModel.animeLocalState
NavigationView{
VStack{
switch(uiState){
case is AnimeLocalStateLoading:
LoadingView()
case let successState as AnimeLocalStateSuccess:
SuccessView(data: successState.data) { id in
viewModel.deleteAnimeById(id: id)
}
case is AnimeLocalStateError:
ErrorView()
default:
ErrorView()
}
}
.padding([.horizontal])
.navigationTitle("Animax")
}.task {
appUiState.subscribe { state in
self.uiState = state!
}
}
}
}

struct SuccessView: View {
let data: SkieSwiftFlow<[LocalAnimeEntity]>
let deleteAnimeById: (Int64) -> Void
private let adaptaiveColumns = [
GridItem(.adaptive(minimum: 170))
]

@State
private var items: [LocalAnimeEntity] = []

var body: some View {
ScrollView {
LazyVGrid(columns: adaptaiveColumns, spacing: 20) {
ForEach(items, id: \.id) { anime in
AnimeGridLocalItem(
onClick: { deleteAnimeById(anime.id as! Int64) },
anime:anime
)
}
}
}
.task {
// Reset items as this is called each time this view appears and we don't want duplicates
items = []
for await item in data {
items = item
}
}
}
}

Finally, we are done with using TouchLab SKIE and SQLDelight for Local Persistence & Flow in SwiftUI to support local saving in our app✨.

For any doubts and suggestions, you can reach out on my Instagram, or LinkedIn. Follow me for Kotlin content and more. Happy Coding!

I will well appreciate one of these 👏

--

--

Android(L2) @Gojek | Mobile Developer | Backend Developer (Java/Kotlin)