Add error management for the network client.

This commit is contained in:
Thomas Andres Gomez 2024-11-15 16:43:36 +01:00
parent e880d37275
commit f459877d55
6 changed files with 302 additions and 18 deletions

View file

@ -95,6 +95,8 @@
<string name="network__socket__type_server">Serveur</string> <string name="network__socket__type_server">Serveur</string>
<string name="network__socket__type_client">Client</string> <string name="network__socket__type_client">Client</string>
<string name="network__socket__type_none">Aucun</string> <string name="network__socket__type_none">Aucun</string>
<string name="network__connect__message">Vous êtes connecté au serveur</string>
<string name="network__disconnect__message">Vous êtes déconnecté du serveur</string>
<string name="roll_history__title">Historique des lancers</string> <string name="roll_history__title">Historique des lancers</string>
<string name="roll_history__item__throw">lance</string> <string name="roll_history__item__throw">lance</string>

View file

@ -1,15 +1,30 @@
package com.pixelized.desktop.lwa 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.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.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.SnackbarHost
import androidx.compose.material.SnackbarHostState import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.ApplicationScope
import androidx.compose.ui.window.Window 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.WindowsNavHost
import com.pixelized.desktop.lwa.navigation.window.destination.CharacterSheetCreateWindow import com.pixelized.desktop.lwa.navigation.window.destination.CharacterSheetCreateWindow
import com.pixelized.desktop.lwa.navigation.window.destination.CharacterSheetWindow 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.screen.characterSheet.CharacterSheetMainNavHost
import com.pixelized.desktop.lwa.theme.LwaTheme 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 import org.jetbrains.compose.ui.tooling.preview.Preview
val LocalWindowController = compositionLocalOf<WindowController> { val LocalWindowController = compositionLocalOf<WindowController> {
@ -33,14 +55,20 @@ val LocalSnackHost = compositionLocalOf<SnackbarHostState> {
error("Local Snack Controller is not yet ready") error("Local Snack Controller is not yet ready")
} }
val LocalErrorSnackHost = compositionLocalOf<SnackbarHostState> {
error("Local Snack Controller is not yet ready")
}
@Composable @Composable
@Preview @Preview
fun ApplicationScope.App() { fun ApplicationScope.App() {
val snackHostState = remember { SnackbarHostState() } val snackHostState = remember { SnackbarHostState() }
val errorSnackHostState = remember { SnackbarHostState() }
val windowController = remember { WindowController() } val windowController = remember { WindowController() }
CompositionLocalProvider( CompositionLocalProvider(
LocalSnackHost provides snackHostState, LocalSnackHost provides snackHostState,
LocalErrorSnackHost provides errorSnackHostState,
LocalWindowController provides windowController, LocalWindowController provides windowController,
) { ) {
Window( Window(
@ -57,14 +85,38 @@ fun ApplicationScope.App() {
) { ) {
Scaffold( Scaffold(
snackbarHost = { snackbarHost = {
SnackbarHost( Column(
hostState = snackHostState, 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 = { content = {
MainNavHost() MainNavHost()
} }
) )
NetworkSnackHandler(
snack = snackHostState,
)
WindowsHandler( WindowsHandler(
windowController = windowController, 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,
)
}
}
}
}
}
/**
* <a href="https://material.io/components/snackbars" class="external" target="_blank">Material Design snackbar</a>.
*
* 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 shouldnt interrupt the user experience,
* and they dont 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
)
} }

View file

@ -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<ErrorSnackUio>,
) {
LaunchedEffect(Unit) {
error.collect {
snack.showSnackbar(
message = it.message,
actionLabel = it.action,
duration = it.duration,
)
}
}
}

View file

@ -103,6 +103,9 @@ object NetworkRepository {
fun connect( fun connect(
host: String, host: String,
port: Int, port: Int,
onConnect: (Type) -> Unit = { },
onFailure: (Exception) -> Unit = { },
onClose: () -> Unit = { },
) { ) {
client = client() client = client()
@ -112,7 +115,7 @@ object NetworkRepository {
client?.connectWebSocket(host = host, port = port) { client?.connectWebSocket(host = host, port = port) {
_type.value = Type.CLIENT _type.value = Type.CLIENT
_status.value = Status.CONNECTED _status.value = Status.CONNECTED
println("Client launched") onConnect(Type.CLIENT)
val job = launch { val job = launch {
// send message to the server // send message to the server
@ -133,12 +136,11 @@ object NetworkRepository {
} }
} }
} catch (exception: Exception) { } catch (exception: Exception) {
// TODO onFailure(exception)
println("WebSocket exception: ${exception.localizedMessage}")
} finally { } finally {
println("Client close")
_type.value = Type.NONE _type.value = Type.NONE
_status.value = Status.DISCONNECTED _status.value = Status.DISCONNECTED
onClose()
} }
} }
} }

View file

@ -1,7 +1,15 @@
package com.pixelized.desktop.lwa.screen.network 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.ScrollState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.Scaffold import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton 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.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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 com.pixelized.desktop.lwa.navigation.screen.LocalScreenController
import kotlinx.io.InternalIoApi
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.network__host__label import lwacharactersheet.composeapp.generated.resources.network__host__label
import lwacharactersheet.composeapp.generated.resources.network__player_name__label import lwacharactersheet.composeapp.generated.resources.network__player_name__label
import lwacharactersheet.composeapp.generated.resources.network__port__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__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__socket__host_action
import lwacharactersheet.composeapp.generated.resources.network__title import lwacharactersheet.composeapp.generated.resources.network__title
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
@ -55,20 +72,55 @@ fun NetworkPage(
viewModel: NetworkViewModel = viewModel { NetworkViewModel() }, viewModel: NetworkViewModel = viewModel { NetworkViewModel() },
) { ) {
val screen = LocalScreenController.current val screen = LocalScreenController.current
val snack = LocalSnackHost.current
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
NetworkContent( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
player = viewModel.network, contentAlignment = Alignment.Center,
onBack = { screen.popBackStack() }, ) {
onPlayerChange = viewModel::onPlayerNameChange, BlurContent(
onHostChange = viewModel::onHostChange, modifier = Modifier.fillMaxSize(),
onPortChange = viewModel::onPortChange, controller = viewModel.controller,
onConnect = viewModel::connect, ) {
onHost = viewModel::host, NetworkContent(
onDisconnect = viewModel::disconnect, 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,
) )
} }
} }

View file

@ -8,7 +8,17 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel 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 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() { class NetworkViewModel : ViewModel() {
private val repository = NetworkRepository private val repository = NetworkRepository
@ -17,6 +27,17 @@ class NetworkViewModel : ViewModel() {
private val host = mutableStateOf(NetworkRepository.DEFAULT_HOST) private val host = mutableStateOf(NetworkRepository.DEFAULT_HOST)
private val port = mutableStateOf(NetworkRepository.DEFAULT_PORT) private val port = mutableStateOf(NetworkRepository.DEFAULT_PORT)
private val _networkError = MutableSharedFlow<ErrorSnackUio>()
val networkError: SharedFlow<ErrorSnackUio> get() = _networkError
private val _message = MutableSharedFlow<String>()
val message: SharedFlow<String> get() = _message
private val _isLoading = mutableStateOf(false)
val isLoading: State<Boolean> get() = _isLoading
val controller: BlurContentController = BlurContentController()
val network: State<NetworkPageUio> val network: State<NetworkPageUio>
@Composable @Composable
@Stable @Stable
@ -54,7 +75,24 @@ class NetworkViewModel : ViewModel() {
} }
fun connect() { 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() { fun disconnect() {