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="level_up__skill_level">niv : %1$d -</string>
<string name="game_master__character_level__label">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_search">Joueur</string>
<string name="game_master__character_tag__character_label">joueur: %1$d</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_search">Npc</string>
<string name="game_master__character_tag__npc_label">npc: %1$d</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__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__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__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.repository.campaign.model.CharacterSheetPreview
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio 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.GMCharacterPreviewUio.Action
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagUio
import com.pixelized.shared.lwa.model.campaign.Campaign import com.pixelized.shared.lwa.model.campaign.Campaign
import lwacharactersheet.composeapp.generated.resources.Res 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_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_label
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc_search
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import java.text.Normalizer import java.text.Normalizer
@ -18,6 +17,7 @@ class GameMasterFactory {
campaign: Campaign, campaign: Campaign,
characters: List<CharacterSheetPreview>, characters: List<CharacterSheetPreview>,
filter: String, filter: String,
tags: Map<GMTagUio.TagId, Boolean>,
): List<GMCharacterPreviewUio> { ): List<GMCharacterPreviewUio> {
val normalizedFilter = Normalizer.normalize(filter, Normalizer.Form.NFD) val normalizedFilter = Normalizer.normalize(filter, Normalizer.Form.NFD)
@ -26,6 +26,7 @@ class GameMasterFactory {
campaign = campaign, campaign = campaign,
character = it, character = it,
filter = normalizedFilter, filter = normalizedFilter,
tags = tags,
) )
} }
} }
@ -34,71 +35,67 @@ class GameMasterFactory {
campaign: Campaign, campaign: Campaign,
character: CharacterSheetPreview, character: CharacterSheetPreview,
filter: String, filter: String,
tags: Map<GMTagUio.TagId, Boolean>,
): GMCharacterPreviewUio? { ): GMCharacterPreviewUio? {
val characterId = campaign.characters.keys.firstOrNull { // get the characterInstanceId from the player list corresponding to this CharacterSheet if any
it.characterSheetId == character.characterSheetId 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 { val npcIds = campaign.npcs.keys.filter {
it.characterSheetId == character.characterSheetId it.characterSheetId == character.characterSheetId
} }
// Filter process : Name.
var playerTagHighlighted = false
var npcTagHighlighted = false
// Filter process.
if (filter.isNotEmpty()) { if (filter.isNotEmpty()) {
val normalizedName = Normalizer.normalize(character.name, Normalizer.Form.NFD) val normalizedName = Normalizer.normalize(character.name, Normalizer.Form.NFD)
// If the filter is not empty and the character is not // 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) val nameHighlight = normalizedName.contains(other = filter, ignoreCase = true)
if (nameHighlight.not() && playerTagHighlighted.not() && npcTagHighlighted.not()) { if (nameHighlight.not()) {
return null return null
} }
} }
// Tag filter process : Player.
val tags = buildList { if (tags[GMTagUio.TagId.PLAYER] == true && characterInstanceId == null) {
if (characterId != 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( add(
GMCharacterPreviewUio.Tag( GMTagUio(
id = GMTagUio.TagId.PLAYER,
label = getString( label = getString(
Res.string.game_master__character_tag__character_label, Res.string.game_master__character_tag__character_label,
characterId.instanceId, characterInstanceId.instanceId,
), ),
highlight = playerTagHighlighted, highlight = tags[GMTagUio.TagId.PLAYER] ?: false,
) )
) )
} }
addAll( addAll(
npcIds.map { npcId -> npcIds.map { npcId ->
GMCharacterPreviewUio.Tag( GMTagUio(
id = GMTagUio.TagId.NPC,
label = getString( label = getString(
Res.string.game_master__character_tag__npc_label, Res.string.game_master__character_tag__npc_label,
npcId.instanceId npcId.instanceId
), ),
highlight = npcTagHighlighted, highlight = tags[GMTagUio.TagId.NPC] ?: false,
) )
} }
) )
} }
// build the cell action list
val actions = buildList { val actions = buildList {
add( add(
when (characterId) { when (characterInstanceId) {
null -> Action.AddToGroup null -> Action.AddToGroup
else -> Action.RemoveFromGroup(instanceId = characterId.instanceId) else -> Action.RemoveFromGroup(instanceId = characterInstanceId.instanceId)
} }
) )
add(Action.AddToNpc) add(Action.AddToNpc)
@ -108,11 +105,11 @@ class GameMasterFactory {
} }
) )
} }
// return the cell UIO.
return GMCharacterPreviewUio( return GMCharacterPreviewUio(
characterSheetId = character.characterSheetId, characterSheetId = character.characterSheetId,
name = character.name, level = character.level, name = character.name, level = character.level,
tags = tags, tags = previewTagsList,
actions = actions, 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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn 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.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
@ -21,13 +29,18 @@ import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio 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.GMCharacterPreview
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterPreviewUio 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 com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_cancel_24dp import lwacharactersheet.composeapp.generated.resources.ic_cancel_24dp
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@ -38,6 +51,7 @@ fun GameMasterScreen(
viewModel: GameMasterViewModel = koinViewModel(), viewModel: GameMasterViewModel = koinViewModel(),
) { ) {
val characters = viewModel.characters.collectAsState() val characters = viewModel.characters.collectAsState()
val tags = viewModel.tags.collectAsState()
Surface( Surface(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
@ -45,7 +59,9 @@ fun GameMasterScreen(
GameMasterContent( GameMasterContent(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
filter = viewModel.filter, filter = viewModel.filter,
tags = tags,
characters = characters, characters = characters,
onTag = viewModel::onTag,
onCharacterAction = viewModel::onCharacterAction, onCharacterAction = viewModel::onCharacterAction,
) )
} }
@ -54,10 +70,15 @@ fun GameMasterScreen(
@Composable @Composable
private fun GameMasterContent( private fun GameMasterContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
filterChipsState: LazyListState = rememberLazyListState(),
filter: LwaTextFieldUio, filter: LwaTextFieldUio,
tags: State<List<GMTagUio>>,
characters: State<List<GMCharacterPreviewUio>>, characters: State<List<GMCharacterPreviewUio>>,
onTag: (GMTagUio.TagId) -> Unit,
onCharacterAction: (String, GMCharacterPreviewUio.Action) -> Unit, onCharacterAction: (String, GMCharacterPreviewUio.Action) -> Unit,
) { ) {
val scope = rememberCoroutineScope()
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { 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( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(all = 8.dp), contentPadding = remember { PaddingValues(all = 8.dp) },
verticalArrangement = Arrangement.spacedBy(space = 8.dp), verticalArrangement = Arrangement.spacedBy(space = 8.dp),
) { ) {
items( 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.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio 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.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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch 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( class GameMasterViewModel(
private val campaignRepository: CampaignRepository, private val campaignRepository: CampaignRepository,
@ -28,10 +35,34 @@ class GameMasterViewModel(
onValueChange = { _filter.value = it }, 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( val characters = combine(
campaignRepository.campaignFlow, campaignRepository.campaignFlow,
characterSheetRepository.characterSheetPreviewFlow, characterSheetRepository.characterSheetPreviewFlow,
filter.valueFlow, filter.valueFlow,
_tags,
gameMasterFactory::convertToGMCharacterPreviewUio, gameMasterFactory::convertToGMCharacterPreviewUio,
).stateIn( ).stateIn(
scope = viewModelScope, 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.Row
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem import androidx.compose.material.DropdownMenuItem
@ -44,15 +43,9 @@ data class GMCharacterPreviewUio(
val characterSheetId: String, val characterSheetId: String,
val name: String, val name: String,
val level: Int, val level: Int,
val tags: List<Tag>, val tags: List<GMTagUio>,
val actions: List<Action>, val actions: List<Action>,
) { ) {
@Stable
data class Tag(
val label: String,
val highlight: Boolean,
)
@Stable @Stable
sealed class Action { sealed class Action {
@Stable @Stable
@ -127,7 +120,10 @@ fun GMCharacterPreview(
horizontalArrangement = Arrangement.spacedBy(space = 4.dp), horizontalArrangement = Arrangement.spacedBy(space = 4.dp),
) { ) {
character.tags.forEach { tag -> 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,
)
}
}