From f459877d558d6a182d6a30895b8b07eb18d605dd Mon Sep 17 00:00:00 2001 From: Thomas Andres Gomez Date: Fri, 15 Nov 2024 16:43:36 +0100 Subject: [PATCH] Add error management for the network client. --- .../composeResources/values/strings.xml | 2 + .../kotlin/com/pixelized/desktop/lwa/App.kt | 158 +++++++++++++++++- .../composable/error/snack/ErrorSnackUio.kt | 38 +++++ .../repository/network/NetworkRepository.kt | 10 +- .../desktop/lwa/screen/network/NetworkPage.kt | 72 ++++++-- .../lwa/screen/network/NetworkViewModel.kt | 40 ++++- 6 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/composable/error/snack/ErrorSnackUio.kt 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". + * + * ![Snackbars image](https://developer.android.com/images/reference/androidx/compose/material/snackbars.png) + * + * 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() {