Add basic network feature.

Basic implementation of a WebSocket protocol with JSON.

Note : obfuscation & release build are deactivated again. Netty (needed for Ktor server) is a nightmare to build in release with or without proguard.
Spend more time until now on project configuration that in actual prototyping.
This commit is contained in:
Thomas Andres Gomez 2024-11-08 18:58:47 +01:00
parent ba0cc30a1a
commit 0e5fee6771
22 changed files with 958 additions and 161 deletions

View file

@ -5,6 +5,8 @@ plugins {
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
// kotlin("jvm") version "1.9.20"
// alias(libs.plugins.kotlinKtor)
}
kotlin {
@ -24,6 +26,12 @@ kotlin {
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.websockets)
}
commonTest.dependencies {
@ -53,12 +61,12 @@ compose.desktop {
includeAllModules = true
// Use system theming fot the app toolbars.
// Use system theming for the app toolbars.
jvmArgs("-Dapple.awt.application.appearance=system")
}
buildTypes.release.proguard {
obfuscate.set(true) // Obfuscation crash at runtime when try to use datastore.
obfuscate.set(false) // Obfuscation doesn't work because of netty.
configurationFiles.from(project.file("compose-desktop.pro"))
}
}

View file

@ -1,5 +1,3 @@
## Data Store old dependancies not removed properly.
-dontwarn okio.AsyncTimeout$Watchdog
-keep class androidx.compose.runtime.** { *; }
-keep class androidx.collection.** { *; }

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="main_page__create_action">Créer une feuille de personnage</string>
<string name="main_page__network_action">Configuration réseau</string>
<string name="roll_page__critical_success">Réussite critique</string>
<string name="roll_page__special_success">Réussite spéciale</string>
@ -65,4 +66,19 @@
<string name="character_sheet__skills__title">Compétences</string>
<string name="character_sheet__occupations_title">Occupations</string>
<string name="character_sheet__magics__title">Compétences magiques</string>
<string name="network__title">Configuration réseau</string>
<string name="network__player_name__label">Nom du joueur</string>
<string name="network__host__label">host</string>
<string name="network__port__label">port</string>
<string name="network__socket__connect_action">Se connecter à la table</string>
<string name="network__socket__host_action">Héberger la table</string>
<string name="network__socket__disconnect_action">Se déconnecter</string>
<string name="network__socket__status_state">État de connexion : %1$s</string>
<string name="network__socket__status_connected">Connecté</string>
<string name="network__socket__status_disconnected">Disconnecté</string>
<string name="network__socket__type_state">Type de connexion : %1$s</string>
<string name="network__socket__type_server">Serveur</string>
<string name="network__socket__type_client">Client</string>
<string name="network__socket__type_none">Aucun</string>
</resources>

View file

@ -3,19 +3,58 @@ package com.pixelized.desktop.lwa
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.rememberWindowState
import com.pixelized.desktop.lwa.navigation.MainNavHost
import com.pixelized.desktop.lwa.theme.LwaTheme
import org.jetbrains.compose.ui.tooling.preview.Preview
val LocalWindowController = compositionLocalOf<WindowController> {
error("Local Window Controller is not yet ready")
}
@Stable
data class WindowController(
private val onCloseRequest: () -> Unit
) {
fun close() = onCloseRequest()
}
@Composable
@Preview
fun App() {
LwaTheme {
Surface(
modifier = Modifier.fillMaxSize()
fun ApplicationScope.App() {
val controller = remember {
WindowController(
onCloseRequest = ::exitApplication
)
}
CompositionLocalProvider(
LocalWindowController provides controller,
) {
Window(
onCloseRequest = {
controller.close()
},
state = rememberWindowState(
width = 320.dp + 64.dp,
height = 900.dp,
),
title = "LwaCharacterSheet",
) {
MainNavHost()
LwaTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
MainNavHost()
}
}
}
}
}

View file

@ -7,11 +7,10 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.pixelized.desktop.lwa.navigation.destination.MainDestination
import com.pixelized.desktop.lwa.navigation.destination.composableCharacterSheetEditPage
import com.pixelized.desktop.lwa.navigation.destination.composableCharacterSheetPage
import com.pixelized.desktop.lwa.navigation.destination.composableMainPage
import com.pixelized.desktop.lwa.navigation.destination.composableNetworkPage
val LocalScreen = compositionLocalOf<NavHostController> {
val LocalScreenController = compositionLocalOf<NavHostController> {
error("MainNavHost controller is not yet ready")
}
@ -21,16 +20,14 @@ fun MainNavHost(
startDestination: String = MainDestination.navigationRoute(),
) {
CompositionLocalProvider(
LocalScreen provides controller,
LocalScreenController provides controller,
) {
NavHost(
navController = controller,
startDestination = startDestination,
) {
composableMainPage()
composableCharacterSheetPage()
composableCharacterSheetEditPage()
composableNetworkPage()
}
}
}

View file

@ -0,0 +1,26 @@
package com.pixelized.desktop.lwa.navigation.destination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.screen.network.NetworkPage
object NetworkDestination {
private const val ROUTE = "network"
fun baseRoute() = ROUTE
fun navigationRoute() = ROUTE
}
fun NavGraphBuilder.composableNetworkPage() {
composable(
route = NetworkDestination.baseRoute(),
) {
NetworkPage()
}
}
fun NavHostController.navigateToNetwork() {
val route = NetworkDestination.navigationRoute()
navigate(route = route)
}

View file

@ -0,0 +1,170 @@
package com.pixelized.desktop.lwa.repository.network
import com.pixelized.desktop.lwa.repository.network.helper.client
import com.pixelized.desktop.lwa.repository.network.helper.connectWebSocket
import com.pixelized.desktop.lwa.repository.network.helper.server
import com.pixelized.desktop.lwa.repository.network.protocol.Message
import io.ktor.client.HttpClient
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.netty.NettyApplicationEngine
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.encodeToJsonElement
typealias Server = EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>
typealias Client = HttpClient
object NetworkRepository {
private val scope = CoroutineScope(Dispatchers.IO)
private var networkJob: Job? = null
private var server: Server? = null
private var client: Client? = null
private val messageResponseFlow = MutableSharedFlow<String>()
private val sharedFlow = messageResponseFlow.asSharedFlow()
private val _player = MutableStateFlow("")
val player: StateFlow<String> get() = _player
private val _status = MutableStateFlow(Status.DISCONNECTED)
val status: StateFlow<Status> get() = _status
private val _type = MutableStateFlow(Type.NONE)
val type: StateFlow<Type> get() = _type
fun onPlayerNameChange(player: String) {
_player.value = player
}
fun host(
port: Int,
) {
_type.value = Type.SERVER
_status.value = Status.CONNECTED
server = server(port = port) {
println("Server launched")
val job = launch {
sharedFlow.collect { message ->
println("Broadcast: $message")
send(Frame.Text(message))
}
}
runCatching {
incoming.consumeEach { frame ->
if (frame is Frame.Text) {
val receivedText = frame.readText()
messageResponseFlow.emit(receivedText)
}
}
}.onFailure { exception ->
// TODO
println("WebSocket exception: ${exception.localizedMessage}")
}.also {
job.cancel()
}
}
networkJob?.cancel()
networkJob = scope.launch {
try {
server?.start(wait = true)
} catch (exception: Exception) {
// TODO
println("WebSocket exception: ${exception.localizedMessage}")
} finally {
println("Server close")
_type.value = Type.NONE
_status.value = Status.DISCONNECTED
}
}
}
fun connect(
host: String,
port: Int,
) {
client = client()
networkJob?.cancel()
networkJob = scope.launch {
try {
client?.connectWebSocket(host = host, port = port) {
_type.value = Type.CLIENT
_status.value = Status.CONNECTED
println("Client launched")
val job = launch {
sharedFlow.collect { message ->
println("Send: $message")
send(Frame.Text(message))
}
}
runBlocking {
incoming.consumeEach { frame ->
if (frame is Frame.Text) {
val receivedText = frame.readText()
println("client received: $receivedText")
}
}
}.also {
job.cancel()
}
}
} catch (exception: Exception) {
// TODO
println("WebSocket exception: ${exception.localizedMessage}")
} finally {
println("Client close")
_type.value = Type.NONE
_status.value = Status.DISCONNECTED
}
}
}
fun disconnect() {
networkJob?.cancel()
scope.launch {
server?.stop()
client?.close()
}
}
fun share(
type: String,
value: String,
) {
if (status.value == Status.CONNECTED) {
scope.launch {
val message = Message(from = player.value, type = type, value = value)
val json = Json.encodeToJsonElement(message)
messageResponseFlow.emit(json.toString())
}
}
}
enum class Status {
CONNECTED,
DISCONNECTED
}
enum class Type {
CLIENT,
SERVER,
NONE,
}
}

View file

@ -0,0 +1,32 @@
package com.pixelized.desktop.lwa.repository.network.helper
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.http.HttpMethod
// https://ktor.io/docs/client-websockets.html#handle-session
fun client(): HttpClient {
val client = HttpClient(CIO) {
install(WebSockets) {
pingIntervalMillis = 20_000
}
}
return client
}
suspend fun HttpClient.connectWebSocket(
host: String,
port: Int,
block: suspend DefaultClientWebSocketSession.() -> Unit
) {
webSocket(
method = HttpMethod.Get,
host = host,
port = port,
path = "/ws",
block = block,
)
}

View file

@ -0,0 +1,39 @@
package com.pixelized.desktop.lwa.repository.network.helper
import io.ktor.server.application.install
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
import io.ktor.server.routing.routing
import io.ktor.server.websocket.DefaultWebSocketServerSession
import io.ktor.server.websocket.WebSockets
import io.ktor.server.websocket.pingPeriod
import io.ktor.server.websocket.timeout
import io.ktor.server.websocket.webSocket
import kotlin.time.Duration.Companion.seconds
// https://ktor.io/docs/server-websockets.html#handle-multiple-session
fun server(
port: Int = 8080,
handler: suspend DefaultWebSocketServerSession.() -> Unit
): EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
return embeddedServer(
factory = Netty,
port = port,
module = {
install(WebSockets) {
pingPeriod = 15.seconds
timeout = 15.seconds
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket(
path = "/ws",
handler = handler,
)
}
},
)
}

View file

@ -0,0 +1,10 @@
package com.pixelized.desktop.lwa.repository.network.protocol
import kotlinx.serialization.Serializable
@Serializable
class Message(
val from: String,
val type: String,
val value: String,
)

View file

@ -0,0 +1,35 @@
package com.pixelized.desktop.lwa.screen.characterSheet
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.pixelized.desktop.lwa.navigation.LocalScreenController
import com.pixelized.desktop.lwa.navigation.destination.composableCharacterSheetEditPage
import com.pixelized.desktop.lwa.navigation.destination.composableCharacterSheetPage
@Composable
fun CharacterSheetMainNavHost(
controller: NavHostController = rememberNavController(),
startDestination: String,
) {
CompositionLocalProvider(
LocalScreenController provides controller,
) {
Surface(
modifier = Modifier.fillMaxSize(),
) {
NavHost(
navController = controller,
startDestination = startDestination,
) {
composableCharacterSheetPage()
composableCharacterSheetEditPage()
}
}
}
}

View file

@ -4,13 +4,12 @@ import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@ -36,14 +35,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.pixelized.desktop.lwa.LocalWindowController
import com.pixelized.desktop.lwa.composable.decoratedBox.DecoratedBox
import com.pixelized.desktop.lwa.composable.overlay.BlurOverlay
import com.pixelized.desktop.lwa.composable.overlay.BlurOverlayViewModel
import com.pixelized.desktop.lwa.navigation.LocalScreen
import com.pixelized.desktop.lwa.navigation.LocalScreenController
import com.pixelized.desktop.lwa.navigation.destination.navigateToCharacterSheetEdit
import com.pixelized.desktop.lwa.screen.roll.RollPage
import com.pixelized.desktop.lwa.screen.roll.RollViewModel
@ -95,7 +94,8 @@ fun CharacterSheetPage(
overlayViewModel: BlurOverlayViewModel = viewModel { BlurOverlayViewModel() },
rollViewModel: RollViewModel = viewModel { RollViewModel() },
) {
val screen = LocalScreen.current
val window = LocalWindowController.current
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
Surface(
@ -123,7 +123,9 @@ fun CharacterSheetPage(
onDelete = {
scope.launch {
viewModel.deleteCharacter(id = sheet.id)
screen.popBackStack()
if (screen.popBackStack().not()) {
window.close()
}
}
},
onCharacteristic = { characteristic ->
@ -148,12 +150,10 @@ fun CharacterSheetPage(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CharacterSheetPageContent(
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
width: Dp = 320.dp,
characterSheet: CharacterSheetPageUio,
onBack: () -> Unit,
onEdit: () -> Unit,
@ -163,6 +163,7 @@ fun CharacterSheetPageContent(
onRoll: (roll: CharacterSheetPageUio.Roll) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
@ -203,115 +204,131 @@ fun CharacterSheetPageContent(
)
},
content = { paddingValues ->
Column(
Row(
modifier = Modifier
.verticalScroll(state = scrollState).padding(all = 16.dp)
.verticalScroll(state = scrollState)
.padding(paddingValues)
.then(other = modifier),
verticalArrangement = Arrangement.spacedBy(space = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
.padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(space = 16.dp),
) {
FlowRow(
maxItemsInEachRow = 3,
horizontalArrangement = Arrangement.spacedBy(
space = 16.dp,
alignment = Alignment.CenterHorizontally,
),
Column(
modifier = Modifier
.fillMaxHeight()
.width(100.dp),
verticalArrangement = Arrangement.spacedBy(space = 16.dp),
) {
characterSheet.characteristics.forEach {
Stat(
modifier = Modifier.width(width = width / 3 - 32.dp)
.height(height = 112.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 120.dp),
characteristic = it,
onClick = { onCharacteristic(it) },
)
}
}
DecoratedBox(
modifier = Modifier.width(width = width).padding(vertical = 8.dp),
Column(
modifier = Modifier
.fillMaxHeight()
.weight(2f / 3f),
verticalArrangement = Arrangement.spacedBy(space = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__sub_characteristics__title),
)
characterSheet.subCharacteristics.forEach {
Characteristics(
modifier = Modifier.fillMaxWidth(),
characteristic = it,
DecoratedBox(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__sub_characteristics__title),
)
characterSheet.subCharacteristics.forEach {
Characteristics(
modifier = Modifier.fillMaxWidth(),
characteristic = it,
)
}
}
}
}
DecoratedBox(
modifier = Modifier.width(width = width).padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__skills__title),
)
characterSheet.skills.forEach {
Skill(
modifier = Modifier.fillMaxWidth(),
label = it.label,
value = it.value,
onClick = { onSkill(it) },
DecoratedBox(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__skills__title),
)
characterSheet.skills.forEach {
Skill(
modifier = Modifier.fillMaxWidth(),
label = it.label,
value = it.value,
onClick = { onSkill(it) },
)
}
}
}
}
DecoratedBox(
modifier = Modifier.width(width = width).padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__occupations_title),
)
characterSheet.occupations.forEach {
Skill(
modifier = Modifier.fillMaxWidth(),
label = it.label,
value = it.value,
onClick = { onSkill(it) },
DecoratedBox(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__occupations_title),
)
characterSheet.occupations.forEach {
Skill(
modifier = Modifier.fillMaxWidth(),
label = it.label,
value = it.value,
onClick = { onSkill(it) },
)
}
}
}
}
DecoratedBox(
modifier = Modifier.width(width = width).padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__magics__title),
)
characterSheet.magics.forEach {
Skill(
modifier = Modifier.fillMaxWidth(),
label = it.label,
value = it.value,
onClick = { onSkill(it) },
DecoratedBox(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column {
Text(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
text = stringResource(Res.string.character_sheet__magics__title),
)
characterSheet.magics.forEach {
Skill(
modifier = Modifier.fillMaxWidth(),
label = it.label,
value = it.value,
onClick = { onSkill(it) },
)
}
}
}
}
characterSheet.rolls.forEach {
Roll(
modifier = Modifier.fillMaxWidth(),
label = it.label,
onClick = { onRoll(it) },
)
characterSheet.rolls.forEach {
Roll(
modifier = Modifier.fillMaxWidth(),
label = it.label,
onClick = { onRoll(it) },
)
}
}
}
}
@ -326,7 +343,9 @@ private fun Stat(
onClick: () -> Unit,
) {
DecoratedBox(
modifier = Modifier.clickable(onClick = onClick).padding(paddingValues = paddingValues)
modifier = Modifier
.clickable(onClick = onClick)
.padding(paddingValues = paddingValues)
.then(other = modifier),
) {
Text(

View file

@ -29,8 +29,9 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.pixelized.desktop.lwa.LocalWindowController
import com.pixelized.desktop.lwa.composable.decoratedBox.DecoratedBox
import com.pixelized.desktop.lwa.navigation.LocalScreen
import com.pixelized.desktop.lwa.navigation.LocalScreenController
import com.pixelized.desktop.lwa.screen.characterSheet.edit.composable.FieldUio
import com.pixelized.desktop.lwa.screen.characterSheet.edit.composable.Form
import kotlinx.coroutines.launch
@ -75,7 +76,8 @@ fun CharacterSheetEditPage(
)
},
) {
val screen = LocalScreen.current
val window = LocalWindowController.current
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
Surface(
@ -89,7 +91,9 @@ fun CharacterSheetEditPage(
onSave = {
scope.launch {
viewModel.save()
screen.popBackStack()
if (screen.popBackStack().not()) {
window.close()
}
}
},
)

View file

@ -3,26 +3,40 @@ package com.pixelized.desktop.lwa.screen.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.rememberWindowState
import androidx.lifecycle.viewmodel.compose.viewModel
import com.pixelized.desktop.lwa.navigation.LocalScreen
import com.pixelized.desktop.lwa.navigation.destination.navigateToCharacterSheet
import com.pixelized.desktop.lwa.navigation.destination.navigateToCharacterSheetEdit
import com.pixelized.desktop.lwa.LocalWindowController
import com.pixelized.desktop.lwa.WindowController
import com.pixelized.desktop.lwa.composable.decoratedBox.DecoratedBox
import com.pixelized.desktop.lwa.navigation.LocalScreenController
import com.pixelized.desktop.lwa.navigation.destination.CharacterSheetDestination
import com.pixelized.desktop.lwa.navigation.destination.CharacterSheetEditDestination
import com.pixelized.desktop.lwa.navigation.destination.navigateToNetwork
import com.pixelized.desktop.lwa.screen.characterSheet.CharacterSheetMainNavHost
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__title
import lwacharactersheet.composeapp.generated.resources.main_page__create_action
import lwacharactersheet.composeapp.generated.resources.main_page__network_action
import org.jetbrains.compose.resources.stringResource
@Stable
@ -35,62 +49,165 @@ data class CharacterUio(
fun MainPage(
viewModel: MainPageViewModel = viewModel { MainPageViewModel() },
) {
val screen = LocalScreen.current
val screen = LocalScreenController.current
Surface {
Surface(
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.verticalScroll(state = rememberScrollState())
.fillMaxSize()
.padding(horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
MainPageContent(
characters = viewModel.characters,
onCharacter = {
screen.navigateToCharacterSheet(id = it.id)
viewModel.showCharacterSheet(sheet = it)
},
onCreateCharacter = {
screen.navigateToCharacterSheetEdit()
viewModel.showCreateCharacterSheet()
},
onNetwork = {
screen.navigateToNetwork()
},
)
}
}
HandleCharacterSheet(
sheets = viewModel.sheet,
onCloseRequest = { viewModel.hideCharacterSheet(sheet = it) }
)
HandleCharacterSheetCreation(
sheets = viewModel.create,
onCloseRequest = { viewModel.hideCreateCharacterSheet(id = it) },
)
}
@Composable
fun HandleCharacterSheet(
sheets: State<Set<CharacterUio>>,
onCloseRequest: (id: CharacterUio) -> Unit,
) {
sheets.value.forEach { sheet ->
val controller = remember {
WindowController(
onCloseRequest = { onCloseRequest(sheet) }
)
}
CompositionLocalProvider(
LocalWindowController provides controller,
) {
Window(
onCloseRequest = { onCloseRequest(sheet) },
state = rememberWindowState(
width = 400.dp + 64.dp,
height = 900.dp,
),
title = sheet.name,
) {
CharacterSheetMainNavHost(
startDestination = CharacterSheetDestination.navigationRoute(id = sheet.id)
)
}
}
}
}
@Composable
fun HandleCharacterSheetCreation(
sheets: State<Set<Int>>,
onCloseRequest: (id: Int) -> Unit,
) {
sheets.value.forEach { sheet ->
val controller = remember {
WindowController(
onCloseRequest = { onCloseRequest(sheet) }
)
}
CompositionLocalProvider(
LocalWindowController provides controller,
) {
Window(
onCloseRequest = { controller.close() },
state = rememberWindowState(
width = 400.dp + 64.dp,
height = 900.dp,
),
title = stringResource(Res.string.character_sheet_edit__title),
) {
CharacterSheetMainNavHost(
startDestination = CharacterSheetEditDestination.navigationRoute(id = null)
)
}
}
}
}
@Composable
fun MainPageContent(
modifier: Modifier = Modifier,
characters: State<List<CharacterUio>>,
onCharacter: (CharacterUio) -> Unit,
onCreateCharacter: () -> Unit,
onNetwork: () -> Unit,
) {
Column(
modifier = modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(space = 32.dp),
modifier = modifier,
) {
Column {
characters.value.forEach { sheet ->
Spacer(
modifier = Modifier.weight(weight = 1f)
)
DecoratedBox {
Column(
modifier = Modifier.padding(horizontal = 8.dp),
verticalArrangement = Arrangement.spacedBy(space = 32.dp),
) {
Column {
characters.value.forEach { sheet ->
TextButton(
onClick = { onCharacter(sheet) },
) {
Text(
modifier = Modifier.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
maxLines = 1,
text = sheet.name,
)
}
}
}
TextButton(
onClick = { onCharacter(sheet) },
onClick = { onCreateCharacter() },
) {
Text(
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
maxLines = 1,
text = sheet.name,
text = stringResource(Res.string.main_page__create_action),
)
}
}
}
Spacer(
modifier = Modifier.weight(weight = 1f)
)
TextButton(
onClick = { onCreateCharacter() },
onClick = { onNetwork() },
) {
Text(
modifier = Modifier.fillMaxWidth(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
text = stringResource(Res.string.main_page__create_action),
text = stringResource(Res.string.main_page__network_action),
)
}
}

View file

@ -3,18 +3,25 @@ package com.pixelized.desktop.lwa.screen.main
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.utils.extention.collectAsState
class MainPageViewModel : ViewModel() {
// using a variable to help with later injection.
private val characterSheetRepository = CharacterSheetRepository
private val repository = CharacterSheetRepository
private val _sheet = mutableStateOf<Set<CharacterUio>>(emptySet())
val sheet: State<Set<CharacterUio>> get() = _sheet
private val _create = mutableStateOf<Set<Int>>(emptySet())
val create: State<Set<Int>> get() = _create
val characters: State<List<CharacterUio>>
@Composable
@Stable
get() = characterSheetRepository
get() = repository
.characterSheetFlow()
.collectAsState { sheets ->
sheets.map { sheet ->
@ -24,4 +31,20 @@ class MainPageViewModel : ViewModel() {
)
}
}
fun showCreateCharacterSheet() {
_create.value = _create.value.toMutableSet().apply { add(size) }
}
fun hideCreateCharacterSheet(id: Int) {
_create.value = _create.value.toMutableSet().apply { remove(id) }
}
fun showCharacterSheet(sheet: CharacterUio) {
_sheet.value = _sheet.value.toMutableSet().apply { add(sheet) }
}
fun hideCharacterSheet(sheet: CharacterUio) {
_sheet.value = _sheet.value.toMutableSet().apply { remove(sheet) }
}
}

View file

@ -0,0 +1,24 @@
package com.pixelized.desktop.lwa.screen.network
import com.pixelized.desktop.lwa.repository.network.NetworkRepository.Status
import com.pixelized.desktop.lwa.repository.network.NetworkRepository.Type
class NetworkFactory {
fun convertToUio(
player: String,
status: Status,
type: Type,
host: String,
port: Int,
): NetworkPageUio {
return NetworkPageUio(
player = player,
host = host,
port = "$port",
enableFields = status == Status.DISCONNECTED,
enableActions = status == Status.DISCONNECTED && player.isNotBlank() && host.isNotBlank() && port > 0,
enableCancel = type != Type.NONE && status == Status.CONNECTED
)
}
}

View file

@ -0,0 +1,176 @@
package com.pixelized.desktop.lwa.screen.network
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TextField
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.pixelized.desktop.lwa.navigation.LocalScreenController
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.network__host__label
import lwacharactersheet.composeapp.generated.resources.network__player_name__label
import lwacharactersheet.composeapp.generated.resources.network__port__label
import lwacharactersheet.composeapp.generated.resources.network__socket__disconnect_action
import lwacharactersheet.composeapp.generated.resources.network__socket__connect_action
import lwacharactersheet.composeapp.generated.resources.network__socket__host_action
import lwacharactersheet.composeapp.generated.resources.network__title
import org.jetbrains.compose.resources.stringResource
@Stable
data class NetworkPageUio(
val player: String,
val host: String,
val port: String,
val enableFields: Boolean,
val enableActions: Boolean,
val enableCancel: Boolean,
)
@Composable
fun NetworkPage(
viewModel: NetworkViewModel = viewModel { NetworkViewModel() },
) {
val screen = LocalScreenController.current
Surface(
modifier = Modifier.fillMaxSize(),
) {
NetworkContent(
modifier = Modifier.fillMaxSize(),
player = viewModel.network,
onBack = { screen.popBackStack() },
onPlayerChange = viewModel::onPlayerNameChange,
onHostChange = viewModel::onHostChange,
onPortChange = viewModel::onPortChange,
onConnect = viewModel::connect,
onHost = viewModel::host,
onDisconnect = viewModel::disconnect,
)
}
}
@Composable
private fun NetworkContent(
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
player: State<NetworkPageUio>,
onBack: () -> Unit,
onPlayerChange: (String) -> Unit,
onHostChange: (String) -> Unit,
onPortChange: (String) -> Unit,
onConnect: () -> Unit,
onHost: () -> Unit,
onDisconnect: () -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = stringResource(Res.string.network__title),
)
},
navigationIcon = {
IconButton(
onClick = onBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
}
)
},
content = { paddingValues ->
Column(
modifier = Modifier
.verticalScroll(state = scrollState)
.padding(paddingValues)
.padding(all = 16.dp),
) {
TextField(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = player.value.enableFields,
label = { Text(text = stringResource(Res.string.network__player_name__label)) },
onValueChange = { onPlayerChange(it) },
value = player.value.player,
)
Spacer(
modifier = Modifier.height(16.dp),
)
Row(
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
TextField(
modifier = Modifier.weight(1f),
singleLine = true,
enabled = player.value.enableFields,
label = { Text(text = stringResource(Res.string.network__host__label)) },
onValueChange = { onHostChange(it) },
value = player.value.host,
)
TextField(
modifier = Modifier.width(100.dp),
singleLine = true,
enabled = player.value.enableFields,
label = { Text(text = stringResource(Res.string.network__port__label)) },
onValueChange = { onPortChange(it) },
value = player.value.port,
)
}
TextButton(
enabled = player.value.enableActions,
onClick = onConnect,
) {
Text(text = stringResource(Res.string.network__socket__connect_action))
}
TextButton(
enabled = player.value.enableActions,
onClick = onHost,
) {
Text(text = stringResource(Res.string.network__socket__host_action))
}
TextButton(
enabled = player.value.enableCancel,
onClick = onDisconnect,
) {
Text(text = stringResource(Res.string.network__socket__disconnect_action))
}
}
}
)
}

View file

@ -0,0 +1,62 @@
package com.pixelized.desktop.lwa.screen.network
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
class NetworkViewModel : ViewModel() {
private val repository = NetworkRepository
private val factory = NetworkFactory()
private val host = mutableStateOf("localhost")
private val port = mutableStateOf(27030)
val network: State<NetworkPageUio>
@Composable
@Stable
get() {
val player = repository.player.collectAsState()
val status = repository.status.collectAsState()
val type = repository.type.collectAsState()
return remember {
derivedStateOf {
factory.convertToUio(
player = player.value,
status = status.value,
type = type.value,
host = host.value,
port = port.value,
)
}
}
}
fun onPlayerNameChange(player: String) {
repository.onPlayerNameChange(player = player)
}
fun onPortChange(port: String) {
this.port.value = port.toIntOrNull() ?: 0
}
fun onHostChange(host: String) {
this.host.value = host
}
fun host() {
repository.host(port = port.value)
}
fun connect() {
repository.connect(host = host.value, port = port.value)
}
fun disconnect() {
repository.disconnect()
}
}

View file

@ -109,7 +109,7 @@ fun RollPage(
this.alpha = 0.8f
this.rotationZ = viewModel.rollRotation.value
},
tint = MaterialTheme.colors.onSurface,
tint = MaterialTheme.colors.primary,
painter = painterResource(Res.drawable.ic_d20_32dp),
contentDescription = null,
)

View file

@ -10,6 +10,7 @@ import com.pixelized.desktop.lwa.business.RollUseCase
import com.pixelized.desktop.lwa.business.SkillStepUseCase
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheet
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.screen.characterSheet.detail.CharacterSheetPageUio
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
@ -25,6 +26,8 @@ import lwacharactersheet.composeapp.generated.resources.roll_page__success
import org.jetbrains.compose.resources.getString
class RollViewModel : ViewModel() {
private val network = NetworkRepository
private val _roll = mutableStateOf(RollUio(label = "", value = 0))
val roll: State<RollUio> get() = _roll
@ -134,8 +137,14 @@ class RollViewModel : ViewModel() {
} ?: "",
value = roll,
)
share(roll = roll)
}
}
}
}
private fun share(roll: Int) {
network.share(type = "roll", value = "$roll")
}
}

View file

@ -1,21 +1,9 @@
package com.pixelized.desktop.lwa
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
fun main() {
application {
Window(
onCloseRequest = ::exitApplication,
state = rememberWindowState(
width = 320.dp + 64.dp,
height = 900.dp,
),
title = "LwaCharacterSheet",
) {
App()
}
App()
}
}

View file

@ -6,22 +6,27 @@ junit = "4.13.2"
compose-multiplatform = "1.7.0"
androidx-lifecycle = "2.8.3"
androidx-navigation = "2.8.0-alpha10"
[libraries]
# Test
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
# Compose
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-navigation-compose = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-json" }
ktor_version = "3.0.1"
[plugins]
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinKtor = { id = "io.ktor.plugin", version.ref = "ktor_version"}
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
androidx-navigation-compose = { group = "org.jetbrains.androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" }
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-json" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor_version" }
ktor-client-cio = { group = 'io.ktor', name = "ktor-client-cio", version.ref = "ktor_version" }
ktor-client-websockets = { group = 'io.ktor', name = "ktor-client-websockets", version.ref = "ktor_version" }
ktor-server-core = { group = 'io.ktor', name = "ktor-server-core", version.ref = "ktor_version" }
ktor-server-netty = { group = 'io.ktor', name = "ktor-server-netty", version.ref = "ktor_version" }
ktor-server-websockets = { group = 'io.ktor', name = "ktor-server-websockets", version.ref = "ktor_version" }