Add error management for the network client.
This commit is contained in:
parent
e880d37275
commit
f459877d55
6 changed files with 302 additions and 18 deletions
|
|
@ -95,6 +95,8 @@
|
|||
<string name="network__socket__type_server">Serveur</string>
|
||||
<string name="network__socket__type_client">Client</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__item__throw">lance</string>
|
||||
|
|
|
|||
|
|
@ -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<WindowController> {
|
||||
|
|
@ -33,14 +55,20 @@ val LocalSnackHost = compositionLocalOf<SnackbarHostState> {
|
|||
error("Local Snack Controller is not yet ready")
|
||||
}
|
||||
|
||||
val LocalErrorSnackHost = compositionLocalOf<SnackbarHostState> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <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 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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>
|
||||
@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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue