diff --git a/README.md b/README.md index 652432bb..4390dd61 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ Kick.controlPanel.getString("list") You can also add action buttons to trigger code in your app. Collect control panel events and handle button IDs you defined in `ControlPanelItem(type = ActionType.Button("id"))`: ``` -Kick.controlPanel.event.collect { event -> +Kick.controlPanel.events.collect { event -> when (event) { is ControlPanelEvent.ButtonClicked -> when (event.id) { "refresh_cache" -> refreshCache() @@ -417,6 +417,20 @@ Button("Kick") { } ``` +To close the viewer programmatically, call `Kick.close()`: + +In Kotlin: + +```kotlin +Kick.close() +``` + +In Swift: + +```swift +KickKt.shared.close() +``` + ## Shortcuts By default, Kick adds a shortcut to your app’s launcher icon (accessible via long-press). To disable it, pass `enableShortcut = false` during initialization: diff --git a/main-core/src/commonMain/kotlin/ru/bartwell/kick/Kick.kt b/main-core/src/commonMain/kotlin/ru/bartwell/kick/Kick.kt index 71c7399c..79114088 100644 --- a/main-core/src/commonMain/kotlin/ru/bartwell/kick/Kick.kt +++ b/main-core/src/commonMain/kotlin/ru/bartwell/kick/Kick.kt @@ -10,6 +10,7 @@ public interface Kick { public val modules: List public fun launch(context: PlatformContext) public fun launch(context: PlatformContext, startScreen: StartScreen?) + public fun close() public fun getShortcutId(): String public companion object Companion { @@ -34,6 +35,10 @@ public interface Kick { instance?.launch(context, startScreen) } + public fun close() { + instance?.close() + } + public fun getShortcutId(): String = instance?.getShortcutId() ?: "" } diff --git a/main-runtime-stub/src/commonMain/kotlin/ru/bartwell/kick/runtime/EmptyKickImpl.kt b/main-runtime-stub/src/commonMain/kotlin/ru/bartwell/kick/runtime/EmptyKickImpl.kt index f57cce92..20fa39cb 100644 --- a/main-runtime-stub/src/commonMain/kotlin/ru/bartwell/kick/runtime/EmptyKickImpl.kt +++ b/main-runtime-stub/src/commonMain/kotlin/ru/bartwell/kick/runtime/EmptyKickImpl.kt @@ -22,5 +22,12 @@ internal class EmptyKickImpl : Kick { ) } + override fun close() { + println( + "Kick: Unable to close the viewer because a stub module has been added. " + + "Please ensure that both the `main-core` and `main-runtime` modules are correctly configured" + ) + } + override fun getShortcutId(): String = "" } diff --git a/main-runtime/src/androidMain/kotlin/ru/bartwell/kick/runtime/KickActivity.kt b/main-runtime/src/androidMain/kotlin/ru/bartwell/kick/runtime/KickActivity.kt index 6bb4bde7..1d1ce84b 100644 --- a/main-runtime/src/androidMain/kotlin/ru/bartwell/kick/runtime/KickActivity.kt +++ b/main-runtime/src/androidMain/kotlin/ru/bartwell/kick/runtime/KickActivity.kt @@ -4,7 +4,11 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.arkivanov.decompose.defaultComponentContext +import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic @@ -13,12 +17,20 @@ import ru.bartwell.kick.core.component.Config import ru.bartwell.kick.core.data.StartScreen import ru.bartwell.kick.core.util.WindowStateManager import ru.bartwell.kick.runtime.core.component.DefaultRootComponent +import ru.bartwell.kick.runtime.core.util.ViewerCommands public class KickActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ViewerCommands.closeRequests.collect { + finish() + } + } + } val modules = Kick.modules val startScreenJson = intent.getStringExtra(EXTRA_START_SCREEN) val startScreen = if (startScreenJson != null) { diff --git a/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/KickImpl.kt b/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/KickImpl.kt index 7ca79f66..aa40a828 100644 --- a/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/KickImpl.kt +++ b/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/KickImpl.kt @@ -8,6 +8,7 @@ import ru.bartwell.kick.core.data.Theme import ru.bartwell.kick.core.util.WindowStateManager import ru.bartwell.kick.runtime.core.util.LaunchManager import ru.bartwell.kick.runtime.core.util.ShortcutManager +import ru.bartwell.kick.runtime.core.util.ViewerCommands import ru.bartwell.kick.runtime.core.util.WindowStateManagerImpl import ru.bartwell.kick.runtime.core.util.id @@ -34,6 +35,10 @@ internal class KickImpl( LaunchManager.launch(context, modules, startScreen) } + override fun close() { + ViewerCommands.requestClose() + } + override fun getShortcutId(): String = ShortcutManager.id } diff --git a/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/core/util/ViewerCommands.kt b/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/core/util/ViewerCommands.kt new file mode 100644 index 00000000..5f2ba046 --- /dev/null +++ b/main-runtime/src/commonMain/kotlin/ru/bartwell/kick/runtime/core/util/ViewerCommands.kt @@ -0,0 +1,19 @@ +package ru.bartwell.kick.runtime.core.util + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow + +internal object ViewerCommands { + private val _closeRequests = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + val closeRequests: SharedFlow = _closeRequests + + fun requestClose() { + _closeRequests.tryEmit(Unit) + } +} diff --git a/main-runtime/src/iosMain/kotlin/ru/bartwell/kick/runtime/core/util/IosSceneController.kt b/main-runtime/src/iosMain/kotlin/ru/bartwell/kick/runtime/core/util/IosSceneController.kt index cfd7ff8f..8d7b8ebd 100644 --- a/main-runtime/src/iosMain/kotlin/ru/bartwell/kick/runtime/core/util/IosSceneController.kt +++ b/main-runtime/src/iosMain/kotlin/ru/bartwell/kick/runtime/core/util/IosSceneController.kt @@ -1,5 +1,6 @@ package ru.bartwell.kick.runtime.core.util +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.window.ComposeUIViewController import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.essenty.lifecycle.LifecycleRegistry @@ -41,6 +42,11 @@ internal object IosSceneController { startScreen = startScreen, ) val uiViewController = ComposeUIViewController(configure = { enforceStrictPlistSanityCheck = false }) { + LaunchedEffect(Unit) { + ViewerCommands.closeRequests.collect { + dismiss() + } + } App(rootComponent) } lifecycle.create() diff --git a/main-runtime/src/jvmMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.kt b/main-runtime/src/jvmMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.kt index 3b48d88a..1ee91aec 100644 --- a/main-runtime/src/jvmMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.kt +++ b/main-runtime/src/jvmMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.kt @@ -1,6 +1,7 @@ package ru.bartwell.kick.runtime.core.util import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.awt.ComposeWindow import com.arkivanov.decompose.DefaultComponentContext @@ -55,6 +56,12 @@ internal actual object LaunchManager { }) setContent { + LaunchedEffect(Unit) { + ViewerCommands.closeRequests.collect { + window.dispose() + WindowStateManager.getInstance()?.setWindowClosed() + } + } CompositionLocalProvider(LocalComposeWindow provides window) { App(rootComponent) } diff --git a/main-runtime/src/wasmJsMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.wasmJs.kt b/main-runtime/src/wasmJsMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.wasmJs.kt index d60a7514..7c146ea7 100644 --- a/main-runtime/src/wasmJsMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.wasmJs.kt +++ b/main-runtime/src/wasmJsMain/kotlin/ru/bartwell/kick/runtime/core/util/LaunchManager.wasmJs.kt @@ -1,6 +1,7 @@ package ru.bartwell.kick.runtime.core.util import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport import com.arkivanov.decompose.DefaultComponentContext @@ -50,6 +51,13 @@ internal actual object LaunchManager { ) ComposeViewport(root) { + LaunchedEffect(Unit) { + ViewerCommands.closeRequests.collect { + val el = document.getElementById(ROOT_ID) + el?.parentElement?.removeChild(el) + WindowStateManager.getInstance()?.setWindowClosed() + } + } CompositionLocalProvider(LocalOverlayRoot provides root) { App(rootComponent) } diff --git a/module/settings/control-panel-stub/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt b/module/settings/control-panel-stub/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt index 77c4963c..050cfcb1 100644 --- a/module/settings/control-panel-stub/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt +++ b/module/settings/control-panel-stub/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt @@ -6,7 +6,7 @@ import ru.bartwell.kick.module.controlpanel.persists.ControlPanelSettings @Suppress("TooManyFunctions") public class ControlPanelAccessor internal constructor() { - public val event: Flow = ControlPanelActions.events + public val events: Flow = ControlPanelActions.events public fun getBoolean(key: String): Boolean = ControlPanelSettings.get(key).value public fun getBooleanOrNull(key: String): Boolean? = ControlPanelSettings.getOrNull(key)?.value diff --git a/module/settings/control-panel/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt b/module/settings/control-panel/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt index cf0fb869..18211870 100644 --- a/module/settings/control-panel/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt +++ b/module/settings/control-panel/src/commonMain/kotlin/ru/bartwell/kick/module/controlpanel/data/ControlPanelAccessor.kt @@ -6,7 +6,7 @@ import ru.bartwell.kick.module.controlpanel.core.persists.ControlPanelSettings @Suppress("TooManyFunctions") public class ControlPanelAccessor internal constructor() { - public val event: Flow = ControlPanelActions.events + public val events: Flow = ControlPanelActions.events public fun getBoolean(key: String): Boolean = ControlPanelSettings.get(key).value public fun getBooleanOrNull(key: String): Boolean? = ControlPanelSettings.getOrNull(key)?.value diff --git a/sample/ios/iosSample/ContentView.swift b/sample/ios/iosSample/ContentView.swift index a2f5edfd..440a840b 100644 --- a/sample/ios/iosSample/ContentView.swift +++ b/sample/ios/iosSample/ContentView.swift @@ -4,13 +4,16 @@ import shared enum DatabaseType: String, CaseIterable, Identifiable { case sqlDelight = "SqlDelight" case room = "Room" - + var id: String { self.rawValue } } struct ContentView: View { @State private var selectedTheme: AppTheme = .auto - + @State private var showButtonAlert = false + @State private var isCollectingEvents = false + @State private var controlPanelCollector: ControlPanelEventCollector? + var body: some View { VStack(spacing: 20) { Picker("Select theme", selection: $selectedTheme) { @@ -20,7 +23,7 @@ struct ContentView: View { } .pickerStyle(SegmentedPickerStyle()) .padding() - + Button("Launch viewer") { KickKt.shared.launch(context: PlatformContextKt.getPlatformContext()) } @@ -34,9 +37,32 @@ struct ContentView: View { .preferredColorScheme(colorScheme(for: selectedTheme)) .onAppear { KickKt.shared.theme = selectedTheme.toLibraryTheme() + startControlPanelEventCollection() + } + .alert("You clicked the button", isPresented: $showButtonAlert) { + Button("OK", role: .cancel) { } } } - + + private func startControlPanelEventCollection() { + guard !isCollectingEvents else { return } + isCollectingEvents = true + let collector = ControlPanelEventCollector { event in + print("Control panel event: \(event)") + if let clicked = event as? ControlPanelEvent.ButtonClicked, + clicked.id == "show_alert" { + KickKt.shared.close() + showButtonAlert = true + } + } + controlPanelCollector = collector + KickCompanion.shared.controlPanel.events.collect(collector: collector) { error in + if let error = error { + print("Control panel event collection error: \(error)") + } + } + } + private func colorScheme(for theme: AppTheme) -> ColorScheme? { switch theme { case .auto: @@ -48,3 +74,16 @@ struct ContentView: View { } } } + +final class ControlPanelEventCollector: Kotlinx_coroutines_coreFlowCollector { + private let onEvent: (Any?) -> Void + + init(onEvent: @escaping (Any?) -> Void) { + self.onEvent = onEvent + } + + func emit(value: Any?, completionHandler: @escaping (Error?) -> Void) { + onEvent(value) + completionHandler(nil) + } +} diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index 7f5c324c..94989acf 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -46,8 +46,10 @@ kotlin { export(projects.mainCore) if (isRelease) { export(projects.mainRuntimeStub) + export(projects.controlPanelStub) } else { export(projects.mainRuntime) + export(projects.controlPanel) } } } diff --git a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/App.kt b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/App.kt index 7140cdc3..ab2227df 100644 --- a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/App.kt +++ b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/App.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ColorScheme import androidx.compose.material3.DropdownMenuItem @@ -32,11 +33,13 @@ import ru.bartwell.kick.core.data.Theme import ru.bartwell.kick.core.data.platformContext import ru.bartwell.kick.core.ui.ExposedDropdownMenuBox import ru.bartwell.kick.module.controlpanel.controlPanel +import ru.bartwell.kick.module.controlpanel.data.ControlPanelEvent @Composable fun App() { val context = platformContext() var selectedTheme by remember { mutableStateOf(AppTheme.Auto) } + var showButtonAlert by remember { mutableStateOf(false) } LaunchedEffect(selectedTheme) { Kick.theme = selectedTheme.toLibraryTheme() @@ -44,8 +47,12 @@ fun App() { } LaunchedEffect(Unit) { - Kick.controlPanel.event.collect { event -> + Kick.controlPanel.events.collect { event -> println("Control panel event: $event") + if (event is ControlPanelEvent.ButtonClicked && event.id == "show_alert") { + Kick.close() + showButtonAlert = true + } } } @@ -67,6 +74,17 @@ fun App() { ) } } + if (showButtonAlert) { + AlertDialog( + onDismissRequest = { showButtonAlert = false }, + confirmButton = { + Button(onClick = { showButtonAlert = false }) { + Text("OK") + } + }, + text = { Text("You clicked the button") }, + ) + } } } } diff --git a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt index 397f8980..249c4279 100644 --- a/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt +++ b/sample/shared/src/commonMain/kotlin/ru/bartwell/kick/sample/shared/TestDataInitializer.kt @@ -171,9 +171,9 @@ class TestDataInitializer(context: PlatformContext) { type = ActionType.Button("aaaaaa"), ), ControlPanelItem( - name = "Log B", + name = "Show alert", category = "Actions", - type = ActionType.Button("bbbbbb"), + type = ActionType.Button("show_alert"), ), ) }