diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index eff5a11..523356f 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -95,6 +95,8 @@
Serveur
Client
Aucun
+ Vous êtes connecté au serveur
+ Vous êtes déconnecté du serveur
Historique des lancers
lance
diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/App.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/App.kt
index c455b4d..ccd1628 100644
--- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/App.kt
@@ -1,15 +1,30 @@
package com.pixelized.desktop.lwa
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
+import androidx.compose.material.ScaffoldState
+import androidx.compose.material.SnackbarData
+import androidx.compose.material.SnackbarDefaults
+import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
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.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Window
@@ -21,8 +36,15 @@ import com.pixelized.desktop.lwa.navigation.window.WindowController
import com.pixelized.desktop.lwa.navigation.window.WindowsNavHost
import com.pixelized.desktop.lwa.navigation.window.destination.CharacterSheetCreateWindow
import com.pixelized.desktop.lwa.navigation.window.destination.CharacterSheetWindow
+import com.pixelized.desktop.lwa.repository.network.NetworkRepository
+import com.pixelized.desktop.lwa.repository.network.NetworkRepository.Status
import com.pixelized.desktop.lwa.screen.characterSheet.CharacterSheetMainNavHost
import com.pixelized.desktop.lwa.theme.LwaTheme
+import kotlinx.coroutines.launch
+import lwacharactersheet.composeapp.generated.resources.Res
+import lwacharactersheet.composeapp.generated.resources.network__connect__message
+import lwacharactersheet.composeapp.generated.resources.network__disconnect__message
+import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.ui.tooling.preview.Preview
val LocalWindowController = compositionLocalOf {
@@ -33,14 +55,20 @@ val LocalSnackHost = compositionLocalOf {
error("Local Snack Controller is not yet ready")
}
+val LocalErrorSnackHost = compositionLocalOf {
+ error("Local Snack Controller is not yet ready")
+}
+
@Composable
@Preview
fun ApplicationScope.App() {
val snackHostState = remember { SnackbarHostState() }
+ val errorSnackHostState = remember { SnackbarHostState() }
val windowController = remember { WindowController() }
CompositionLocalProvider(
LocalSnackHost provides snackHostState,
+ LocalErrorSnackHost provides errorSnackHostState,
LocalWindowController provides windowController,
) {
Window(
@@ -57,14 +85,38 @@ fun ApplicationScope.App() {
) {
Scaffold(
snackbarHost = {
- SnackbarHost(
- hostState = snackHostState,
- )
+ Column(
+ modifier = Modifier.padding(all = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(space = 4.dp)
+ ) {
+ SnackbarHost(
+ hostState = snackHostState,
+ snackbar = {
+ Snackbar(
+ snackbarData = it,
+ )
+ }
+ )
+ SnackbarHost(
+ hostState = errorSnackHostState,
+ snackbar = {
+ Snackbar(
+ snackbarData = it,
+ backgroundColor = MaterialTheme.colors.error,
+ contentColor = MaterialTheme.colors.onError,
+ actionColor = MaterialTheme.colors.onError,
+ )
+ }
+ )
+ }
},
content = {
MainNavHost()
}
)
+ NetworkSnackHandler(
+ snack = snackHostState,
+ )
WindowsHandler(
windowController = windowController,
)
@@ -97,4 +149,104 @@ private fun WindowsHandler(
}
}
)
+}
+
+@Composable
+private fun NetworkSnackHandler(
+ snack: SnackbarHostState,
+) {
+ LaunchedEffect(Unit) {
+ launch {
+ var ignoreInitial = true
+ NetworkRepository.status.collect {
+ if (ignoreInitial) {
+ ignoreInitial = false
+ } else {
+ val message = when (it) {
+ Status.CONNECTED -> getString(Res.string.network__connect__message)
+ Status.DISCONNECTED -> getString(Res.string.network__disconnect__message)
+ }
+ snack.showSnackbar(
+ message = message,
+ duration = SnackbarDuration.Short,
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Material Design snackbar.
+ *
+ * Snackbars provide brief messages about app processes at the bottom of the screen.
+ *
+ * Snackbars inform users of a process that an app has performed or will perform. They appear
+ * temporarily, towards the bottom of the screen. They shouldn’t interrupt the user experience,
+ * and they don’t require user input to disappear.
+ *
+ * A Snackbar can contain a single action. Because they disappear automatically, the action
+ * shouldn't be "Dismiss" or "Cancel".
+ *
+ * 
+ *
+ * This version of snackbar is designed to work with [SnackbarData] provided by the
+ * [SnackbarHost], which is usually used inside of the [Scaffold].
+ *
+ * This components provides only the visuals of the [Snackbar]. If you need to show a [Snackbar]
+ * with defaults on the screen, use [ScaffoldState.snackbarHostState] and
+ * [SnackbarHostState.showSnackbar]:
+ *
+ * @sample androidx.compose.material.samples.ScaffoldWithSimpleSnackbar
+ *
+ * If you want to customize appearance of the [Snackbar], you can pass your own version as a child
+ * of the [SnackbarHost] to the [Scaffold]:
+ * @sample androidx.compose.material.samples.ScaffoldWithCustomSnackbar
+ *
+ * @param snackbarData data about the current snackbar showing via [SnackbarHostState]
+ * @param modifier modifiers for the Snackbar layout
+ * @param actionOnNewLine whether or not action should be put on the separate line. Recommended
+ * for action with long action text
+ * @param shape Defines the Snackbar's shape as well as its shadow
+ * @param backgroundColor background color of the Snackbar
+ * @param contentColor color of the content to use inside the snackbar. Defaults to
+ * either the matching content color for [backgroundColor], or, if it is not a color from
+ * the theme, this will keep the same value set above this Surface.
+ * @param actionColor color of the action
+ * @param elevation The z-coordinate at which to place the SnackBar. This controls the size
+ * of the shadow below the SnackBar
+ */
+@Composable
+fun Snackbar(
+ snackbarData: SnackbarData,
+ modifier: Modifier = Modifier,
+ actionOnNewLine: Boolean = false,
+ shape: Shape = MaterialTheme.shapes.small,
+ backgroundColor: Color = SnackbarDefaults.backgroundColor,
+ contentColor: Color = MaterialTheme.colors.surface,
+ actionColor: Color = SnackbarDefaults.primaryActionColor,
+ elevation: Dp = 6.dp
+) {
+ val actionLabel = snackbarData.actionLabel
+ val actionComposable: (@Composable () -> Unit)? = if (actionLabel != null) {
+ @Composable {
+ TextButton(
+ colors = ButtonDefaults.textButtonColors(contentColor = actionColor),
+ onClick = { snackbarData.performAction() },
+ content = { Text(actionLabel) }
+ )
+ }
+ } else {
+ null
+ }
+ androidx.compose.material.Snackbar(
+ modifier = modifier,
+ content = { Text(snackbarData.message) },
+ action = actionComposable,
+ actionOnNewLine = actionOnNewLine,
+ shape = shape,
+ backgroundColor = backgroundColor,
+ contentColor = contentColor,
+ elevation = elevation
+ )
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/composable/error/snack/ErrorSnackUio.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/composable/error/snack/ErrorSnackUio.kt
new file mode 100644
index 0000000..b7f756f
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/composable/error/snack/ErrorSnackUio.kt
@@ -0,0 +1,38 @@
+package com.pixelized.desktop.lwa.composable.error.snack
+
+import androidx.compose.material.SnackbarDuration
+import androidx.compose.material.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+import com.pixelized.desktop.lwa.LocalErrorSnackHost
+import kotlinx.coroutines.flow.SharedFlow
+
+@Stable
+class ErrorSnackUio(
+ val message: String,
+ val action: String?,
+ val duration: SnackbarDuration,
+) {
+ constructor(exception: Exception) : this(
+ message = exception.localizedMessage,
+ action = "Ok",
+ duration = SnackbarDuration.Indefinite,
+ )
+}
+
+@Composable
+fun ErrorSnack(
+ snack: SnackbarHostState = LocalErrorSnackHost.current,
+ error: SharedFlow,
+) {
+ LaunchedEffect(Unit) {
+ error.collect {
+ snack.showSnackbar(
+ message = it.message,
+ actionLabel = it.action,
+ duration = it.duration,
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt
index 011b704..d5221b6 100644
--- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt
@@ -103,6 +103,9 @@ object NetworkRepository {
fun connect(
host: String,
port: Int,
+ onConnect: (Type) -> Unit = { },
+ onFailure: (Exception) -> Unit = { },
+ onClose: () -> Unit = { },
) {
client = client()
@@ -112,7 +115,7 @@ object NetworkRepository {
client?.connectWebSocket(host = host, port = port) {
_type.value = Type.CLIENT
_status.value = Status.CONNECTED
- println("Client launched")
+ onConnect(Type.CLIENT)
val job = launch {
// send message to the server
@@ -133,12 +136,11 @@ object NetworkRepository {
}
}
} catch (exception: Exception) {
- // TODO
- println("WebSocket exception: ${exception.localizedMessage}")
+ onFailure(exception)
} finally {
- println("Client close")
_type.value = Type.NONE
_status.value = Status.DISCONNECTED
+ onClose()
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkPage.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkPage.kt
index 55a04b1..9c26321 100644
--- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkPage.kt
+++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkPage.kt
@@ -1,7 +1,15 @@
package com.pixelized.desktop.lwa.screen.network
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.SizeTransform
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -9,12 +17,15 @@ 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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold
+import androidx.compose.material.SnackbarDuration
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
@@ -23,19 +34,25 @@ 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.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
+import androidx.compose.ui.Alignment
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.LocalSnackHost
+import com.pixelized.desktop.lwa.composable.blur.BlurContent
+import com.pixelized.desktop.lwa.composable.error.snack.ErrorSnack
import com.pixelized.desktop.lwa.navigation.screen.LocalScreenController
+import kotlinx.io.InternalIoApi
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__disconnect_action
import lwacharactersheet.composeapp.generated.resources.network__socket__host_action
import lwacharactersheet.composeapp.generated.resources.network__title
import org.jetbrains.compose.resources.stringResource
@@ -55,20 +72,55 @@ fun NetworkPage(
viewModel: NetworkViewModel = viewModel { NetworkViewModel() },
) {
val screen = LocalScreenController.current
+ val snack = LocalSnackHost.current
Surface(
modifier = Modifier.fillMaxSize(),
) {
- NetworkContent(
+ Box(
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,
+ contentAlignment = Alignment.Center,
+ ) {
+ BlurContent(
+ modifier = Modifier.fillMaxSize(),
+ controller = viewModel.controller,
+ ) {
+ 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,
+ )
+ }
+
+ AnimatedContent(
+ modifier = Modifier.size(size = 64.dp),
+ targetState = viewModel.isLoading.value,
+ transitionSpec = { fadeIn() togetherWith fadeOut() },
+ ) {
+ when (it) {
+ true -> CircularProgressIndicator()
+ else -> Box(modifier = Modifier)
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ viewModel.message.collect {
+ snack.showSnackbar(
+ message = it,
+ duration = SnackbarDuration.Short,
+ )
+ }
+ }
+
+ ErrorSnack(
+ error = viewModel.networkError,
)
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkViewModel.kt
index c1b45d8..85c2d8f 100644
--- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/screen/network/NetworkViewModel.kt
@@ -8,7 +8,17 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.pixelized.desktop.lwa.composable.blur.BlurContentController
+import com.pixelized.desktop.lwa.composable.error.snack.ErrorSnackUio
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+import lwacharactersheet.composeapp.generated.resources.Res
+import lwacharactersheet.composeapp.generated.resources.network__connect__message
+import lwacharactersheet.composeapp.generated.resources.network__disconnect__message
+import org.jetbrains.compose.resources.getString
class NetworkViewModel : ViewModel() {
private val repository = NetworkRepository
@@ -17,6 +27,17 @@ class NetworkViewModel : ViewModel() {
private val host = mutableStateOf(NetworkRepository.DEFAULT_HOST)
private val port = mutableStateOf(NetworkRepository.DEFAULT_PORT)
+ private val _networkError = MutableSharedFlow()
+ val networkError: SharedFlow get() = _networkError
+
+ private val _message = MutableSharedFlow()
+ val message: SharedFlow get() = _message
+
+ private val _isLoading = mutableStateOf(false)
+ val isLoading: State get() = _isLoading
+
+ val controller: BlurContentController = BlurContentController()
+
val network: State
@Composable
@Stable
@@ -54,7 +75,24 @@ class NetworkViewModel : ViewModel() {
}
fun connect() {
- repository.connect(host = host.value, port = port.value)
+ controller.show()
+ _isLoading.value = true
+
+ repository.connect(
+ host = host.value,
+ port = port.value,
+ onConnect = {
+ _isLoading.value = false
+ controller.hide()
+ },
+ onFailure = {
+ _isLoading.value = false
+ controller.hide()
+ viewModelScope.launch {
+ _networkError.emit(ErrorSnackUio(it))
+ }
+ },
+ )
}
fun disconnect() {