From ef4302da7ee1d307164dee82123e94c43dbcfca3 Mon Sep 17 00:00:00 2001 From: Dr Watson <> Date: Fri, 30 May 2025 15:09:40 +0300 Subject: [PATCH 1/5] kvstore demo backend and UI skeleton --- build.gradle.kts | 1 + composeApp/build.gradle.kts | 2 + composeApp/src/commonMain/kotlin/DI.kt | 5 ++ .../kotlin/coordinator/AppCoordinator.kt | 1 + .../kvstoreDemo/IKVStoreDemoService.kt | 22 +++++ .../service/kvstoreDemo/KVStoreDemoService.kt | 83 +++++++++++++++++++ composeApp/src/commonMain/kotlin/ui/Route.kt | 1 + .../src/commonMain/kotlin/ui/app/App.kt | 2 + .../commonMain/kotlin/ui/home/HomeScreen.kt | 4 + .../kotlin/ui/home/HomeViewInteractor.kt | 1 + .../ui/kvstoreDemo/KVStoreDemoScreen.kt | 26 ++++++ .../KVStoreDemoScreenViewInteractor.kt | 17 ++++ gradle/libs.versions.toml | 3 + 13 files changed, 168 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt create mode 100644 composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt create mode 100644 composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt diff --git a/build.gradle.kts b/build.gradle.kts index 58a477e..1d4ac23 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 bdfb3f3..a897acd 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..480bc1c 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 kvStorelicked() = 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..4ba0125 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt @@ -0,0 +1,22 @@ +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 +} + +@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..1bef2bd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt @@ -0,0 +1,83 @@ +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" + + +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 + } + } + + 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..c38cb3b 100644 --- a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt @@ -112,6 +112,10 @@ fun HomeScreen( onClick = interactor::htmlDemoButtonClicked, enabled = Platform.current == Platform.WebBrowser, ) + Button( + content = { Text("KV Store Demo") }, + onClick = interactor::kvStoreButtonClicked, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt index bf2a554..bb1b766 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.kvStorelicked() } \ 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..a5ce0c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt @@ -0,0 +1,26 @@ +package ui.kvstoreDemo + +import androidx.compose.foundation.layout.* +import androidx.compose.material.Button +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.outsidesource.oskitcompose.interactor.collectAsState +import com.outsidesource.oskitcompose.lib.rememberInjectForRoute +import ui.capability.CapabilityScreenViewInteractor +import ui.capability.CapabilityType +import ui.common.Screen +import ui.common.Tab + +@Composable +fun KVStoreDemoScreen( + interactor: KVStoreDemoScreenViewInteractor = rememberInjectForRoute() +) { + val state = interactor.collectAsState() + + Screen("KV Store Demo") { + + } +} 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..c2bd311 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt @@ -0,0 +1,17 @@ +package ui.kvstoreDemo + +import com.outsidesource.oskitkmp.interactor.Interactor +import service.kvstoreDemo.IKVStoreDemoService + +data class KVStoreDemoScreenViewState( + val test: String = "", +) + +class KVStoreDemoScreenViewInteractor( + private val kvStoreDemoService: IKVStoreDemoService, +) : Interactor( + initialState = KVStoreDemoScreenViewState() +) { + + +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9d75ec3..5fc572d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ koinCore = "4.0.0" kotlinxAtomicfu = "0.26.1" kotlinxCoroutinesCore = "1.10.2" kotlinxDatetime = "0.6.1" +kotlinxSerialization = "1.6.3" okio = "3.9.1" oskitCompose = "4.1.0" oskitKmp = "5.0.0" @@ -36,6 +37,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,4 +45,5 @@ 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" } \ No newline at end of file From fd06c76b6d4581b3e20ca68d50ee8d8f6c3f179e Mon Sep 17 00:00:00 2001 From: Dr Watson <> Date: Fri, 30 May 2025 16:15:18 +0300 Subject: [PATCH 2/5] simple todo list for KV store demo UI and interactor --- .../kvstoreDemo/IKVStoreDemoService.kt | 1 + .../service/kvstoreDemo/KVStoreDemoService.kt | 14 ++ .../ui/kvstoreDemo/KVStoreDemoScreen.kt | 180 +++++++++++++++++- .../KVStoreDemoScreenViewInteractor.kt | 76 +++++++- 4 files changed, 266 insertions(+), 5 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt index 4ba0125..8cd2316 100644 --- a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/IKVStoreDemoService.kt @@ -12,6 +12,7 @@ interface IKVStoreDemoService { 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 diff --git a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt index 1bef2bd..f6e494d 100644 --- a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt @@ -69,6 +69,20 @@ class KVStoreDemoService( } } + 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, diff --git a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt index a5ce0c0..13d4d53 100644 --- a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt @@ -1,26 +1,198 @@ 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 ui.capability.CapabilityScreenViewInteractor -import ui.capability.CapabilityType +import service.kvstoreDemo.TodoItem import ui.common.Screen -import ui.common.Tab @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) }, + ) + 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() + ) - Screen("KV Store Demo") { + Spacer(Modifier.height(16.dp)) + // Completed Checkbox + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = state.selectedTodoItem.completed, + onCheckedChange = { isChecked -> + interactor.currentToDoItemCompletionStatusChanged(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 +) { + 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 = null, // Display only, editing handled in the detail view + enabled = false, + 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 index c2bd311..58c3f61 100644 --- a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt @@ -1,10 +1,17 @@ package ui.kvstoreDemo import com.outsidesource.oskitkmp.interactor.Interactor +import com.outsidesource.oskitkmp.outcome.Outcome +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import service.kvstoreDemo.IKVStoreDemoService +import service.kvstoreDemo.TodoItem data class KVStoreDemoScreenViewState( - val test: String = "", + val todoItems: List = emptyList(), + val newTodoText: String? = null, + val editableName: String? = null, + val selectedTodoItem: TodoItem? = null ) class KVStoreDemoScreenViewInteractor( @@ -13,5 +20,72 @@ class KVStoreDemoScreenViewInteractor( 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 currentToDoItemCompletionStatusChanged(completed: Boolean) { + val item = state.selectedTodoItem ?: return + 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) + } + } + } + } From ee3fba4abdbda11f62c037146b1ed96e7bed4a0f Mon Sep 17 00:00:00 2001 From: Dr Watson <> Date: Fri, 30 May 2025 17:55:42 +0300 Subject: [PATCH 3/5] docs updated --- .../service/kvstoreDemo/KVStoreDemoService.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt index f6e494d..41b262c 100644 --- a/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt +++ b/composeApp/src/commonMain/kotlin/service/kvstoreDemo/KVStoreDemoService.kt @@ -14,6 +14,21 @@ 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 { From f7c42d604cbc895617504e02d17887f5c691e54e Mon Sep 17 00:00:00 2001 From: Dr Watson <> Date: Fri, 30 May 2025 17:56:03 +0300 Subject: [PATCH 4/5] workaround for KVStore not emitting initial state of the key --- .../ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt index 58c3f61..c361d41 100644 --- a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt @@ -2,6 +2,7 @@ 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 @@ -33,6 +34,12 @@ class KVStoreDemoScreenViewInteractor( } } } + + interactorScope.launch { + // As observeSerializable does not emit the initial value upon subscription, we do a technical + // store update to trigger the initial emission. This is a temporary workaround until the issue is fixed + kvStoreDemoService.removeTodoItem(kvStoreDemoService.addTodoItem("XXXXX1234567890").unwrapOrNull()?.id ?: "") + } } fun newTodoNameTyped(value: String) { From e95b59cb1665e2e91d2f56c15aedfe28da304b6c Mon Sep 17 00:00:00 2001 From: Dr Watson <> Date: Wed, 4 Jun 2025 19:24:13 +0300 Subject: [PATCH 5/5] PR feedback --- .../commonMain/kotlin/coordinator/AppCoordinator.kt | 2 +- composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt | 8 ++++---- .../commonMain/kotlin/ui/home/HomeViewInteractor.kt | 2 +- .../kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt | 10 ++++++---- .../ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt | 9 +-------- 5 files changed, 13 insertions(+), 18 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt b/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt index 480bc1c..7c07f82 100644 --- a/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/coordinator/AppCoordinator.kt @@ -37,5 +37,5 @@ class AppCoordinator(): Coordinator( fun colorPickerClicked() = push(Route.ColorPicker) fun htmlDemoClicked() = push(Route.WebDemo) fun windowInfoClicked() = push(Route.WindowInfo) - fun kvStorelicked() = push(Route.KVStore) + fun kvStoreClicked() = push(Route.KVStore) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeScreen.kt index c38cb3b..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, @@ -112,10 +116,6 @@ fun HomeScreen( onClick = interactor::htmlDemoButtonClicked, enabled = Platform.current == Platform.WebBrowser, ) - Button( - content = { Text("KV Store Demo") }, - onClick = interactor::kvStoreButtonClicked, - ) } } } diff --git a/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt b/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt index bb1b766..d46b99e 100644 --- a/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt +++ b/composeApp/src/commonMain/kotlin/ui/home/HomeViewInteractor.kt @@ -19,5 +19,5 @@ class HomeViewInteractor( fun colorPickerButtonClicked() = coordinator.colorPickerClicked() fun htmlDemoButtonClicked() = coordinator.htmlDemoClicked() fun windowInfoButtonClicked() = coordinator.windowInfoClicked() - fun kvStoreButtonClicked() = coordinator.kvStorelicked() + 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 index 13d4d53..b60cdc2 100644 --- a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreen.kt @@ -97,6 +97,7 @@ fun KVStoreDemoScreen( item = item, isSelected = item.id == state.selectedTodoItem?.id, onItemClick = { interactor.selectTodoItem(item.id) }, + onItemCheckChanged = { interactor.toDoItemCompletionStatusChanged(item, it) } ) Divider() } @@ -143,7 +144,7 @@ fun KVStoreDemoScreen( Checkbox( checked = state.selectedTodoItem.completed, onCheckedChange = { isChecked -> - interactor.currentToDoItemCompletionStatusChanged(isChecked) + interactor.toDoItemCompletionStatusChanged(state.selectedTodoItem, isChecked) } ) Text("Completed") @@ -173,7 +174,8 @@ fun KVStoreDemoScreen( fun TodoListItem( item: TodoItem, isSelected: Boolean, - onItemClick: () -> Unit + onItemClick: () -> Unit, + onItemCheckChanged: (Boolean) -> Unit, ) { Row( modifier = Modifier @@ -189,8 +191,8 @@ fun TodoListItem( ) Checkbox( checked = item.completed, - onCheckedChange = null, // Display only, editing handled in the detail view - enabled = false, + 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 index c361d41..8d3d46a 100644 --- a/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt +++ b/composeApp/src/commonMain/kotlin/ui/kvstoreDemo/KVStoreDemoScreenViewInteractor.kt @@ -34,12 +34,6 @@ class KVStoreDemoScreenViewInteractor( } } } - - interactorScope.launch { - // As observeSerializable does not emit the initial value upon subscription, we do a technical - // store update to trigger the initial emission. This is a temporary workaround until the issue is fixed - kvStoreDemoService.removeTodoItem(kvStoreDemoService.addTodoItem("XXXXX1234567890").unwrapOrNull()?.id ?: "") - } } fun newTodoNameTyped(value: String) { @@ -58,8 +52,7 @@ class KVStoreDemoScreenViewInteractor( } } - fun currentToDoItemCompletionStatusChanged(completed: Boolean) { - val item = state.selectedTodoItem ?: return + fun toDoItemCompletionStatusChanged(item: TodoItem, completed: Boolean) { interactorScope.launch { kvStoreDemoService.changeState(item.id, completed) }