Add filter chip to the gamemaster character screen.

This commit is contained in:
Thomas Andres Gomez 2025-03-16 14:41:23 +01:00
parent a59444c610
commit 662e270f3f
6 changed files with 196 additions and 75 deletions

View file

@ -178,10 +178,10 @@
<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_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>

View file

@ -3,12 +3,11 @@ 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.desktop.lwa.ui.screen.gamemaster.items.GMTagUio
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
@ -18,6 +17,7 @@ class GameMasterFactory {
campaign: Campaign,
characters: List<CharacterSheetPreview>,
filter: String,
tags: Map<GMTagUio.TagId, Boolean>,
): List<GMCharacterPreviewUio> {
val normalizedFilter = Normalizer.normalize(filter, Normalizer.Form.NFD)
@ -26,6 +26,7 @@ class GameMasterFactory {
campaign = campaign,
character = it,
filter = normalizedFilter,
tags = tags,
)
}
}
@ -34,71 +35,67 @@ class GameMasterFactory {
campaign: Campaign,
character: CharacterSheetPreview,
filter: String,
tags: Map<GMTagUio.TagId, Boolean>,
): GMCharacterPreviewUio? {
val characterId = campaign.characters.keys.firstOrNull {
it.characterSheetId == character.characterSheetId
}
// get the characterInstanceId from the player list corresponding to this CharacterSheet if any
val characterInstanceId: Campaign.CharacterInstance.Id? =
campaign.characters.keys.firstOrNull {
it.characterSheetId == character.characterSheetId
}
// get all characterInstanceId from the npcs list corresponding to this CharacterSheet if any
val npcIds = campaign.npcs.keys.filter {
it.characterSheetId == character.characterSheetId
}
var playerTagHighlighted = false
var npcTagHighlighted = false
// Filter process.
// Filter process : Name.
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()) {
if (nameHighlight.not()) {
return null
}
}
val tags = buildList {
if (characterId != null) {
// Tag filter process : Player.
if (tags[GMTagUio.TagId.PLAYER] == true && characterInstanceId == null) {
return null
}
// Tag filter process : Npc.
if (tags[GMTagUio.TagId.NPC] == true && npcIds.isEmpty()) {
return null
}
// Build the call tag list.
val previewTagsList = buildList {
if (characterInstanceId != null) {
add(
GMCharacterPreviewUio.Tag(
GMTagUio(
id = GMTagUio.TagId.PLAYER,
label = getString(
Res.string.game_master__character_tag__character_label,
characterId.instanceId,
characterInstanceId.instanceId,
),
highlight = playerTagHighlighted,
highlight = tags[GMTagUio.TagId.PLAYER] ?: false,
)
)
}
addAll(
npcIds.map { npcId ->
GMCharacterPreviewUio.Tag(
GMTagUio(
id = GMTagUio.TagId.NPC,
label = getString(
Res.string.game_master__character_tag__npc_label,
npcId.instanceId
),
highlight = npcTagHighlighted,
highlight = tags[GMTagUio.TagId.NPC] ?: false,
)
}
)
}
// build the cell action list
val actions = buildList {
add(
when (characterId) {
when (characterInstanceId) {
null -> Action.AddToGroup
else -> Action.RemoveFromGroup(instanceId = characterId.instanceId)
else -> Action.RemoveFromGroup(instanceId = characterInstanceId.instanceId)
}
)
add(Action.AddToNpc)
@ -108,11 +105,11 @@ class GameMasterFactory {
}
)
}
// return the cell UIO.
return GMCharacterPreviewUio(
characterSheetId = character.characterSheetId,
name = character.name, level = character.level,
tags = tags,
tags = previewTagsList,
actions = actions,
)
}

View file

@ -3,6 +3,11 @@ 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.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
@ -10,7 +15,10 @@ 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.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
@ -21,13 +29,18 @@ import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.screen.gamemaster.items.GMTag
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_cancel_24dp
import org.jetbrains.compose.resources.painterResource
@ -38,6 +51,7 @@ fun GameMasterScreen(
viewModel: GameMasterViewModel = koinViewModel(),
) {
val characters = viewModel.characters.collectAsState()
val tags = viewModel.tags.collectAsState()
Surface(
modifier = Modifier.fillMaxSize()
@ -45,7 +59,9 @@ fun GameMasterScreen(
GameMasterContent(
modifier = Modifier.fillMaxSize(),
filter = viewModel.filter,
tags = tags,
characters = characters,
onTag = viewModel::onTag,
onCharacterAction = viewModel::onCharacterAction,
)
}
@ -54,10 +70,15 @@ fun GameMasterScreen(
@Composable
private fun GameMasterContent(
modifier: Modifier = Modifier,
filterChipsState: LazyListState = rememberLazyListState(),
filter: LwaTextFieldUio,
tags: State<List<GMTagUio>>,
characters: State<List<GMCharacterPreviewUio>>,
onTag: (GMTagUio.TagId) -> Unit,
onCharacterAction: (String, GMCharacterPreviewUio.Action) -> Unit,
) {
val scope = rememberCoroutineScope()
Scaffold(
modifier = modifier,
topBar = {
@ -95,9 +116,32 @@ private fun GameMasterContent(
}
}
)
LazyRow(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
filterChipsState.scrollBy(-delta)
}
},
),
state = filterChipsState,
contentPadding = remember { PaddingValues(all = 8.dp) },
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
items(
items = tags.value,
) { tag ->
GMTag(
style = MaterialTheme.lwa.typography.base.body1,
tag = tag,
onTag = { onTag(tag.id) },
)
}
}
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(all = 8.dp),
contentPadding = remember { PaddingValues(all = 8.dp) },
verticalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
items(

View file

@ -6,11 +6,18 @@ 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 com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagUio.TagId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__character_search
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc_search
import org.jetbrains.compose.resources.getString
class GameMasterViewModel(
private val campaignRepository: CampaignRepository,
@ -28,10 +35,34 @@ class GameMasterViewModel(
onValueChange = { _filter.value = it },
)
private val _tags = MutableStateFlow(mapOf(TagId.PLAYER to false, TagId.NPC to false))
val tags = _tags.map { it: Map<TagId, Boolean> ->
it.map { (tag, highlight) ->
when (tag) {
TagId.PLAYER -> GMTagUio(
id = TagId.PLAYER,
label = getString(Res.string.game_master__character_tag__character_search),
highlight = highlight,
)
TagId.NPC -> GMTagUio(
id = TagId.NPC,
label = getString(Res.string.game_master__character_tag__npc_search),
highlight = highlight,
)
}
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
)
val characters = combine(
campaignRepository.campaignFlow,
characterSheetRepository.characterSheetPreviewFlow,
filter.valueFlow,
_tags,
gameMasterFactory::convertToGMCharacterPreviewUio,
).stateIn(
scope = viewModelScope,
@ -54,4 +85,12 @@ class GameMasterViewModel(
}
}
}
fun onTag(
id: TagId,
) {
_tags.value = _tags.value.toMutableMap().also {
it[id] = it.getOrPut(id) { true }.not()
}
}
}

View file

@ -8,7 +8,6 @@ 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
@ -44,15 +43,9 @@ data class GMCharacterPreviewUio(
val characterSheetId: String,
val name: String,
val level: Int,
val tags: List<Tag>,
val tags: List<GMTagUio>,
val actions: List<Action>,
) {
@Stable
data class Tag(
val label: String,
val highlight: Boolean,
)
@Stable
sealed class Action {
@Stable
@ -127,7 +120,10 @@ fun GMCharacterPreview(
horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
) {
character.tags.forEach { tag ->
Tag(tag = tag)
GMTag(
style = MaterialTheme.lwa.typography.base.caption,
tag = tag,
)
}
}
}
@ -200,27 +196,4 @@ private fun OverflowActionMenu(
}
},
)
}
@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

@ -0,0 +1,68 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.theme.lwa
@Stable
data class GMTagUio(
val id: TagId,
val label: String,
val highlight: Boolean,
) {
@Stable
enum class TagId {
PLAYER, NPC
}
}
@Stable
object GmTagDefault {
val padding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
}
@Composable
fun GMTag(
modifier: Modifier = Modifier,
padding: PaddingValues = GmTagDefault.padding,
shape: Shape = CircleShape,
elevation: Dp = 2.dp,
style: TextStyle,
tag: GMTagUio,
onTag: (() -> Unit)? = null,
) {
val animatedColor = animateColorAsState(
when (tag.highlight) {
true -> MaterialTheme.lwa.colorScheme.base.secondary
else -> MaterialTheme.lwa.colorScheme.base.onSurface
}
)
Surface(
modifier = modifier,
shape = shape,
elevation = elevation,
) {
Text(
modifier = Modifier
.clickable(enabled = onTag != null) { onTag?.invoke() }
.padding(paddingValues = padding),
style = style,
color = animatedColor.value,
text = tag.label,
)
}
}