diff --git a/build.gradle.kts b/build.gradle.kts index 0cf4f47..1dd7bf2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader + alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.compose) apply false diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 576fcf6..d56d483 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -9,6 +9,7 @@ import java.util.Properties import kotlin.apply plugins { + alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) alias(libs.plugins.compose) @@ -111,6 +112,7 @@ kotlin { api(libs.koin.core) api(libs.kotlinx.datetime) api(libs.atomicfu) + api(libs.kotlinx.serialization.json) implementation(compose.runtime) implementation(compose.foundation) diff --git a/composeApp/src/commonMain/kotlin/DI.kt b/composeApp/src/commonMain/kotlin/DI.kt index 60c11fd..360044d 100644 --- a/composeApp/src/commonMain/kotlin/DI.kt +++ b/composeApp/src/commonMain/kotlin/DI.kt @@ -13,6 +13,8 @@ import org.koin.core.module.Module import org.koin.dsl.KoinAppDeclaration import org.koin.dsl.bind import org.koin.dsl.module +import service.kvstoreDemo.IKVStoreDemoService +import service.kvstoreDemo.KVStoreDemoService import ui.app.AppViewInteractor import ui.appStateExample.AppStateExampleViewInteractor import ui.capability.CapabilityScreenViewInteractor @@ -22,6 +24,7 @@ import ui.device.DeviceHomeViewInteractor import ui.file.FileSystemViewInteractor import ui.home.HomeViewInteractor import ui.iosServices.IOSServicesScreenViewInteractor +import ui.kvstoreDemo.KVStoreDemoScreenViewInteractor import ui.popups.PopupsScreenViewInteractor import ui.viewStateExample.ViewStateExampleViewInteractor @@ -47,6 +50,7 @@ fun commonModule() = module { single { DeviceInteractor(get()) } single { AppInteractor(get()) } + single { KVStoreDemoService(get()) } bind IKVStoreDemoService::class factory { params -> AppViewInteractor(params[0], get(), get()) } factory { ScreenViewInteractor(get(), get()) } @@ -59,4 +63,5 @@ fun commonModule() = module { factory { IOSServicesScreenViewInteractor(get()) } factory { CapabilityScreenViewInteractor(get()) } factory { ColorPickerViewInteractor() } + factory { KVStoreDemoScreenViewInteractor(get()) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt b/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt index c3ff0f2..7c07f82 100644 --- a/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt @@ -37,4 +37,5 @@ class AppCoordinator(): Coordinator( fun colorPickerClicked() = push(Route.ColorPicker) fun htmlDemoClicked() = push(Route.WebDemo) fun windowInfoClicked() = push(Route.WindowInfo) + fun kvStoreClicked() = push(Route.KVStore) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt new file mode 100644 index 0000000..8cd2316 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt @@ -0,0 +1,23 @@ +package service.kvstoreDemo + +import com.outsidesource.oskitkmp.filesystem.KmpFsRef +import com.outsidesource.oskitkmp.filesystem.KmpFsType +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.Serializable +import ui.kvstoreDemo.KVStoreDemoScreenViewState + +interface IKVStoreDemoService { + suspend fun observeTodos(): Flow?> + suspend fun addTodoItem(title: String): Outcome + suspend fun removeTodoItem(id: String): Boolean + suspend fun changeState(id: String, completed: Boolean): Boolean + suspend fun rename(id: String, name: String): Boolean +} + +@Serializable +data class TodoItem( + val id: String, + val name: String, + val completed: Boolean +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt new file mode 100644 index 0000000..41b262c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt @@ -0,0 +1,112 @@ +package service.kvstoreDemo + +import com.outsidesource.oskitkmp.outcome.Outcome +import com.outsidesource.oskitkmp.outcome.unwrapOrNull +import com.outsidesource.oskitkmp.storage.IKmpKvStore +import com.outsidesource.oskitkmp.storage.IKmpKvStoreNode +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.serialization.builtins.ListSerializer + +private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + +private const val KEY_ITEMS = "items" + + +/** + * A simple KVStore demo service that demonstrates how to use KmpKvStore to store and retrieve data in a reactive + * way. Note, the implementation here is simplified for the demo purposes. + * + * The KVStoreDemoService is a simple service that stores and retrieves TodoItems in a reactive way. It uses a single + * KmpKvStore node to store the serialized list of [TodoItem]. The [KmpKvStore] node is opened asynchronously when the service is + * initialized. The [KmpKvStore] node is closed when the service is destroyed. + * + * The [KVStoreDemoService] provides a flow of a list of [TodoItem] that can be observed using the observeTodos() function. + * + * The [KVStoreDemoService] provides functions to add, remove, update, and rename TodoItems using the addTodoItem(), + * removeTodoItem(), changeState(), and rename() functions, respectively. + * + * This service is being consumed in the [ui.kvstoreDemo.KVStoreDemoScreenViewInteractor]. + */ +class KVStoreDemoService( + private val storage: IKmpKvStore, +) : IKVStoreDemoService { + + private val node = CompletableDeferred() + + init { + scope.launch { + node.complete(storage.openNode("kvstoredemo").unwrapOrNull()) + } + } + + override suspend fun observeTodos(): Flow?> { + return node.await()?.observeSerializable(KEY_ITEMS, ListSerializer(TodoItem.serializer())) ?: emptyFlow() + } + + override suspend fun addTodoItem(title: String): Outcome { + val data = readItemsSnapshot() + val entity = TodoItem(title, title, false) + val res = writeItemsSnapshot(data.toMutableList().apply { add(entity) }) + + return when { + res != null && res is Outcome.Ok -> Outcome.Ok(entity) + res is Outcome.Error -> Outcome.Error(res.error) + else -> Outcome.Error(IllegalStateException("Node is null")) + } + } + + override suspend fun removeTodoItem(id: String): Boolean { + val data = readItemsSnapshot().toMutableList() + val item = data.find { it.id == id } + + return if (item != null) { + data.remove(item) + writeItemsSnapshot(data) + true + } else { + false + } + } + + override suspend fun changeState(id: String, completed: Boolean): Boolean { + val data = readItemsSnapshot().toMutableList() + val item = data.find { it.id == id } + + return if (item != null) { + val index = data.indexOf(item) + data[index] = item.copy(completed = completed) + writeItemsSnapshot(data) + true + } else { + false + } + } + + override suspend fun rename(id: String, name: String): Boolean { + val data = readItemsSnapshot().toMutableList() + val item = data.find { it.id == id } + + return if (item != null) { + val index = data.indexOf(item) + data[index] = item.copy(name = name) + writeItemsSnapshot(data) + true + } else { + false + } + } + + private suspend fun readItemsSnapshot(): List { + return node.await()?.getSerializable( + KEY_ITEMS, + ListSerializer(TodoItem.serializer()) + ).orEmpty().toMutableList() + } + + private suspend fun writeItemsSnapshot(data: List): Outcome? { + return node.await()?.putSerializable(KEY_ITEMS, data, ListSerializer(TodoItem.serializer())) + } + +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/Route.kt b/composeApp/src/commonMain/kotlin/ui/Route.kt index 3289ed8..31d1ca5 100644 --- a/composeApp/src/commonMain/kotlin/ui/Route.kt +++ b/composeApp/src/commonMain/kotlin/ui/Route.kt @@ -22,6 +22,7 @@ sealed class Route( data object ColorPicker : Route(webRoutePath = "/color-picker") data object WebDemo : Route(webRoutePath = "/web-demo") data object WindowInfo : Route(webRoutePath = "/window-info") + data object KVStore : Route(webRoutePath = "/kvstore") companion object { val deepLinks = Router.buildDeepLinks { diff --git a/composeApp/src/commonMain/kotlin/ui/app/App.kt b/composeApp/src/commonMain/kotlin/ui/app/App.kt index 924750e..1ddf7ce 100644 --- a/composeApp/src/commonMain/kotlin/ui/app/App.kt +++ b/composeApp/src/commonMain/kotlin/ui/app/App.kt @@ -23,6 +23,7 @@ import ui.markdown.MarkdownScreen import ui.popups.PopupsScreen import ui.viewStateExample.ViewStateExampleScreen import ui.htmlDemo.HtmlDemoScreen +import ui.kvstoreDemo.KVStoreDemoScreen import ui.widgets.WidgetsScreen import ui.windowInfo.WindowInfoScreen @@ -62,6 +63,7 @@ fun App( RouteTransitionDirection.Out } ) + is Route.KVStore -> KVStoreDemoScreen() } } } diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt index c5826db..defdaf4 100644 --- a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt @@ -102,6 +102,10 @@ fun HomeScreen( content = { Text("Window Info") }, onClick = interactor::windowInfoButtonClicked, ) + Button( + content = { Text("KV Store Demo") }, + onClick = interactor::kvStoreButtonClicked, + ) Button( content = { Text(rememberKmpString(Strings.iosServices)) }, onClick = interactor::iosServicesButtonClicked, diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt index bf2a554..d46b99e 100644 --- a/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt +++ b/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt @@ -19,4 +19,5 @@ class HomeViewInteractor( fun colorPickerButtonClicked() = coordinator.colorPickerClicked() fun htmlDemoButtonClicked() = coordinator.htmlDemoClicked() fun windowInfoButtonClicked() = coordinator.windowInfoClicked() + fun kvStoreButtonClicked() = coordinator.kvStoreClicked() } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt new file mode 100644 index 0000000..b60cdc2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt @@ -0,0 +1,200 @@ +package ui.kvstoreDemo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Checkbox +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import com.outsidesource.oskitcompose.interactor.collectAsState +import com.outsidesource.oskitcompose.lib.rememberInjectForRoute +import service.kvstoreDemo.TodoItem +import ui.common.Screen + +@Composable +fun KVStoreDemoScreen( + interactor: KVStoreDemoScreenViewInteractor = rememberInjectForRoute() +) { + LaunchedEffect(Unit) { + interactor.onViewMounted() + } + + val state = interactor.collectAsState() + val focusManager = LocalFocusManager.current + + + Screen("Todo List Demo") { + Row(Modifier.fillMaxSize()) { + // Column 1: Input field and Todo List + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp) + ) { + // Input field for new todo item + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = state.newTodoText ?: "", + singleLine = true, + onValueChange = { interactor.newTodoNameTyped(it) }, + label = { Text("New Todo Item") }, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + if (!state.newTodoText.isNullOrBlank()) { + interactor.createTodoItemClicked() + } + focusManager.clearFocus() // Optionally clear focus + } + ), + + modifier = Modifier.weight(1f).padding(end = 8.dp) + ) + Button( + onClick = { + if (!state.newTodoText.isNullOrBlank()) { + interactor.createTodoItemClicked() + } + }, + modifier = Modifier.fillMaxHeight() + ) { + Text("Add") + } + } + + Spacer(Modifier.height(16.dp)) + + // Todo List + Text("Todo Items") + LazyColumn( + modifier = Modifier.fillMaxHeight() + ) { + items(state.todoItems, key = { it.id }) { item -> + TodoListItem( + item = item, + isSelected = item.id == state.selectedTodoItem?.id, + onItemClick = { interactor.selectTodoItem(item.id) }, + onItemCheckChanged = { interactor.toDoItemCompletionStatusChanged(item, it) } + ) + Divider() + } + } + } + + // Column 2: Selected Todo Item Details (conditionally visible) + if (state.selectedTodoItem != null) { + Column( + modifier = Modifier + .weight(1f) + .padding(16.dp) + .fillMaxHeight(), + horizontalAlignment = Alignment.Start + ) { + Text("Edit Item") + Spacer(Modifier.height(16.dp)) + + // Name (editable) + OutlinedTextField( + value = state.editableName ?: "", + onValueChange = { + interactor.currentToDoItemNameEdited(it) + }, + label = { Text("Item Name") }, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + interactor.saveCurrentTodoItemNewName() + focusManager.clearFocus() + } + ), + + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + // Completed Checkbox + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = state.selectedTodoItem.completed, + onCheckedChange = { isChecked -> + interactor.toDoItemCompletionStatusChanged(state.selectedTodoItem, isChecked) + } + ) + Text("Completed") + } + + Spacer(Modifier.height(16.dp)) + + // Delete Button + Button( + onClick = { + interactor.deleteCurrentItemClicked() + }, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error) + ) { + Icon(Icons.Filled.Delete, contentDescription = "Delete Item") + Spacer(Modifier.width(4.dp)) + Text("Delete Item", color = MaterialTheme.colors.onError) + } + } + } + + } + } +} + +@Composable +fun TodoListItem( + item: TodoItem, + isSelected: Boolean, + onItemClick: () -> Unit, + onItemCheckChanged: (Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onItemClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = item.name, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + modifier = Modifier.weight(1f) + ) + Checkbox( + checked = item.completed, + onCheckedChange = { onItemCheckChanged(it) }, + enabled = true, + modifier = Modifier.padding(start = 8.dp) + ) + } +} + diff --git a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt new file mode 100644 index 0000000..8d3d46a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt @@ -0,0 +1,91 @@ +package ui.kvstoreDemo + +import com.outsidesource.oskitkmp.interactor.Interactor +import com.outsidesource.oskitkmp.outcome.Outcome +import com.outsidesource.oskitkmp.outcome.unwrapOrNull +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import service.kvstoreDemo.IKVStoreDemoService +import service.kvstoreDemo.TodoItem + +data class KVStoreDemoScreenViewState( + val todoItems: List = emptyList(), + val newTodoText: String? = null, + val editableName: String? = null, + val selectedTodoItem: TodoItem? = null +) + +class KVStoreDemoScreenViewInteractor( + private val kvStoreDemoService: IKVStoreDemoService, +) : Interactor( + initialState = KVStoreDemoScreenViewState() +) { + + fun onViewMounted() { + interactorScope.launch { + kvStoreDemoService.observeTodos().collect { todos -> + val currentSelectedItem = state.selectedTodoItem + + update { state -> + state.copy( + todoItems = todos.orEmpty(), + selectedTodoItem = todos.orEmpty().find { it.id == currentSelectedItem?.id }, + ) + } + } + } + } + + fun newTodoNameTyped(value: String) { + update { state -> state.copy(newTodoText = value) } + } + + fun createTodoItemClicked() { + val name = state.newTodoText + if (name.isNullOrBlank()) return + + interactorScope.launch { + val res = kvStoreDemoService.addTodoItem(name) + if (res is Outcome.Ok) { + update { state -> state.copy(newTodoText = null) } + } + } + } + + fun toDoItemCompletionStatusChanged(item: TodoItem, completed: Boolean) { + interactorScope.launch { + kvStoreDemoService.changeState(item.id, completed) + } + } + + fun deleteCurrentItemClicked() { + val item = state.selectedTodoItem ?: return + interactorScope.launch { + kvStoreDemoService.removeTodoItem(item.id) + } + } + + fun selectTodoItem(id: String) { + val item = state.todoItems.find { it.id == id } ?: return + update { state -> state.copy(selectedTodoItem = item, editableName = item.name) } + } + + fun currentToDoItemNameEdited(value: String) { + if (value.isNotBlank()) { + update { state -> state.copy(editableName = value) } + } + } + + fun saveCurrentTodoItemNewName() { + val item = state.selectedTodoItem ?: return + val value = state.editableName ?: return + + if (value.isNotBlank()) { + interactorScope.launch { + kvStoreDemoService.rename(item.id, value) + } + } + } + + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 410a484..61bee14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ oskit-compose = { module = "com.outsidesource:oskit-compose", version.ref = "osk oskit-kmp = { module = "com.outsidesource:oskit-kmp", version.ref = "oskitKmp" } ui = { module = "androidx.compose.ui:ui", version.ref = "ui" } material-icons = { module = "org.jetbrains.compose.material:material-icons-core", version.ref = "material-icons" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -43,5 +44,6 @@ androidLibrary = { id = "com.android.library", version.ref = "agp" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } compose = { id = "org.jetbrains.compose", version.ref = "compose" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } skie = { id = "co.touchlab.skie", version.ref = "skie" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file