Add a basic version of the GM screen.

This commit is contained in:
Thomas Andres Gomez 2025-03-15 10:24:55 +01:00
parent 35396b6069
commit 6b86a6c075
42 changed files with 969 additions and 784 deletions

View file

@ -177,6 +177,15 @@
<string name="level_up__character_level_description">Passage du niveau %1$d &#x25B8; %2$d</string>
<string name="level_up__skill_level">niv : %1$d -</string>
<string name="game_master__character_level__label">niv: %1$d</string>
<string name="game_master__character_tag__character_search">joueur</string>
<string name="game_master__character_tag__character_label">joueur: %1$d</string>
<string name="game_master__character_tag__npc_search">npc</string>
<string name="game_master__character_tag__npc_label">npc: %1$d</string>
<string name="game_master__character_action__display_portrait">Afficher le portrait</string>
<string name="game_master__character_action__add_to_group">Ajouter au groupe</string>
<string name="game_master__character_action__remove_from_group">Retirer du groupe (id: %1$d)</string>
<string name="game_master__character_action__add_to_npc">Ajouter aux Npcs</string>
<string name="game_master__character_action__remove_from_npc">Retirer des Npcs (id: %1$d)</string>
</resources>

View file

@ -47,19 +47,15 @@ import com.pixelized.desktop.lwa.ui.navigation.window.WindowController
import com.pixelized.desktop.lwa.ui.navigation.window.WindowsNavHost
import com.pixelized.desktop.lwa.ui.navigation.window.destination.CharacterSheetEditWindow
import com.pixelized.desktop.lwa.ui.navigation.window.destination.CharacterSheetWindow
import com.pixelized.desktop.lwa.ui.navigation.window.destination.NetworkWindows
import com.pixelized.desktop.lwa.ui.navigation.window.destination.GameMasterWindow
import com.pixelized.desktop.lwa.ui.navigation.window.destination.RollHistoryWindow
import com.pixelized.desktop.lwa.ui.navigation.window.rememberMaxWindowHeight
import com.pixelized.desktop.lwa.ui.overlay.roll.RollHostState
import com.pixelized.desktop.lwa.ui.overlay.roll.RollOverlay
import com.pixelized.desktop.lwa.ui.screen.campaign.CampaignViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon
import com.pixelized.desktop.lwa.ui.screen.characterSheet.CharacterSheetMainNavHost
import com.pixelized.desktop.lwa.ui.screen.network.NetworkPage
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterScreen
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryPage
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel
import com.pixelized.desktop.lwa.ui.theme.LwaTheme
import com.pixelized.desktop.lwa.utils.InstallCoil
import kotlinx.coroutines.launch
@ -153,10 +149,6 @@ fun ApplicationScope.LwaApplication() {
@Composable
private fun MainWindowScreen(
dataSyncViewModel: DataSyncViewModel = koinViewModel(),
networkViewModel: NetworkViewModel = koinViewModel(),
campaignViewModel: CampaignViewModel = koinViewModel(),
campaignChatViewModel: CampaignChatViewModel = koinViewModel(),
rollViewModel: RollHistoryViewModel = koinViewModel(),
) {
LaunchedEffect(Unit) {
dataSyncViewModel.autoConnect()
@ -221,7 +213,6 @@ private fun MainWindowScreen(
)
WindowsHandler(
windowController = windowController,
rollViewModel = rollViewModel,
)
}
}
@ -230,7 +221,6 @@ private fun MainWindowScreen(
@Composable
private fun WindowsHandler(
windowController: WindowController,
rollViewModel: RollHistoryViewModel = koinViewModel(),
) {
WindowsNavHost(
controller = windowController,
@ -248,11 +238,9 @@ private fun WindowsHandler(
),
)
is RollHistoryWindow -> RollHistoryPage(
viewModel = rollViewModel,
)
is RollHistoryWindow -> RollHistoryPage()
is NetworkWindows -> NetworkPage()
is GameMasterWindow -> GameMasterScreen()
}
}
)

View file

@ -16,7 +16,7 @@ import com.pixelized.desktop.lwa.repository.settings.SettingsStore
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterDetailCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogFactory
import com.pixelized.desktop.lwa.ui.overlay.roll.RollViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.CampaignViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbarViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.TextMessageFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailFactory
@ -31,9 +31,11 @@ import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.CharacterSheetEdi
import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.common.SkillFieldFactory
import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpFactory
import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpViewModel
import com.pixelized.desktop.lwa.ui.screen.main.MainPageViewModel
import com.pixelized.desktop.lwa.ui.screen.network.NetworkFactory
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkFactory
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterActionUseCase
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterViewModel
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel
import com.pixelized.desktop.lwa.ui.screen.settings.SettingsViewModel
import com.pixelized.desktop.lwa.usecase.SettingsUseCase
@ -112,13 +114,13 @@ val factoryDependencies
factoryOf(::CharacterSheetCharacteristicDialogFactory)
factoryOf(::TextMessageFactory)
factoryOf(::LevelUpFactory)
factoryOf(::GameMasterFactory)
}
val viewModelDependencies
get() = module {
viewModelOf(::DataSyncViewModel)
viewModelOf(::CampaignViewModel)
viewModelOf(::MainPageViewModel)
viewModelOf(::CampaignToolbarViewModel)
viewModelOf(::CharacterSheetViewModel)
viewModelOf(::CharacterSheetEditViewModel)
viewModelOf(::RollViewModel)
@ -131,9 +133,11 @@ val viewModelDependencies
viewModelOf(::CampaignChatViewModel)
viewModelOf(::SettingsViewModel)
viewModelOf(::LevelUpViewModel)
viewModelOf(::GameMasterViewModel)
}
val useCaseDependencies
get() = module {
factoryOf(::SettingsUseCase)
factoryOf(::GameMasterActionUseCase)
}

View file

@ -17,11 +17,11 @@ interface LwaClient {
suspend fun campaign(): CampaignJson
suspend fun campaignAddCharacter(characterSheetId: String, instanceId: Int)
suspend fun campaignAddCharacter(characterSheetId: String)
suspend fun campaignDeleteCharacter(characterSheetId: String, instanceId: Int)
suspend fun campaignAddNpc(characterSheetId: String, instanceId: Int)
suspend fun campaignAddNpc(characterSheetId: String)
suspend fun campaignDeleteNpc(characterSheetId: String, instanceId: Int)

View file

@ -45,9 +45,8 @@ class LwaClientImpl(
override suspend fun campaignAddCharacter(
characterSheetId: String,
instanceId: Int,
) = client
.put("$root/campaign/character/update?characterSheetId=$characterSheetId&instanceId=$instanceId")
.put("$root/campaign/character/update?characterSheetId=$characterSheetId")
.body<Unit>()
override suspend fun campaignDeleteCharacter(
@ -59,9 +58,8 @@ class LwaClientImpl(
override suspend fun campaignAddNpc(
characterSheetId: String,
instanceId: Int,
) = client
.put("$root/campaign/npc/update?characterSheetId=$characterSheetId&instanceId=$instanceId")
.put("$root/campaign/npc/update?characterSheetId=$characterSheetId")
.body<Unit>()
override suspend fun campaignDeleteNpc(

View file

@ -40,4 +40,32 @@ class CampaignRepository(
): Campaign.CharacterInstance {
return campaignFlow.value.character(characterInstanceId)
}
suspend fun addCharacter(
characterSheetId: String,
) = store.addCharacter(
characterSheetId = characterSheetId,
)
suspend fun removeCharacter(
characterSheetId: String,
instanceId: Int,
) = store.removeCharacter(
characterSheetId = characterSheetId,
instanceId = instanceId,
)
suspend fun addNpc(
characterSheetId: String,
) = store.addNpc(
characterSheetId = characterSheetId,
)
suspend fun removeNpc(
characterSheetId: String,
instanceId: Int,
) = store.removeNpc(
characterSheetId = characterSheetId,
instanceId = instanceId,
)
}

View file

@ -3,8 +3,8 @@ package com.pixelized.desktop.lwa.repository.campaign
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.model.campaign.character
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
import com.pixelized.shared.lwa.model.campaign.npc
import com.pixelized.shared.lwa.protocol.websocket.Message
import com.pixelized.shared.lwa.protocol.websocket.payload.CampaignMessage
@ -47,6 +47,42 @@ class CampaignStore(
return data
}
suspend fun addCharacter(
characterSheetId: String,
) {
client.campaignAddCharacter(
characterSheetId = characterSheetId
)
}
suspend fun removeCharacter(
characterSheetId: String,
instanceId: Int,
) {
client.campaignDeleteCharacter(
characterSheetId = characterSheetId,
instanceId = instanceId,
)
}
suspend fun addNpc(
characterSheetId: String,
) {
client.campaignAddNpc(
characterSheetId = characterSheetId
)
}
suspend fun removeNpc(
characterSheetId: String,
instanceId: Int,
) {
client.campaignDeleteNpc(
characterSheetId = characterSheetId,
instanceId = instanceId,
)
}
// region : WebSocket message Handling.
private suspend fun handleMessage(message: Message) {

View file

@ -1,7 +1,7 @@
package com.pixelized.desktop.lwa.repository.campaign.model
data class CharacterSheetPreview(
val id: String,
val characterSheetId: String,
val name: String,
val level: Int,
)

View file

@ -19,7 +19,7 @@ class CharacterSheetRepository(
val characterDetailFlow get() = store.detailFlow
fun characterPreview(characterId: String?): CharacterSheetPreview? {
return characterSheetPreviewFlow.value.firstOrNull { it.id == characterId }
return characterSheetPreviewFlow.value.firstOrNull { it.characterSheetId == characterId }
}
suspend fun characterDetail(

View file

@ -46,7 +46,7 @@ class CharacterSheetStore(
val request = client.characters()
val data = request.map {
CharacterSheetPreview(
id = it.id,
characterSheetId = it.id,
name = it.name,
level = it.level,
)
@ -105,7 +105,7 @@ class CharacterSheetStore(
is RestSynchronisation.CharacterDelete -> {
_previewFlow.value = previewFlow.value.toMutableList()
.also { sheets -> sheets.removeIf { it.id == payload.characterId } }
.also { sheets -> sheets.removeIf { it.characterSheetId == payload.characterId } }
_detailFlow.delete(payload.characterId)
}

View file

@ -21,6 +21,7 @@ class SettingsFactory(
autoHideDelay = settings.autoHideDelay,
autoShowChat = settings.autoShowChat,
autoScrollChat = settings.autoScrollChat,
isGM = settings.isGM,
)
}
@ -45,6 +46,7 @@ class SettingsFactory(
autoHideDelay = json.autoHideDelay ?: default.autoHideDelay,
autoShowChat = json.autoShowChat ?: default.autoShowChat,
autoScrollChat = json.autoScrollChat ?: default.autoScrollChat,
isGM = json.isGM ?: default.isGM,
)
}
}

View file

@ -9,6 +9,7 @@ data class Settings(
val autoHideDelay: Int,
val autoShowChat: Boolean,
val autoScrollChat: Boolean,
val isGM: Boolean,
) {
val root: String get() = "http://${"${host}:${port}".removePrefix("http://")}"
}

View file

@ -12,4 +12,5 @@ data class SettingsJsonV1(
val autoHideDelay: Int?,
val autoShowChat: Boolean?,
val autoScrollChat: Boolean?,
val isGM: Boolean?,
) : SettingsJson

View file

@ -0,0 +1,88 @@
package com.pixelized.desktop.lwa.ui.composable.textfield
import androidx.compose.foundation.layout.height
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.utils.rememberKeyboardActions
import kotlinx.coroutines.flow.StateFlow
@Stable
data class LwaTextFieldUio(
val enable: Boolean,
val labelFlow: StateFlow<String?>,
val valueFlow: StateFlow<String>,
val placeHolderFlow: StateFlow<String?>,
val onValueChange: (String) -> Unit,
)
@Composable
fun LwaTextField(
modifier: Modifier = Modifier,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
singleLine: Boolean = true,
field: LwaTextFieldUio,
) {
val focus = LocalFocusManager.current
val colorScheme = MaterialTheme.colors
val localModifier = if (singleLine) {
Modifier.height(height = 56.dp)
} else {
Modifier
}
val label = field.labelFlow.collectAsState()
val value = field.valueFlow.collectAsState()
val placeHolder = field.placeHolderFlow.collectAsState()
TextField(
modifier = localModifier.then(other = modifier),
colors = TextFieldDefaults.textFieldColors(
backgroundColor = remember(field.enable) {
when (field.enable) {
true -> colorScheme.onSurface.copy(alpha = 0.03f)
else -> colorScheme.surface
}
},
),
keyboardActions = rememberKeyboardActions {
focus.moveFocus(FocusDirection.Next)
},
enabled = field.enable,
singleLine = singleLine,
placeholder = placeHolder.value?.let {
{
Text(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = it
)
}
},
label = label.value?.let {
{
Text(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = it
)
}
},
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
onValueChange = { field.onValueChange(it) },
value = value.value,
)
}

View file

@ -9,12 +9,7 @@ import androidx.navigation.compose.rememberNavController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.MainDestination
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.composableLevelUp
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.composableMainPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.composableNetworkPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.composableOldMainPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.composableSettingsPage
import com.pixelized.desktop.lwa.ui.screen.campaign.CampaignViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.chat.CampaignChatViewModel
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
val LocalScreenController = compositionLocalOf<NavHostController> {
error("MainNavHost controller is not yet ready")
@ -35,9 +30,6 @@ fun MainNavHost(
composableMainPage()
composableSettingsPage()
composableLevelUp()
composableNetworkPage()
composableOldMainPage()
}
}
}

View file

@ -1,29 +0,0 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.ui.screen.network.NetworkScreen
@Deprecated(message = "Part of the old UI")
object NetworkDestination {
private const val ROUTE = "network"
fun baseRoute() = ROUTE
fun navigationRoute() = ROUTE
}
@Deprecated(message = "Part of the old UI")
fun NavGraphBuilder.composableNetworkPage() {
composable(
route = NetworkDestination.baseRoute(),
) {
NetworkScreen()
}
}
@Deprecated(message = "Part of the old UI")
fun NavHostController.navigateToNetwork() {
val route = NetworkDestination.navigationRoute()
navigate(route = route)
}

View file

@ -1,29 +0,0 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.ui.screen.main.OldMainPage
@Deprecated(message = "Part of the old UI")
object OldMainDestination {
private const val ROUTE = "old_main"
fun baseRoute() = ROUTE
fun navigationRoute() = ROUTE
}
@Deprecated(message = "Part of the old UI")
fun NavGraphBuilder.composableOldMainPage() {
composable(
route = OldMainDestination.baseRoute(),
) {
OldMainPage()
}
}
@Deprecated(message = "Part of the old UI")
fun NavHostController.navigateToOldMainPage() {
val route = OldMainDestination.navigationRoute()
navigate(route = route)
}

View file

@ -6,7 +6,7 @@ import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.navigation.window.WindowController
@Stable
class NetworkWindows(
class GameMasterWindow(
title: String,
size: DpSize,
) : Window(
@ -14,14 +14,15 @@ class NetworkWindows(
size = size,
)
fun WindowController.navigateToNetwork(
title: String = "",
fun WindowController.navigateToGameMasterWindow(
title: String = "Game master",
) {
showWindow(
window = NetworkWindows(
title = title, size = DpSize(
width = 464.dp,
height = 300.dp,
window = GameMasterWindow(
title = title,
size = DpSize(
width = 400.dp + 64.dp,
height = maxWindowHeight - 32.dp,
)
)
)

View file

@ -19,7 +19,8 @@ fun WindowController.navigateToRollHistory(
) {
showWindow(
window = RollHistoryWindow(
title = title, size = DpSize(
title = title,
size = DpSize(
width = 400.dp + 64.dp,
height = maxWindowHeight,
)

View file

@ -42,10 +42,8 @@ import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDimin
import com.pixelized.desktop.lwa.ui.screen.campaign.player.ribbon.PlayerRibbon
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbar
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.overlay.roll.RollHostState
import com.pixelized.desktop.lwa.ui.overlay.roll.RollOverlay
import com.pixelized.desktop.lwa.ui.overlay.roll.RollViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.toolbar.CampaignToolbarViewModel
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
@ -58,7 +56,7 @@ fun CampaignScreen(
characterDetailViewModel: CharacterDetailViewModel = koinViewModel(),
characteristicDialogViewModel: CharacterDetailCharacteristicDialogViewModel = koinViewModel(),
dismissedViewModel: CharacterDiminishedViewModel = koinViewModel(),
campaignViewModel: CampaignViewModel = koinViewModel(),
campaignViewModel: CampaignToolbarViewModel = koinViewModel(),
networkViewModel: NetworkViewModel = koinViewModel(),
campaignChatViewModel: CampaignChatViewModel = koinViewModel(),
) {
@ -88,8 +86,7 @@ fun CampaignScreen(
modifier = Modifier.fillMaxSize(),
top = {
CampaignToolbar(
campaignViewModel = campaignViewModel,
networkViewModel = networkViewModel,
viewModel = campaignViewModel,
)
},
bottom = {

View file

@ -1,58 +0,0 @@
package com.pixelized.desktop.lwa.ui.screen.campaign
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class CampaignViewModel(
private val characterRepository: CharacterSheetRepository,
private val alterationRepository: AlterationRepository,
private val campaignRepository: CampaignRepository,
private val network: NetworkRepository,
) : ViewModel() {
val title: Flow<String> = campaignRepository.campaignFlow
.map { it.scene.name }
val networkStatus = network.status
fun init() {
viewModelScope.launch {
launch {
network.status.collect { status ->
if (status == NetworkRepository.Status.CONNECTED) {
campaignRepository.update()
}
}
}
launch {
combine(
network.status,
campaignRepository.campaignFlow,
) { status, campaign ->
status to campaign
}.collectLatest { (status, campaign) ->
if (status == NetworkRepository.Status.CONNECTED) {
campaign.characters.keys.forEach { id ->
characterRepository.characterDetail(
characterSheetId = id.characterSheetId,
forceUpdate = true,
)
alterationRepository.updateActiveAlterations(
characterInstanceId = id,
)
}
}
}
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.network
package com.pixelized.desktop.lwa.ui.screen.campaign.network
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@ -27,15 +27,11 @@ import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
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
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.LaunchedEffect
import androidx.compose.runtime.Stable
@ -44,12 +40,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.LocalSnackHost
import com.pixelized.desktop.lwa.ui.composable.blur.BlurContent
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnack
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
@ -59,7 +53,6 @@ import lwacharactersheet.composeapp.generated.resources.network__player_name__la
import lwacharactersheet.composeapp.generated.resources.network__port__label
import lwacharactersheet.composeapp.generated.resources.network__socket__connect_action
import lwacharactersheet.composeapp.generated.resources.network__socket__disconnect_action
import lwacharactersheet.composeapp.generated.resources.network__title
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@ -100,93 +93,7 @@ data class NetworkPageUio(
}
@Composable
fun NetworkScreen(
viewModel: NetworkViewModel = koinViewModel(),
) {
val screen = LocalScreenController.current
val snack = LocalSnackHost.current
val scope = rememberCoroutineScope()
Surface(
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
BlurContent(
modifier = Modifier.fillMaxSize(),
controller = viewModel.blurController,
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = {
Text(
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = stringResource(Res.string.network__title),
)
},
navigationIcon = {
IconButton(
onClick = { screen.popBackStack() },
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
}
)
},
content = { paddingValues ->
NetworkContent(
modifier = Modifier.fillMaxSize(),
paddingValues = paddingValues,
network = viewModel.network.collectAsState(),
onPlayerChange = viewModel::onPlayerNameChange,
onHostChange = viewModel::onHostChange,
onResetPortChange = viewModel::onResetPortChange,
onPortChange = viewModel::onPortChange,
onResetHostChange = viewModel::onResetHostChange,
onConnect = { scope.launch { viewModel.connect() } },
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,
)
}
}
@Composable
fun NetworkPage(
fun NetworkDialog(
modifier: Modifier = Modifier,
viewModel: NetworkViewModel = koinViewModel(),
) {

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.network
package com.pixelized.desktop.lwa.ui.screen.campaign.network
import com.pixelized.desktop.lwa.repository.network.NetworkRepository.Status

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.network
package com.pixelized.desktop.lwa.ui.screen.campaign.network
import androidx.compose.material.SnackbarDuration
import androidx.compose.runtime.State

View file

@ -1,13 +1,18 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.toolbar
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
@ -17,33 +22,32 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.LocalWindowController
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToOldMainPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToSettings
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToGameMasterWindow
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToRollHistory
import com.pixelized.desktop.lwa.ui.screen.campaign.CampaignViewModel
import com.pixelized.desktop.lwa.ui.screen.network.NetworkPage
import com.pixelized.desktop.lwa.ui.screen.network.NetworkViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.network.NetworkDialog
import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_settings_24dp
import lwacharactersheet.composeapp.generated.resources.ic_table_24dp
import lwacharactersheet.composeapp.generated.resources.ic_timeline_24dp
import lwacharactersheet.composeapp.generated.resources.ic_wifi_24dp
import lwacharactersheet.composeapp.generated.resources.ic_wifi_off_24dp
import lwacharactersheet.composeapp.generated.resources.main_page__roll_history_action
import lwacharactersheet.composeapp.generated.resources.settings__title
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun CampaignToolbar(
campaignViewModel: CampaignViewModel = koinViewModel(),
networkViewModel: NetworkViewModel = koinViewModel(),
viewModel: CampaignToolbarViewModel = koinViewModel(),
) {
val windows = LocalWindowController.current
val screen = LocalScreenController.current
@ -51,60 +55,31 @@ fun CampaignToolbar(
val isOverflowMenuOpen = remember { mutableStateOf(false) }
val isNetworkMenuOpen = remember { mutableStateOf(false) }
val title = viewModel.title.collectAsState()
val status = viewModel.status.collectAsState()
val isGM = viewModel.isGM.collectAsState()
CampaignToolbarContent(
title = campaignViewModel.title.collectAsState(initial = ""),
networkStatus = campaignViewModel.networkStatus.collectAsState(),
title = title,
status = status,
isGM = isGM,
isNetworkMenuOpen = isNetworkMenuOpen,
isOverflowMenuOpen = isOverflowMenuOpen,
networkMenu = {
NetworkPage(
modifier = Modifier.size(384.dp + 96.dp, 240.dp),
viewModel = networkViewModel
)
},
overflowMenu = {
DropdownMenuItem(
onClick = {
isOverflowMenuOpen.value = false
windows.navigateToRollHistory()
},
) {
Icon(
painter = painterResource(Res.drawable.ic_timeline_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
Text(
modifier = Modifier.padding(start = 8.dp),
color = MaterialTheme.colors.primary,
text = stringResource(Res.string.main_page__roll_history_action),
)
}
DropdownMenuItem(
onClick = {
isOverflowMenuOpen.value = false
screen.navigateToOldMainPage()
},
) {
Icon(
painter = painterResource(Res.drawable.ic_table_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
Text(
modifier = Modifier.padding(start = 8.dp),
color = MaterialTheme.colors.primary,
text = "Ancienne interface utilisateur",
)
}
onGM = {
windows.navigateToGameMasterWindow()
},
onNetwork = {
isNetworkMenuOpen.value = true
},
onOverflow = {
isOverflowMenuOpen.value = isOverflowMenuOpen.value.not()
isOverflowMenuOpen.value = true
},
onRollHistory = {
isOverflowMenuOpen.value = false
windows.navigateToRollHistory()
},
onSettings = {
isOverflowMenuOpen.value = false
screen.navigateToSettings()
},
onDismissNetworkMenu = {
@ -120,13 +95,14 @@ fun CampaignToolbar(
private fun CampaignToolbarContent(
modifier: Modifier = Modifier,
title: State<String>,
networkStatus: State<NetworkRepository.Status>,
status: State<NetworkRepository.Status>,
isGM: State<Boolean>,
isNetworkMenuOpen: State<Boolean>,
isOverflowMenuOpen: State<Boolean>,
networkMenu: @Composable () -> Unit,
overflowMenu: @Composable () -> Unit,
onGM: () -> Unit,
onNetwork: () -> Unit,
onOverflow: () -> Unit,
onRollHistory: () -> Unit,
onSettings: () -> Unit,
onDismissNetworkMenu: () -> Unit,
onDismissOverflowMenu: () -> Unit,
@ -139,38 +115,38 @@ private fun CampaignToolbarContent(
)
},
actions = {
AnimatedVisibility(
visible = isGM.value,
enter = fadeIn(),
exit = fadeOut(),
) {
TextButton(
modifier = Modifier.size(size = 48.dp).clip(shape = CircleShape),
onClick = onGM,
) {
Text(
fontWeight = FontWeight.SemiBold,
text = "GM",
)
}
}
IconButton(
onClick = onNetwork
onClick = onNetwork,
) {
Icon(
painter = painterResource(
when (networkStatus.value) {
when (status.value) {
NetworkRepository.Status.CONNECTED -> Res.drawable.ic_wifi_24dp
NetworkRepository.Status.DISCONNECTED -> Res.drawable.ic_wifi_off_24dp
}
),
tint = when (networkStatus.value) {
tint = when (status.value) {
NetworkRepository.Status.CONNECTED -> MaterialTheme.lwa.colorScheme.base.primary
NetworkRepository.Status.DISCONNECTED -> MaterialTheme.lwa.colorScheme.base.error
},
contentDescription = null,
)
}
DropdownMenu(
offset = remember { DpOffset(x = -(48.dp * 2 + 8.dp), y = 8.dp) },
expanded = isNetworkMenuOpen.value,
onDismissRequest = onDismissNetworkMenu,
content = { networkMenu() },
)
IconButton(
onClick = onSettings
) {
Icon(
painter = painterResource(Res.drawable.ic_settings_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
}
IconButton(
onClick = onOverflow,
) {
@ -180,11 +156,50 @@ private fun CampaignToolbarContent(
contentDescription = null,
)
}
DropdownMenu(
offset = remember { DpOffset(x = -(48.dp + 8.dp), y = 8.dp) },
expanded = isNetworkMenuOpen.value,
onDismissRequest = onDismissNetworkMenu,
content = {
NetworkDialog(
modifier = Modifier.size(384.dp + 96.dp, 240.dp),
)
},
)
DropdownMenu(
offset = remember { DpOffset(x = (-8).dp, y = 8.dp) },
expanded = isOverflowMenuOpen.value,
onDismissRequest = onDismissOverflowMenu,
content = { overflowMenu() },
content = {
DropdownMenuItem(
onClick = onRollHistory,
) {
Icon(
painter = painterResource(Res.drawable.ic_timeline_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
Text(
modifier = Modifier.padding(start = 8.dp),
color = MaterialTheme.colors.primary,
text = stringResource(Res.string.main_page__roll_history_action),
)
}
DropdownMenuItem(
onClick = onSettings,
) {
Icon(
painter = painterResource(Res.drawable.ic_settings_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
Text(
modifier = Modifier.padding(start = 8.dp),
color = MaterialTheme.colors.primary,
text = stringResource(Res.string.settings__title),
)
}
},
)
},
)

View file

@ -0,0 +1,35 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.toolbar
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class CampaignToolbarViewModel(
campaignRepository: CampaignRepository,
networkRepository: NetworkRepository,
settingsRepository: SettingsRepository,
) : ViewModel() {
val status = networkRepository.status
val title = campaignRepository.campaignFlow
.map { it.scene.name }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = "",
)
val isGM = settingsRepository.settingsFlow()
.map { it.isGM }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = false,
)
}

View file

@ -94,7 +94,7 @@ class CharacterSheetViewModel(
characterId = argument.characterInstanceId.characterSheetId
) ?: return
_displayDeleteConfirmationDialog.value = CharacterSheetDeleteConfirmationDialogUio(
id = preview.id,
id = preview.characterSheetId,
name = preview.name,
)
}

View file

@ -0,0 +1,35 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio.Action
class GameMasterActionUseCase(
private val campaignRepository: CampaignRepository,
) {
suspend fun handleAction(
characterSheetId: String,
action: Action,
) {
when (action) {
Action.DisplayPortrait -> TODO()
Action.AddToGroup -> campaignRepository.addCharacter(
characterSheetId = characterSheetId,
)
Action.AddToNpc -> campaignRepository.addNpc(
characterSheetId = characterSheetId,
)
is Action.RemoveFromGroup -> campaignRepository.removeCharacter(
characterSheetId = characterSheetId,
instanceId = action.instanceId
)
is Action.RemoveFromNpc -> campaignRepository.removeNpc(
characterSheetId = characterSheetId,
instanceId = action.instanceId
)
}
}
}

View file

@ -0,0 +1,119 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster
import com.pixelized.desktop.lwa.repository.campaign.model.CharacterSheetPreview
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio.Action
import com.pixelized.shared.lwa.model.campaign.Campaign
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__character_label
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__character_search
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc_label
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc_search
import org.jetbrains.compose.resources.getString
import java.text.Normalizer
class GameMasterFactory {
suspend fun convertToGMCharacterPreviewUio(
campaign: Campaign,
characters: List<CharacterSheetPreview>,
filter: String,
): List<GMCharacterPreviewUio> {
val normalizedFilter = Normalizer.normalize(filter, Normalizer.Form.NFD)
return characters.mapNotNull {
convertToGMCharacterPreviewUio(
campaign = campaign,
character = it,
filter = normalizedFilter,
)
}
}
private suspend fun convertToGMCharacterPreviewUio(
campaign: Campaign,
character: CharacterSheetPreview,
filter: String,
): GMCharacterPreviewUio? {
val characterId = campaign.characters.keys.firstOrNull {
it.characterSheetId == character.characterSheetId
}
val npcIds = campaign.npcs.keys.filter {
it.characterSheetId == character.characterSheetId
}
var playerTagHighlighted = false
var npcTagHighlighted = false
// Filter process.
if (filter.isNotEmpty()) {
val normalizedName = Normalizer.normalize(character.name, Normalizer.Form.NFD)
// If the filter is not empty and the character is not
val playerTag = getString(Res.string.game_master__character_tag__character_search)
val npcTag = getString(Res.string.game_master__character_tag__npc_search)
playerTagHighlighted = playerTag.contains(other = filter, ignoreCase = true)
if (playerTagHighlighted && characterId == null) {
return null
}
npcTagHighlighted = npcTag.contains(other = filter, ignoreCase = true)
if (npcTagHighlighted && npcIds.isEmpty()) {
return null
}
val nameHighlight = normalizedName.contains(other = filter, ignoreCase = true)
if (nameHighlight.not() && playerTagHighlighted.not() && npcTagHighlighted.not()) {
return null
}
}
val tags = buildList {
if (characterId != null) {
add(
GMCharacterPreviewUio.Tag(
label = getString(
Res.string.game_master__character_tag__character_label,
characterId.instanceId,
),
highlight = playerTagHighlighted,
)
)
}
addAll(
npcIds.map { npcId ->
GMCharacterPreviewUio.Tag(
label = getString(
Res.string.game_master__character_tag__npc_label,
npcId.instanceId
),
highlight = npcTagHighlighted,
)
}
)
}
val actions = buildList {
add(
when (characterId) {
null -> Action.AddToGroup
else -> Action.RemoveFromGroup(instanceId = characterId.instanceId)
}
)
add(Action.AddToNpc)
addAll(
npcIds.map { npcId ->
Action.RemoveFromNpc(instanceId = npcId.instanceId)
}
)
}
return GMCharacterPreviewUio(
characterSheetId = character.characterSheetId,
name = character.name, level = character.level,
tags = tags,
actions = actions,
)
}
}

View file

@ -0,0 +1,118 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreview
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio
import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_cancel_24dp
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun GameMasterScreen(
viewModel: GameMasterViewModel = koinViewModel(),
) {
val characters = viewModel.characters.collectAsState()
Surface(
modifier = Modifier.fillMaxSize()
) {
GameMasterContent(
modifier = Modifier.fillMaxSize(),
filter = viewModel.filter,
characters = characters,
onCharacterAction = viewModel::onCharacterAction,
)
}
}
@Composable
private fun GameMasterContent(
modifier: Modifier = Modifier,
filter: LwaTextFieldUio,
characters: State<List<GMCharacterPreviewUio>>,
onCharacterAction: (String, GMCharacterPreviewUio.Action) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = "",
)
}
)
},
content = { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues = paddingValues)
) {
LwaTextField(
modifier = Modifier.fillMaxWidth(),
field = filter,
trailingIcon = {
val value = filter.valueFlow.collectAsState()
AnimatedVisibility(
visible = value.value.isNotBlank(),
enter = fadeIn(),
exit = fadeOut(),
) {
IconButton(
onClick = { filter.onValueChange.invoke("") },
) {
Icon(
painter = painterResource(Res.drawable.ic_cancel_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
}
}
}
)
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(all = 8.dp),
verticalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
items(
items = characters.value,
) { character ->
GMCharacterPreview(
modifier = Modifier.fillMaxWidth(),
character = character,
onAction = { action ->
onCharacterAction(character.characterSheetId, action)
}
)
}
}
}
}
)
}

View file

@ -0,0 +1,57 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class GameMasterViewModel(
private val campaignRepository: CampaignRepository,
private val characterSheetRepository: CharacterSheetRepository,
private val gameMasterFactory: GameMasterFactory,
private val useCase: GameMasterActionUseCase,
) : ViewModel() {
private val _filter = MutableStateFlow("")
val filter = LwaTextFieldUio(
enable = true,
labelFlow = MutableStateFlow("Filtre"),
valueFlow = _filter,
placeHolderFlow = MutableStateFlow(null),
onValueChange = { _filter.value = it },
)
val characters = combine(
campaignRepository.campaignFlow,
characterSheetRepository.characterSheetPreviewFlow,
filter.valueFlow,
gameMasterFactory::convertToGMCharacterPreviewUio,
).stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
fun onCharacterAction(
characterSheetId: String,
action: GMCharacterPreviewUio.Action,
) {
viewModelScope.launch {
try {
useCase.handleAction(
characterSheetId = characterSheetId,
action = action,
)
} catch (exception: Exception) {
// TODO
}
}
}
}

View file

@ -0,0 +1,226 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio.Action
import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character_action__add_to_group
import lwacharactersheet.composeapp.generated.resources.game_master__character_action__add_to_npc
import lwacharactersheet.composeapp.generated.resources.game_master__character_action__display_portrait
import lwacharactersheet.composeapp.generated.resources.game_master__character_action__remove_from_group
import lwacharactersheet.composeapp.generated.resources.game_master__character_action__remove_from_npc
import lwacharactersheet.composeapp.generated.resources.game_master__character_level__label
import org.jetbrains.compose.resources.stringResource
@Stable
data class GMCharacterPreviewUio(
val characterSheetId: String,
val name: String,
val level: Int,
val tags: List<Tag>,
val actions: List<Action>,
) {
@Stable
data class Tag(
val label: String,
val highlight: Boolean,
)
@Stable
sealed class Action {
@Stable
data object DisplayPortrait : Action()
@Stable
data object AddToGroup : Action()
@Stable
data class RemoveFromGroup(val instanceId: Int) : Action()
@Stable
data object AddToNpc : Action()
@Stable
data class RemoveFromNpc(val instanceId: Int) : Action()
}
}
object GMCharacterPreviewDefault {
val padding = PaddingValues(horizontal = 16.dp)
}
@Composable
fun GMCharacterPreview(
modifier: Modifier = Modifier,
padding: PaddingValues = GMCharacterPreviewDefault.padding,
character: GMCharacterPreviewUio,
onAction: (Action) -> Unit,
) {
val layoutDirection = LocalLayoutDirection.current
val startPadding = padding.calculateStartPadding(layoutDirection)
Box(
modifier = Modifier
.clip(shape = remember { RoundedCornerShape(8.dp) })
.background(color = MaterialTheme.lwa.colorScheme.elevated.base1dp)
.then(other = modifier),
) {
Column {
Row(
modifier = Modifier.padding(start = startPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier.weight(weight = 1f),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.lwa.typography.base.body1,
text = character.name,
)
Text(
modifier = Modifier.alignByBaseline(),
style = MaterialTheme.lwa.typography.base.caption,
text = stringResource(
Res.string.game_master__character_level__label,
character.level,
),
)
}
OverflowActionMenu(
character = character,
onAction = onAction,
)
}
Row(
modifier = Modifier
.padding(paddingValues = padding)
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
) {
character.tags.forEach { tag ->
Tag(tag = tag)
}
}
}
}
}
@Composable
private fun OverflowActionMenu(
modifier: Modifier = Modifier,
character: GMCharacterPreviewUio,
onAction: (Action) -> Unit,
) {
val overflowMenu = remember(character) {
mutableStateOf(false)
}
IconButton(
modifier = modifier,
onClick = {
overflowMenu.value = true
},
) {
Icon(
imageVector = Icons.Default.MoreVert,
tint = MaterialTheme.colors.primary,
contentDescription = null,
)
}
DropdownMenu(
offset = remember { DpOffset(x = -(48.dp + 8.dp), y = -(48.dp)) },
expanded = overflowMenu.value,
onDismissRequest = {
overflowMenu.value = false
},
content = {
character.actions.forEach { action ->
DropdownMenuItem(
onClick = {
overflowMenu.value = false
onAction(action)
},
) {
Text(
style = MaterialTheme.lwa.typography.base.body1,
color = MaterialTheme.lwa.colorScheme.base.primary,
text = when (action) {
Action.DisplayPortrait -> stringResource(
Res.string.game_master__character_action__display_portrait,
)
Action.AddToGroup -> stringResource(
Res.string.game_master__character_action__add_to_group,
)
Action.AddToNpc -> stringResource(
Res.string.game_master__character_action__add_to_npc,
)
is Action.RemoveFromGroup -> stringResource(
Res.string.game_master__character_action__remove_from_group,
action.instanceId,
)
is Action.RemoveFromNpc -> stringResource(
Res.string.game_master__character_action__remove_from_npc,
action.instanceId,
)
}
)
}
}
},
)
}
@Composable
private fun Tag(
modifier: Modifier = Modifier,
padding: PaddingValues = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
tag: GMCharacterPreviewUio.Tag,
) {
Text(
modifier = modifier
.background(
color = MaterialTheme.lwa.colorScheme.elevated.base4dp,
shape = CircleShape,
)
.padding(paddingValues = padding),
style = MaterialTheme.lwa.typography.base.caption,
color = when (tag.highlight) {
true -> MaterialTheme.lwa.colorScheme.base.secondary
else -> MaterialTheme.lwa.colorScheme.base.onSurface
},
text = tag.label,
)
}

View file

@ -1,294 +0,0 @@
package com.pixelized.desktop.lwa.ui.screen.main
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
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.Scaffold
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
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 com.pixelized.desktop.lwa.LocalWindowController
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToMainPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToNetwork
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheet
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheetEdit
import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToRollHistory
import com.pixelized.shared.lwa.model.campaign.Campaign
import kotlinx.coroutines.runBlocking
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.app_name
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__create__title
import lwacharactersheet.composeapp.generated.resources.ic_d20_24dp
import lwacharactersheet.composeapp.generated.resources.ic_file_24dp
import lwacharactersheet.composeapp.generated.resources.ic_folder_24dp
import lwacharactersheet.composeapp.generated.resources.ic_swords_24dp
import lwacharactersheet.composeapp.generated.resources.ic_table_24dp
import lwacharactersheet.composeapp.generated.resources.main_page__create_action
import lwacharactersheet.composeapp.generated.resources.main_page__network_action
import lwacharactersheet.composeapp.generated.resources.main_page__open_save_directory
import lwacharactersheet.composeapp.generated.resources.main_page__roll_history_action
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@Stable
data class CharacterUio(
val id: Campaign.CharacterInstance.Id,
val name: String,
)
@Composable
fun OldMainPage(
viewModel: MainPageViewModel = koinViewModel(),
) {
val window = LocalWindowController.current
val screen = LocalScreenController.current
val characters = viewModel.characters.collectAsState()
val npcs = viewModel.npcs.collectAsState()
val enableRollHistory = viewModel.enableRollHistoryFlow.collectAsState()
Surface(
modifier = Modifier.fillMaxSize(),
) {
MainPageContent(
characters = characters,
npcs = npcs,
enableRollHistory = enableRollHistory,
onCharacter = {
window.navigateToCharacterSheet(
characterId = it.id,
title = it.name,
)
},
onCreateCharacter = {
window.navigateToCharacterSheetEdit(
characterId = null,
title = runBlocking { getString(Res.string.character_sheet_edit__create__title) },
)
},
onRollHistory = {
window.navigateToRollHistory()
},
onOpenSaveDirectory = {
viewModel.openSaveDirectory()
},
onNetwork = {
screen.navigateToNetwork()
},
onMainPage = {
screen.navigateToMainPage()
}
)
}
}
@Composable
fun MainPageContent(
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
characters: State<List<CharacterUio>>,
npcs: State<List<CharacterUio>>,
enableRollHistory: State<Boolean>,
onCharacter: (CharacterUio) -> Unit,
onCreateCharacter: () -> Unit,
onRollHistory: () -> Unit,
onOpenSaveDirectory: () -> Unit,
onNetwork: () -> Unit,
onMainPage: () -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = runBlocking { getString(Res.string.app_name) },
)
},
actions = {
TextButton(
onClick = onMainPage,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_swords_24dp),
contentDescription = null,
)
Text(
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
maxLines = 1,
text = "Nouvelle interface utilisateur",
)
}
}
}
)
},
content = {
Column {
Box(
modifier = Modifier
.verticalScroll(state = scrollState)
.fillMaxSize()
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center,
) {
Column {
if (characters.value.isNotEmpty()) {
Column {
characters.value.forEach { sheet ->
TextButton(
onClick = { onCharacter(sheet) },
) {
Text(
modifier = Modifier.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
maxLines = 1,
text = sheet.name,
)
}
}
}
}
if (characters.value.isNotEmpty() && npcs.value.isNotEmpty()) {
Spacer(modifier = Modifier.height(height = 24.dp))
}
if (npcs.value.isNotEmpty()) {
Column {
npcs.value.forEach { sheet ->
TextButton(
onClick = { onCharacter(sheet) },
) {
Text(
modifier = Modifier.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
maxLines = 1,
text = sheet.name,
)
}
}
}
}
Spacer(modifier = Modifier.height(height = 24.dp))
TextButton(
onClick = onCreateCharacter,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_file_24dp),
contentDescription = null,
)
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
text = stringResource(Res.string.main_page__create_action),
)
}
}
TextButton(
onClick = onOpenSaveDirectory,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_folder_24dp),
contentDescription = null,
)
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
text = stringResource(Res.string.main_page__open_save_directory),
)
}
}
TextButton(
enabled = enableRollHistory.value,
onClick = onRollHistory,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_d20_24dp),
contentDescription = null,
)
Text(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
text = stringResource(Res.string.main_page__roll_history_action),
)
}
}
TextButton(
onClick = onNetwork,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_table_24dp),
contentDescription = null,
)
Text(
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Start,
maxLines = 1,
text = stringResource(Res.string.main_page__network_action),
)
}
}
}
}
}
},
)
}

View file

@ -1,96 +0,0 @@
package com.pixelized.desktop.lwa.ui.screen.main
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.lordcodes.turtle.shellRun
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.utils.OperatingSystem
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class MainPageViewModel(
private val pathProvider: PathProvider,
private val characterSheetRepository: CharacterSheetRepository,
private val campaignRepository: CampaignRepository,
networkRepository: NetworkRepository,
) : ViewModel() {
@OptIn(ExperimentalCoroutinesApi::class)
val characters: StateFlow<List<CharacterUio>> = campaignRepository.campaignFlow
.flatMapMerge { campaign ->
combine(
campaign.characters
.map { entry ->
characterSheetRepository
.characterDetailFlow(characterSheetId = entry.key.characterSheetId)
.map transform@{ sheet ->
if (sheet == null) return@transform null
CharacterUio(id = entry.key, name = sheet.name)
}
}
.ifEmpty {
listOf(flowOf(null))
}
) { data ->
data.mapNotNull { it }
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val npcs: StateFlow<List<CharacterUio>> = campaignRepository.campaignFlow
.flatMapMerge { campaign ->
combine(
campaign.npcs
.map { entry ->
characterSheetRepository
.characterDetailFlow(characterSheetId = entry.key.characterSheetId)
.map transform@{ sheet ->
if (sheet == null) return@transform null
CharacterUio(id = entry.key, name = sheet.name)
}
}
.ifEmpty {
listOf(flowOf(null))
}
) { data ->
data.mapNotNull { it }
}
}
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
val enableRollHistoryFlow = networkRepository.status
.map { it == NetworkRepository.Status.CONNECTED }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = false,
)
fun openSaveDirectory(
os: OperatingSystem = OperatingSystem.current,
) {
val path = pathProvider.storePath(os = os)
when (os) {
OperatingSystem.Windows -> shellRun("explorer.exe", listOf(path))
OperatingSystem.Macintosh -> shellRun("open", listOf(path))
}
}
}

View file

@ -18,7 +18,7 @@ class RollHistoryViewModel(
characterRepository.characterSheetPreviewFlow,
rollRepository.rolls,
) { sheets, message ->
val name = sheets.firstOrNull { it.id == message.characterSheetId }?.name ?: ""
val name = sheets.firstOrNull { it.characterSheetId == message.characterSheetId }?.name ?: ""
val roll = RollHistoryItemUio(
character = name,
skillLabel = message.skillLabel,

View file

@ -13,6 +13,7 @@ class SettingsUseCase {
autoHideDelay = 8,
autoShowChat = true,
autoScrollChat = true,
isGM = false,
)
companion object {

View file

@ -33,7 +33,11 @@ class CampaignService(
initialValue = factory.convertToJson(campaignFlow.value),
)
fun campaign(): CampaignJson {
fun campaign(): Campaign {
return campaignFlow.value
}
fun campaignJson(): CampaignJson {
return campaignJsonFlow.value
}

View file

@ -5,6 +5,6 @@ import io.ktor.server.response.respond
fun Engine.getCampaign(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
call.respond(campaignService.campaign())
call.respond(campaignService.campaignJson())
}
}

View file

@ -9,24 +9,29 @@ import io.ktor.server.response.respondText
fun Engine.putCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
?: error("missing character sheet id")
val instanceId = campaignService.campaign().characters.keys
.firstOrNull { key -> key.characterSheetId == characterSheetId }
if (instanceId != null) {
error("Character Already in party")
}
val id = Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
instanceId = 0,
)
} else {
null
}
val updated = id?.let { campaignService.addCharacter(it) } ?: false
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
if (campaignService.addCharacter(id).not()) {
error("Unexpected error occurred when the character instance was added to the party")
}
call.respondText(
text = "$code",
status = code,
text = "Character $characterSheetId successfully added to the party",
status = HttpStatusCode.Accepted,
)
webSocket.emit(
Message(
@ -34,5 +39,13 @@ fun Engine.putCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext
value = RestSynchronisation.Campaign,
)
)
} catch (exception: Exception) {
call.run {
respondText(
text = "${exception.message}",
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}
}

View file

@ -9,35 +9,46 @@ import io.ktor.server.response.respondText
fun Engine.putCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val characterSheetId = call.queryParameters["characterSheetId"]
val instanceId = call.queryParameters["instanceId"]?.toIntOrNull()
?: error("missing character sheet id")
val id = if (characterSheetId != null && instanceId != null) {
Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId
)
val instanceId = campaignService.campaign().npcs.keys
.filter { it.characterSheetId == characterSheetId }
.reduceOrNull { acc, id ->
if (acc.instanceId < id.instanceId) {
id
} else {
null
acc
}
}
val updated = id?.let { campaignService.addNpc(it) } ?: false
val id = Campaign.CharacterInstance.Id(
characterSheetId = characterSheetId,
instanceId = instanceId?.let { it.instanceId + 1 } ?: 0,
)
val code = when (updated) {
true -> HttpStatusCode.Accepted
else -> HttpStatusCode.UnprocessableEntity
if (campaignService.addNpc(id).not()) {
error("Unexpected error occurred when the character instance was added to the npcs")
}
call.respondText(
text = "$code",
status = code,
text = "Character $characterSheetId successfully added to the npcs",
status = HttpStatusCode.Accepted,
)
webSocket.emit(
Message(
from = "Server",
value = RestSynchronisation.Campaign,
)
)
} catch (exception: Exception) {
call.run {
respondText(
text = "${exception.message}",
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}
}

View file

@ -87,6 +87,8 @@ class CharacterSheetJsonFactory(
): CharacterPreviewJson {
return CharacterPreviewJson(
id = sheet.id,
portrait = sheet.portrait,
thumbnail = sheet.thumbnail,
name = sheet.name,
level = sheet.level,
)
@ -98,8 +100,8 @@ class CharacterSheetJsonFactory(
val json = CharacterSheetJsonV1(
id = sheet.id,
name = sheet.name,
thumbnail = sheet.thumbnail,
portrait = sheet.portrait,
thumbnail = sheet.thumbnail,
level = sheet.level,
shouldLevelUp = sheet.shouldLevelUp,
strength = sheet.strength,

View file

@ -5,6 +5,8 @@ import kotlinx.serialization.Serializable
@Serializable
class CharacterPreviewJson(
val id: String,
val portrait: String?,
val thumbnail: String?,
val name: String,
val level: Int,
)