diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/App.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/App.kt deleted file mode 100644 index 501a2de..0000000 --- a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/App.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.arkivanov.minesweeper - -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.arkivanov.minesweeper.game.GameContent -import com.arkivanov.minesweeper.root.RootComponent - -@Composable -internal fun App(component: RootComponent) { - MaterialTheme { - GameContent(component = component.gameComponent, modifier = Modifier.fillMaxSize()) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/NavUtils.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/NavUtils.kt new file mode 100644 index 0000000..28408c1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/NavUtils.kt @@ -0,0 +1,36 @@ +package com.arkivanov.minesweeper + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.router.children.ChildNavState +import com.arkivanov.decompose.router.children.NavState +import com.arkivanov.decompose.router.children.NavigationSource +import com.arkivanov.decompose.router.children.SimpleChildNavState +import com.arkivanov.decompose.router.children.children +import com.arkivanov.decompose.value.Value +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable + +internal fun ComponentContext.child( + source: NavigationSource, + serializer: KSerializer, + initialConfiguration: () -> C, + key: String = "child", + childFactory: (C, ComponentContext) -> T, +): Value = + children( + source = source, + stateSerializer = SimpleNavState.serializer(typeSerial0 = serializer), + initialState = { SimpleNavState(initialConfiguration()) }, + key = key, + navTransformer = { _, config -> SimpleNavState(config) }, + stateMapper = { _, children -> requireNotNull(children.single().instance) }, + childFactory = childFactory, + ) + +@Serializable +private data class SimpleNavState( + private val config: C, +) : NavState { + override val children: List> = + listOf(SimpleChildNavState(config, ChildNavState.Status.RESUMED)) +} diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/game/GameSettings.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/game/GameSettings.kt index 36ff3f9..5628f1f 100644 --- a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/game/GameSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/game/GameSettings.kt @@ -1,7 +1,10 @@ package com.arkivanov.minesweeper.game +import kotlinx.serialization.Serializable + +@Serializable internal data class GameSettings( - val width: Int, - val height: Int, - val maxMines: Int, + val width: Int = 20, + val height: Int = 20, + val maxMines: Int = 30, ) diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/DefaultRootComponent.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/DefaultRootComponent.kt index 57fabd5..32d8208 100644 --- a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/DefaultRootComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/DefaultRootComponent.kt @@ -1,22 +1,59 @@ package com.arkivanov.minesweeper.root import com.arkivanov.decompose.ComponentContext -import com.arkivanov.decompose.childContext +import com.arkivanov.decompose.router.children.SimpleNavigation +import com.arkivanov.decompose.router.slot.ChildSlot +import com.arkivanov.decompose.router.slot.SlotNavigation +import com.arkivanov.decompose.router.slot.activate +import com.arkivanov.decompose.router.slot.childSlot +import com.arkivanov.decompose.router.slot.dismiss +import com.arkivanov.decompose.value.Value +import com.arkivanov.minesweeper.child import com.arkivanov.minesweeper.game.DefaultGameComponent import com.arkivanov.minesweeper.game.GameComponent import com.arkivanov.minesweeper.game.GameSettings +import com.arkivanov.minesweeper.settings.DefaultEditSettingsComponent +import com.arkivanov.minesweeper.settings.EditSettingsComponent import com.arkivanov.mvikotlin.core.store.StoreFactory internal class DefaultRootComponent( componentContext: ComponentContext, gameComponentFactory: GameComponent.Factory, + editSettingsComponentFactory: EditSettingsComponent.Factory, ) : RootComponent, ComponentContext by componentContext { - override val gameComponent: GameComponent = - gameComponentFactory( - componentContext = childContext(key = "game"), - settings = GameSettings(width = 20, height = 20, maxMines = 30), + private var settings = GameSettings() + + private val gameNav = SimpleNavigation() + override val gameComponent: Value = + child( + source = gameNav, + serializer = GameSettings.serializer(), + initialConfiguration = { settings }, + childFactory = { settings, ctx -> gameComponentFactory(componentContext = ctx, settings = settings) }, + ) + + private val editSettingsNav = SlotNavigation() + override val editSettingsComponent: Value> = + childSlot( + source = editSettingsNav, + serializer = null, + childFactory = { settings, _ -> + editSettingsComponentFactory( + settings = settings, + onConfirmed = { + this.settings = it + editSettingsNav.dismiss() + gameNav.navigate(it) + }, + onCancelled = editSettingsNav::dismiss, + ) + }, ) + + override fun onEditSettingsClicked() { + editSettingsNav.activate(settings) + } } internal fun DefaultRootComponent(componentContext: ComponentContext, storeFactory: StoreFactory): DefaultRootComponent = @@ -25,4 +62,5 @@ internal fun DefaultRootComponent(componentContext: ComponentContext, storeFacto gameComponentFactory = { ctx, settings -> DefaultGameComponent(componentContext = ctx, storeFactory = storeFactory, settings = settings) }, + editSettingsComponentFactory = ::DefaultEditSettingsComponent, ) diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootComponent.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootComponent.kt index 36a10b2..17a7e66 100644 --- a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootComponent.kt +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootComponent.kt @@ -1,8 +1,14 @@ package com.arkivanov.minesweeper.root +import com.arkivanov.decompose.router.slot.ChildSlot +import com.arkivanov.decompose.value.Value import com.arkivanov.minesweeper.game.GameComponent +import com.arkivanov.minesweeper.settings.EditSettingsComponent internal interface RootComponent { - val gameComponent: GameComponent + val gameComponent: Value + val editSettingsComponent: Value> + + fun onEditSettingsClicked() } diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootContent.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootContent.kt new file mode 100644 index 0000000..7a51693 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/root/RootContent.kt @@ -0,0 +1,45 @@ +package com.arkivanov.minesweeper.root + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import com.arkivanov.minesweeper.game.GameContent +import com.arkivanov.minesweeper.settings.EditSettingsContent + +@Composable +internal fun RootContent(component: RootComponent) { + val gameComponent by component.gameComponent.subscribeAsState() + val editSettingsComponentSlot by component.editSettingsComponent.subscribeAsState() + + MaterialTheme { + Column(modifier = Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("Minesweeper") }, + actions = { + IconButton(onClick = component::onEditSettingsClicked) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + ) + } + }, + ) + + GameContent(component = gameComponent, modifier = Modifier.fillMaxSize()) + } + + editSettingsComponentSlot.child?.instance?.also { editSettingsComponent -> + EditSettingsContent(component = editSettingsComponent) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/DefaultEditSettingsComponent.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/DefaultEditSettingsComponent.kt new file mode 100644 index 0000000..d379dd4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/DefaultEditSettingsComponent.kt @@ -0,0 +1,55 @@ +package com.arkivanov.minesweeper.settings + +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.update +import com.arkivanov.minesweeper.game.GameSettings +import com.arkivanov.minesweeper.settings.EditSettingsComponent.Model + +internal class DefaultEditSettingsComponent( + settings: GameSettings, + private val onConfirmed: (GameSettings) -> Unit, + private val onDismissed: () -> Unit, +) : EditSettingsComponent { + + private val _model = + MutableValue( + Model( + width = settings.width.toString(), + height = settings.height.toString(), + maxMines = settings.maxMines.toString(), + ) + ) + + override val model: Value = _model + + override fun onWidthChanged(text: String) { + _model.update { it.copy(width = text) } + } + + override fun onHeightChanged(text: String) { + _model.update { it.copy(height = text) } + } + + override fun onMaxMinesChanged(text: String) { + _model.update { it.copy(maxMines = text) } + } + + override fun onConfirmClicked() { + val width = _model.value.width.toIntOrNull() ?: return + val height = _model.value.height.toIntOrNull() ?: return + val maxMines = _model.value.maxMines.toIntOrNull() ?: return + + onConfirmed( + GameSettings( + width = width, + height = height, + maxMines = maxMines, + ) + ) + } + + override fun onDismissRequested() { + onDismissed() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/EditSettingsComponent.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/EditSettingsComponent.kt new file mode 100644 index 0000000..b7ff90a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/EditSettingsComponent.kt @@ -0,0 +1,29 @@ +package com.arkivanov.minesweeper.settings + +import com.arkivanov.decompose.value.Value +import com.arkivanov.minesweeper.game.GameSettings + +internal interface EditSettingsComponent { + + val model: Value + + fun onWidthChanged(text: String) + fun onHeightChanged(text: String) + fun onMaxMinesChanged(text: String) + fun onConfirmClicked() + fun onDismissRequested() + + data class Model( + val width: String, + val height: String, + val maxMines: String, + ) + + fun interface Factory { + operator fun invoke( + settings: GameSettings, + onConfirmed: (GameSettings) -> Unit, + onCancelled: () -> Unit, + ): EditSettingsComponent + } +} diff --git a/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/EditSettingsContent.kt b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/EditSettingsContent.kt new file mode 100644 index 0000000..4b157d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/arkivanov/minesweeper/settings/EditSettingsContent.kt @@ -0,0 +1,63 @@ +package com.arkivanov.minesweeper.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.arkivanov.decompose.extensions.compose.subscribeAsState + +@Composable +internal fun EditSettingsContent(component: EditSettingsComponent) { + val model by component.model.subscribeAsState() + + Dialog(onDismissRequest = component::onDismissRequested) { + Surface(shape = MaterialTheme.shapes.medium) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + TextField( + value = model.width, + onValueChange = component::onWidthChanged, + label = { Text(text = "Width") }, + ) + + TextField( + value = model.height, + onValueChange = component::onHeightChanged, + label = { Text(text = "Height") }, + ) + + TextField( + value = model.maxMines, + onValueChange = component::onMaxMinesChanged, + label = { Text(text = "Mine count") }, + ) + + Row( + modifier = Modifier.align(Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Button(onClick = component::onConfirmClicked) { + Text(text = "Apply") + } + + Button(onClick = component::onConfirmClicked) { + Text(text = "Cancel") + } + } + } + } + } +} diff --git a/composeApp/src/jvmMain/kotlin/Main.kt b/composeApp/src/jvmMain/kotlin/Main.kt index b394772..8365dc4 100644 --- a/composeApp/src/jvmMain/kotlin/Main.kt +++ b/composeApp/src/jvmMain/kotlin/Main.kt @@ -5,8 +5,8 @@ import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.extensions.compose.lifecycle.LifecycleController import com.arkivanov.essenty.lifecycle.LifecycleRegistry -import com.arkivanov.minesweeper.App import com.arkivanov.minesweeper.root.DefaultRootComponent +import com.arkivanov.minesweeper.root.RootContent import com.arkivanov.mvikotlin.timetravel.server.TimeTravelServer import com.arkivanov.mvikotlin.timetravel.store.TimeTravelStoreFactory import javax.swing.SwingUtilities @@ -27,7 +27,7 @@ fun main() { val windowState = rememberWindowState() Window(onCloseRequest = ::exitApplication, title = "Minesweeper", state = windowState) { - App(component = root) + RootContent(component = root) } @OptIn(ExperimentalDecomposeApi::class) diff --git a/composeApp/src/wasmJsMain/kotlin/Main.kt b/composeApp/src/wasmJsMain/kotlin/Main.kt index 973150b..1ac4974 100644 --- a/composeApp/src/wasmJsMain/kotlin/Main.kt +++ b/composeApp/src/wasmJsMain/kotlin/Main.kt @@ -3,8 +3,8 @@ import androidx.compose.ui.window.CanvasBasedWindow import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.essenty.lifecycle.resume -import com.arkivanov.minesweeper.App import com.arkivanov.minesweeper.root.DefaultRootComponent +import com.arkivanov.minesweeper.root.RootContent import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory @OptIn(ExperimentalComposeUiApi::class) @@ -20,6 +20,6 @@ fun main() { lifecycle.resume() CanvasBasedWindow(title = "Minesweeper", canvasElementId = "ComposeTarget") { - App(root) + RootContent(root) } }