This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2025-03-28 16:57:39 +01:00 committed by Thomas Andres Gomez
parent 76336dfbb0
commit 02987a0a53
54 changed files with 1487 additions and 332 deletions

View file

@ -243,6 +243,16 @@
<string name="game_master__character_action__remove_from_npc">Retirer des Npcs</string> <string name="game_master__character_action__remove_from_npc">Retirer des Npcs</string>
<string name="game_master__create_character_sheet">Créer un personnage</string> <string name="game_master__create_character_sheet">Créer un personnage</string>
<string name="game_master__alteration__filter">Filtrer par nom :</string> <string name="game_master__alteration__filter">Filtrer par nom :</string>
<string name="game_master__alteration__create">Créer une altération</string>
<string name="game_master__alteration__delete">Supprimer l'altération</string> <string name="game_master__alteration__delete">Supprimer l'altération</string>
<string name="game_master__alteration__edit_add_field">Ajouter un champ</string>
<string name="game_master__alteration__edit_id">Identifiant de l'altération</string>
<string name="game_master__alteration__edit_label">Nom</string>
<string name="game_master__alteration__edit_description">Description</string>
<string name="game_master__alteration__edit_tags">Tags</string>
<string name="game_master__alteration__edit_field_id">Identifiant du champ</string>
<string name="game_master__alteration__edit_field_expression">Expression</string>
<string name="game_master__alteration__edit_field_save">Sauvegarder</string>
<string name="game_master__alteration__edit_field_cancel">Annuler</string>
</resources> </resources>

View file

@ -41,7 +41,9 @@ class DataSyncViewModel(
.filter { status -> status == NetworkRepository.Status.CONNECTED } .filter { status -> status == NetworkRepository.Status.CONNECTED }
.onEach { .onEach {
alterationRepository.updateAlterations() alterationRepository.updateAlterations()
alterationRepository.updateTags()
characterRepository.updateCharacterPreviews() characterRepository.updateCharacterPreviews()
characterRepository.updateTags()
campaignRepository.updateCampaign() campaignRepository.updateCampaign()
} }
.launchIn(this) .launchIn(this)

View file

@ -36,10 +36,13 @@ 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.characterSheet.edit.common.SkillFieldFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterViewModel import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.action.GMActionViewModel import com.pixelized.desktop.lwa.ui.screen.gamemaster.action.GMActionViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.GMAlterationFactory import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit.GMAlterationEditFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.GMAlterationViewModel import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list.GMAlterationFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.GMCharacterFactory import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list.GMAlterationViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.GMCharacterViewModel import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit.GMAlterationEditViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpFactory 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.levelup.LevelUpViewModel
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel
@ -123,8 +126,10 @@ val factoryDependencies
factoryOf(::CharacterSheetDiminishedDialogFactory) factoryOf(::CharacterSheetDiminishedDialogFactory)
factoryOf(::TextMessageFactory) factoryOf(::TextMessageFactory)
factoryOf(::LevelUpFactory) factoryOf(::LevelUpFactory)
factoryOf(::GMTagFactory)
factoryOf(::GMCharacterFactory) factoryOf(::GMCharacterFactory)
factoryOf(::GMAlterationFactory) factoryOf(::GMAlterationFactory)
factoryOf(::GMAlterationEditFactory)
} }
val viewModelDependencies val viewModelDependencies
@ -149,6 +154,7 @@ val viewModelDependencies
viewModelOf(::GameMasterViewModel) viewModelOf(::GameMasterViewModel)
viewModelOf(::GMActionViewModel) viewModelOf(::GMActionViewModel)
viewModelOf(::GMAlterationViewModel) viewModelOf(::GMAlterationViewModel)
viewModelOf(::GMAlterationEditViewModel)
} }
val useCaseDependencies val useCaseDependencies

View file

@ -3,6 +3,7 @@ package com.pixelized.desktop.lwa.network
import com.pixelized.shared.lwa.model.alteration.AlterationJson import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.CampaignJson import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
interface LwaClient { interface LwaClient {
@ -15,6 +16,16 @@ interface LwaClient {
alterationId: String, alterationId: String,
): AlterationJson? ): AlterationJson?
suspend fun updateAlteration(
alterationJson: AlterationJson
)
suspend fun deleteAlteration(
alterationId: String,
)
suspend fun alterationTags(): List<TagJson>
// Campaign // Campaign
suspend fun campaign(): CampaignJson suspend fun campaign(): CampaignJson
@ -39,6 +50,8 @@ interface LwaClient {
suspend fun characters(): List<CharacterPreviewJson> suspend fun characters(): List<CharacterPreviewJson>
suspend fun characterTags(): List<TagJson>
suspend fun character( suspend fun character(
characterSheetId: String, characterSheetId: String,
): CharacterSheetJson ): CharacterSheetJson

View file

@ -4,6 +4,7 @@ import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.shared.lwa.model.alteration.AlterationJson import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.CampaignJson import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
@ -30,6 +31,23 @@ class LwaClientImpl(
.get("$root/alteration/detail?alterationId=$alterationId") .get("$root/alteration/detail?alterationId=$alterationId")
.body() .body()
override suspend fun updateAlteration(
alterationJson: AlterationJson
) = client
.put("$root/alteration/update") {
contentType(ContentType.Application.Json)
setBody(alterationJson)
}
.body<Unit>()
override suspend fun deleteAlteration(alterationId: String) = client
.delete("$root/alteration/delete?alterationId=$alterationId")
.body<Unit>()
override suspend fun alterationTags(): List<TagJson> = client
.get("$root/alteration/tags")
.body()
override suspend fun campaign(): CampaignJson = client override suspend fun campaign(): CampaignJson = client
.get("$root/campaign") .get("$root/campaign")
.body() .body()
@ -62,6 +80,10 @@ class LwaClientImpl(
.get("$root/character/all") .get("$root/character/all")
.body() .body()
override suspend fun characterTags(): List<TagJson> = client
.get("$root/character/tags")
.body()
override suspend fun character( override suspend fun character(
characterSheetId: String, characterSheetId: String,
): CharacterSheetJson = client ): CharacterSheetJson = client

View file

@ -26,6 +26,8 @@ class AlterationRepository(
val alterationFlow get() = alterationStore.alterationsFlow val alterationFlow get() = alterationStore.alterationsFlow
val tagsFlow get() = alterationStore.tagsFlow
/** /**
* This flow transform the campaign instance (player + npc) into a * This flow transform the campaign instance (player + npc) into a
* Map<CharacterSheetId, List<AlterationId>>. * Map<CharacterSheetId, List<AlterationId>>.
@ -67,6 +69,16 @@ class AlterationRepository(
alterationStore.updateAlterations() alterationStore.updateAlterations()
} }
suspend fun updateTags() {
alterationStore.updateTags()
}
fun alteration(
alterationId: String?,
): Alteration? {
return alterationFlow.value[alterationId]
}
fun fieldAlterations( fun fieldAlterations(
characterSheetId: String, characterSheetId: String,
): Map<String, List<FieldAlteration>> { ): Map<String, List<FieldAlteration>> {
@ -79,6 +91,18 @@ class AlterationRepository(
return activeAlterationMapFlow.map { it[characterSheetId] ?: emptyMap() } return activeAlterationMapFlow.map { it[characterSheetId] ?: emptyMap() }
} }
suspend fun updateAlteration(
alteration: Alteration
) {
alterationStore.putAlteration(alteration)
}
suspend fun deleteAlteration(
alterationId: String,
) {
alterationStore.deleteAlteration(alterationId = alterationId)
}
private fun transformToAlterationFieldMap( private fun transformToAlterationFieldMap(
alterations: Map<String, Alteration>, alterations: Map<String, Alteration>,
actives: List<String>, actives: List<String>,

View file

@ -1,32 +1,92 @@
package com.pixelized.desktop.lwa.repository.alteration package com.pixelized.desktop.lwa.repository.alteration
import com.pixelized.desktop.lwa.network.LwaClient import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.alteration.Alteration import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.model.tag.Tag
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class AlterationStore( class AlterationStore(
private val networkRepository: NetworkRepository,
private val alterationFactory: AlterationJsonFactory, private val alterationFactory: AlterationJsonFactory,
private val tagFactory: TagJsonFactory,
private val client: LwaClient, private val client: LwaClient,
) { ) {
private val _alterationsFlow = MutableStateFlow<Map<String, Alteration>>(emptyMap()) private val _alterationsFlow = MutableStateFlow<Map<String, Alteration>>(emptyMap())
val alterationsFlow: StateFlow<Map<String, Alteration>> = _alterationsFlow val alterationsFlow: StateFlow<Map<String, Alteration>> = _alterationsFlow
private val _tagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
val tagsFlow: StateFlow<Map<String, Tag>> = _tagsFlow
init { init {
val scope = CoroutineScope(Dispatchers.IO + Job()) val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch { scope.launch {
updateAlterations() updateAlterations()
updateTags()
} }
// data update through WebSocket.
scope.launch {
networkRepository.data.collect(::handleMessage)
}
}
fun alterations(): Collection<Alteration> {
return alterationsFlow.value.values
}
fun tags(): Collection<Tag> {
return tagsFlow.value.values
}
fun alteration(alterationId: String): Alteration? {
return alterationsFlow.value[alterationId]
}
fun tag(tagId: String): Tag? {
return tagsFlow.value[tagId]
} }
suspend fun updateAlterations() { suspend fun updateAlterations() {
_alterationsFlow.value = try { _alterationsFlow.value = try {
loadAlteration() getAlteration()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
suspend fun updateAlteration(
alterationId: String,
) {
val alteration = try {
getAlteration(alterationId = alterationId)
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
null
}
if (alteration == null) return
_alterationsFlow.update { alterations ->
alterations.toMutableMap().also {
it[alterationId] = alteration
}
}
}
suspend fun updateTags() {
_tagsFlow.value = try {
getAlterationTag()
} catch (exception: Exception) { } catch (exception: Exception) {
println(exception.message) // TODO proper exception handling println(exception.message) // TODO proper exception handling
emptyMap() emptyMap()
@ -34,17 +94,62 @@ class AlterationStore(
} }
@Throws @Throws
private suspend fun loadAlteration(): Map<String, Alteration> { private suspend fun getAlteration(): Map<String, Alteration> {
val request = client.alterations() val request = client.alterations()
val data = request.map { alterationFactory.convertFromJson(json = it) } val data = request.map { alterationFactory.convertFromJson(json = it) }
return data.associateBy { it.id } return data.associateBy { it.id }
} }
fun alterations(): Collection<Alteration> { @Throws
return alterationsFlow.value.values private suspend fun getAlteration(
alterationId: String,
): Alteration? {
val request = client.alterations(alterationId = alterationId)
return request?.let { alterationFactory.convertFromJson(json = it) }
} }
fun alteration(alterationId: String): Alteration? { @Throws
return alterationsFlow.value[alterationId] private suspend fun getAlterationTag(): Map<String, Tag> {
val request = client.alterationTags()
val data = request.map { tagFactory.convertFromJson(json = it) }
return data.associateBy { it.id }
} }
@Throws
suspend fun putAlteration(
alteration: Alteration,
) {
client.updateAlteration(
alterationJson = alterationFactory.convertToJson(data = alteration)
)
}
@Throws
suspend fun deleteAlteration(
alterationId: String
) {
client.deleteAlteration(
alterationId = alterationId
)
}
// region: WebSocket & data update.
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ApiSynchronisation.AlterationUpdate -> updateAlteration(
alterationId = message.alterationId,
)
is ApiSynchronisation.AlterationDelete -> _alterationsFlow.update { alterations ->
alterations.toMutableMap().also {
it.remove(message.alterationId)
}
}
else -> Unit
}
}
// endregion
} }

View file

@ -19,9 +19,14 @@ class CharacterSheetRepository(
val characterSheetPreviewFlow get() = store.previewFlow val characterSheetPreviewFlow get() = store.previewFlow
val characterDetailFlow get() = store.detailFlow val characterDetailFlow get() = store.detailFlow
val tagsFlow get() = store.tagsFlow
suspend fun updateCharacterPreviews() { suspend fun updateCharacterPreviews() {
store.charactersPreview() store.updateCharactersPreview()
}
suspend fun updateTags() {
store.updateTags()
} }
fun characterPreview(characterId: String?): CharacterSheetPreview? { fun characterPreview(characterId: String?): CharacterSheetPreview? {

View file

@ -6,6 +6,8 @@ import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetPreview import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetPreview
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.model.tag.Tag
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
@ -23,7 +25,7 @@ class CharacterSheetStore(
private val client: LwaClient, private val client: LwaClient,
private val network: NetworkRepository, private val network: NetworkRepository,
private val factory: CharacterSheetJsonFactory, private val factory: CharacterSheetJsonFactory,
private val useCase: CharacterSheetUseCase, private val tagFactory: TagJsonFactory,
) { ) {
private val _previewFlow = MutableStateFlow<List<CharacterSheetPreview>>(value = emptyList()) private val _previewFlow = MutableStateFlow<List<CharacterSheetPreview>>(value = emptyList())
val previewFlow: StateFlow<List<CharacterSheetPreview>> get() = _previewFlow val previewFlow: StateFlow<List<CharacterSheetPreview>> get() = _previewFlow
@ -31,11 +33,15 @@ class CharacterSheetStore(
private val _detailFlow = MutableStateFlow<Map<String, CharacterSheet>>(value = emptyMap()) private val _detailFlow = MutableStateFlow<Map<String, CharacterSheet>>(value = emptyMap())
val detailFlow: StateFlow<Map<String, CharacterSheet>> get() = _detailFlow val detailFlow: StateFlow<Map<String, CharacterSheet>> get() = _detailFlow
private val _tagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
val tagsFlow: StateFlow<Map<String, Tag>> = _tagsFlow
init { init {
val scope = CoroutineScope(Dispatchers.IO + Job()) val scope = CoroutineScope(Dispatchers.IO + Job())
// initial data loading. // initial data loading.
scope.launch { scope.launch {
charactersPreview() updateCharactersPreview()
updateTags()
} }
// data update through WebSocket. // data update through WebSocket.
scope.launch { scope.launch {
@ -45,7 +51,7 @@ class CharacterSheetStore(
// region Rest // region Rest
suspend fun charactersPreview(): List<CharacterSheetPreview> { suspend fun updateCharactersPreview(): List<CharacterSheetPreview> {
val request = try { val request = try {
client.characters() client.characters()
} catch (exception: Exception) { } catch (exception: Exception) {
@ -58,6 +64,21 @@ class CharacterSheetStore(
return _previewFlow.update(characters) return _previewFlow.update(characters)
} }
suspend fun updateTags(): Map<String, Tag> {
val request = try {
client.characterTags()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
emptyList()
}
val tags = request.map {
tagFactory.convertFromJson(json = it)
}.associateBy { it.id }
_tagsFlow.value = tags
return tags
}
suspend fun getCharacterSheet( suspend fun getCharacterSheet(
characterSheetId: String, characterSheetId: String,
forceUpdate: Boolean = false, forceUpdate: Boolean = false,
@ -110,7 +131,7 @@ class CharacterSheetStore(
forceUpdate = true, forceUpdate = true,
) )
if (_previewFlow.value.firstOrNull { it.characterSheetId == message.characterSheetId } == null) { if (_previewFlow.value.firstOrNull { it.characterSheetId == message.characterSheetId } == null) {
charactersPreview() updateCharactersPreview()
} }
} }
@ -126,6 +147,9 @@ class CharacterSheetStore(
sheets.toMutableMap().also { it.remove(message.characterSheetId) } sheets.toMutableMap().also { it.remove(message.characterSheetId) }
} }
} }
is ApiSynchronisation.AlterationUpdate -> Unit
is ApiSynchronisation.AlterationDelete -> Unit
} }
is CharacterSheetEvent -> when (message) { is CharacterSheetEvent -> when (message) {

View file

@ -20,9 +20,9 @@ import kotlinx.coroutines.flow.StateFlow
data class LwaTextFieldUio( data class LwaTextFieldUio(
val enable: Boolean = true, val enable: Boolean = true,
val isError: StateFlow<Boolean>, val isError: StateFlow<Boolean>,
val labelFlow: StateFlow<String?>, val labelFlow: StateFlow<String?>?,
val valueFlow: StateFlow<String>, val valueFlow: StateFlow<String>,
val placeHolderFlow: StateFlow<String?>, val placeHolderFlow: StateFlow<String?>?,
val onValueChange: (String) -> Unit, val onValueChange: (String) -> Unit,
) )
@ -43,9 +43,9 @@ fun LwaTextField(
Modifier Modifier
} }
val label = field.labelFlow.collectAsState() val label = field.labelFlow?.collectAsState()
val placeHolder = field.placeHolderFlow?.collectAsState()
val value = field.valueFlow.collectAsState() val value = field.valueFlow.collectAsState()
val placeHolder = field.placeHolderFlow.collectAsState()
val isError = field.isError.collectAsState() val isError = field.isError.collectAsState()
TextField( TextField(
@ -56,7 +56,7 @@ fun LwaTextField(
}, },
enabled = field.enable, enabled = field.enable,
singleLine = singleLine, singleLine = singleLine,
placeholder = placeHolder.value?.let { placeholder = placeHolder?.value?.let {
{ {
Text( Text(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@ -66,7 +66,7 @@ fun LwaTextField(
} }
}, },
isError = isError.value, isError = isError.value,
label = label.value?.let { label = label?.value?.let {
{ {
Text( Text(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,

View file

@ -1,5 +1,6 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination package com.pixelized.desktop.lwa.ui.navigation.screen.destination
import androidx.compose.runtime.Stable
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
@ -15,8 +16,7 @@ object CharacterSheetEditDestination {
fun baseRoute() = "$ROUTE?${CHARACTER_ID.ARG}" fun baseRoute() = "$ROUTE?${CHARACTER_ID.ARG}"
fun navigationRoute(characterSheetId: String?) = ROUTE + fun navigationRoute(characterSheetId: String?) = "$ROUTE?$CHARACTER_ID=$characterSheetId"
"?$CHARACTER_ID=$characterSheetId"
fun arguments() = listOf( fun arguments() = listOf(
navArgument(CHARACTER_ID) { navArgument(CHARACTER_ID) {
@ -25,6 +25,7 @@ object CharacterSheetEditDestination {
}, },
) )
@Stable
data class Argument( data class Argument(
val id: String?, val id: String?,
) { ) {

View file

@ -3,7 +3,7 @@ package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.GMAlterationPage import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list.GMAlterationPage
object GMAlterationDestination { object GMAlterationDestination {
private const val ROUTE = "GameMasterAlteration" private const val ROUTE = "GameMasterAlteration"

View file

@ -0,0 +1,58 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster
import androidx.compose.runtime.Stable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit.GMAlterationEditPage
import com.pixelized.desktop.lwa.utils.extention.ARG
@Stable
object GMAlterationEditDestination {
private const val ROUTE = "GameMasterAlterationEdit"
private const val ALTERATION_ID = "id"
@Stable
fun baseRoute() = "$ROUTE?${ALTERATION_ID.ARG}"
@Stable
fun navigationRoute(alterationId: String?) = "$ROUTE?$ALTERATION_ID=$alterationId"
@Stable
fun arguments() = listOf(
navArgument(ALTERATION_ID) {
nullable = true
type = NavType.StringType
},
)
@Stable
data class Argument(
val id: String?,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
id = savedStateHandle.get<String>(ALTERATION_ID),
)
}
}
fun NavGraphBuilder.composableGameMasterAlterationEditPage() {
composable(
route = GMAlterationEditDestination.baseRoute(),
arguments = GMAlterationEditDestination.arguments(),
) {
GMAlterationEditPage()
}
}
fun NavHostController.navigateToGameMasterAlterationEditPage(
alterationId: String?,
) {
val route = GMAlterationEditDestination.navigationRoute(
alterationId = alterationId,
)
navigate(route = route)
}

View file

@ -3,7 +3,7 @@ package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.GMCharacterPage import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterPage
object GMCharacterDestination { object GMCharacterDestination {
private const val ROUTE = "GameMasterCharacter" private const val ROUTE = "GameMasterCharacter"

View file

@ -20,27 +20,18 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.pixelized.desktop.lwa.LocalBlurController
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterDetailCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterSheetDiminishedViewModel
import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMActionDestination import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMActionDestination
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterActionPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterActionPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterAlterationEditPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterAlterationPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterAlterationPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterCharacterPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterCharacterPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterObjectPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterObjectPage
@ -48,11 +39,8 @@ import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.nav
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterAlterationPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterAlterationPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterCharacterPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterCharacterPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterObjectPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterObjectPage
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanel import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMTab
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailViewModel import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMTabUio
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.rememberTransitionAnimation
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTab
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTabUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaSwitchColors import com.pixelized.desktop.lwa.ui.theme.color.component.LwaSwitchColors
import com.pixelized.desktop.lwa.ui.theme.lwa import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
@ -175,6 +163,7 @@ private fun GameMasterContent(
composableGameMasterActionPage() composableGameMasterActionPage()
composableGameMasterCharacterPage() composableGameMasterCharacterPage()
composableGameMasterAlterationPage() composableGameMasterAlterationPage()
composableGameMasterAlterationEditPage()
composableGameMasterObjectPage() composableGameMasterObjectPage()
} }
} }

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items package com.pixelized.desktop.lwa.ui.screen.gamemaster.action
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -6,10 +6,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.minimumInteractiveComponentSize import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable

View file

@ -15,7 +15,6 @@ 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMAction
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.ic_visibility_24dp import lwacharactersheet.composeapp.generated.resources.ic_visibility_24dp

View file

@ -0,0 +1,115 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.parser.expression.ExpressionParser
import kotlinx.coroutines.flow.MutableStateFlow
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_field_expression
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_field_id
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_id
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_label
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_description
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_tags
import org.jetbrains.compose.resources.getString
import java.util.UUID
class GMAlterationEditFactory(
private val expressionParser: ExpressionParser,
) {
suspend fun createForm(
alteration: Alteration?,
): GMAlterationEditPageUio {
val id = MutableStateFlow(alteration?.id ?: "")
val label = MutableStateFlow(alteration?.metadata?.name ?: "")
val description = MutableStateFlow(alteration?.metadata?.description ?: "")
val tags = MutableStateFlow(alteration?.tags?.joinToString(", ") { it } ?: "")
val fields = MutableStateFlow(alteration?.fields?.map { createField(it) } ?: listOf(createField(null)))
return GMAlterationEditPageUio(
id = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_id)),
valueFlow = id,
placeHolderFlow = null,
onValueChange = { id.value = it },
),
label = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_label)),
valueFlow = label,
placeHolderFlow = null,
onValueChange = { label.value = it },
),
description = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_description)),
valueFlow = description,
placeHolderFlow = null,
onValueChange = { description.value = it },
),
tags = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_tags)),
valueFlow = tags,
placeHolderFlow = null,
onValueChange = { tags.value = it },
),
fields = fields,
)
}
suspend fun createField(
alteration: Alteration.Field?,
): GMAlterationEditPageUio.SkillUio {
val idFlow = MutableStateFlow(alteration?.fieldId ?: "")
val expressionFlow = MutableStateFlow(alteration?.expression?.toString() ?: "")
return GMAlterationEditPageUio.SkillUio(
key = "${UUID.randomUUID()}-${System.currentTimeMillis()}",
id = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_field_id)),
valueFlow = idFlow,
placeHolderFlow = null,
onValueChange = { idFlow.value = it },
),
expression = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_field_expression)),
valueFlow = expressionFlow,
placeHolderFlow = null,
onValueChange = { expressionFlow.value = it },
)
)
}
suspend fun createAlteration(
form: GMAlterationEditPageUio?,
): Alteration? {
if (form == null) return null
return Alteration(
id = form.id.valueFlow.value,
metadata = Alteration.MetaData(
name = form.label.valueFlow.value,
description = form.description.valueFlow.value,
),
tags = form.tags.valueFlow.value.split(","),
fields = form.fields.value.mapNotNull { field ->
expressionParser.parse(input = field.expression.valueFlow.value)?.let {
Alteration.Field(
fieldId = field.id.valueFlow.value,
expression = it
)
}
}
)
}
}

View file

@ -0,0 +1,280 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
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.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_add_field
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_field_cancel
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_field_save
import lwacharactersheet.composeapp.generated.resources.ic_save_24dp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@Stable
data class GMAlterationEditPageUio(
val id: LwaTextFieldUio,
val label: LwaTextFieldUio,
val description: LwaTextFieldUio,
val tags: LwaTextFieldUio,
val fields: MutableStateFlow<List<SkillUio>>,
) {
@Stable
data class SkillUio(
val key: String,
val id: LwaTextFieldUio,
val expression: LwaTextFieldUio,
)
}
@Stable
object GMAlterationEditPageDefault {
val paddings = PaddingValues(all = 8.dp)
}
@Composable
fun GMAlterationEditPage(
viewModel: GMAlterationEditViewModel = koinViewModel(),
) {
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
val form = viewModel.form.collectAsState()
AnimatedContent(
targetState = form.value,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) {
when (it) {
null -> Box(modifier = Modifier.fillMaxSize())
else -> GMAlterationEditContent(
modifier = Modifier.fillMaxSize(),
form = it,
paddings = GMAlterationEditPageDefault.paddings,
addField = {
scope.launch {
viewModel.addField()
}
},
removeField = viewModel::removeField,
onSave = {
scope.launch {
viewModel.save()
}
},
onCancel = {
screen.popBackStack()
},
)
}
}
AlterationEditKeyHandler(
onDismissRequest = {
screen.popBackStack()
}
)
}
@Composable
private fun GMAlterationEditContent(
modifier: Modifier = Modifier,
form: GMAlterationEditPageUio,
paddings: PaddingValues,
addField: () -> Unit,
removeField: (index: Int) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit,
) {
val fields = form.fields.collectAsState()
LazyColumn(
modifier = modifier,
contentPadding = paddings,
verticalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
item(
key = "Id",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = form.id,
singleLine = true,
)
}
item(
key = "Name",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = form.label,
singleLine = true,
)
}
item(
key = "Description",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = form.description,
singleLine = false,
)
}
item(
key = "Tags",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = form.tags,
singleLine = true,
)
}
itemsIndexed(
items = fields.value,
key = { _, item -> item.key },
) { index, item ->
Row(
modifier = Modifier.animateItem(),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LwaTextField(
modifier = Modifier.weight(1f),
field = item.id,
singleLine = true,
)
LwaTextField(
modifier = Modifier.weight(1f),
field = item.expression,
singleLine = true,
)
IconButton(
onClick = { removeField(index) },
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = MaterialTheme.lwa.colorScheme.base.primary,
)
}
}
}
item(
key = "Actions",
) {
Column(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
horizontalAlignment = Alignment.End
) {
Button(
colors = LwaButtonColors(),
shape = CircleShape,
onClick = addField,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__alteration__edit_add_field),
)
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
}
Button(
colors = LwaButtonColors(),
shape = CircleShape,
onClick = onSave,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__alteration__edit_field_save),
)
Icon(
painter = painterResource(Res.drawable.ic_save_24dp),
contentDescription = null,
)
}
Button(
colors = LwaButtonColors(),
shape = CircleShape,
onClick = onCancel,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__alteration__edit_field_cancel),
)
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null,
)
}
}
}
}
}
@Composable
private fun AlterationEditKeyHandler(
onDismissRequest: () -> Unit,
) {
KeyHandler {
when {
it.type == KeyEventType.KeyUp && it.key == Key.Escape -> {
onDismissRequest()
true
}
else -> false
}
}
}

View file

@ -0,0 +1,58 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMAlterationEditDestination
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GMAlterationEditViewModel(
private val alterationRepository: AlterationRepository,
private val factory: GMAlterationEditFactory,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val argument = GMAlterationEditDestination.Argument(savedStateHandle)
private val _form = MutableStateFlow<GMAlterationEditPageUio?>(null)
val form: StateFlow<GMAlterationEditPageUio?> get() = _form
init {
viewModelScope.launch {
_form.value = factory.createForm(
alteration = alterationRepository.alteration(alterationId = argument.id)
)
}
}
suspend fun save() {
val edited = factory.createAlteration(form = form.value)
val actual = alterationRepository.alterationFlow.value[edited?.id]
// TODO if argument.id == null et actual?.id != null on créer et on écrase existant !!!
if (edited != null)
alterationRepository.updateAlteration(edited)
}
suspend fun addField() {
form.value?.fields?.update { fields ->
fields.toMutableList().also {
it.add(element = factory.createField(alteration = null))
}
}
}
fun removeField(index: Int) {
form.value?.fields?.update { fields ->
fields.toMutableList().also {
it.removeAt(index = index)
}
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -26,6 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTag
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.lwa import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__delete import lwacharactersheet.composeapp.generated.resources.game_master__alteration__delete
@ -37,7 +39,7 @@ import org.jetbrains.compose.resources.stringResource
data class GMAlterationUio( data class GMAlterationUio(
val alterationId: String, val alterationId: String,
val label: String, val label: String,
val tags: List<GMTagItemUio>, val tags: List<GMTagUio>,
) )
@Stable @Stable

View file

@ -1,12 +1,14 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMAlterationUio import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagItemUio
import com.pixelized.desktop.lwa.utils.extention.unAccent import com.pixelized.desktop.lwa.utils.extention.unAccent
import com.pixelized.shared.lwa.model.alteration.Alteration import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.tag.Tag
import java.text.Collator import java.text.Collator
class GMAlterationFactory { class GMAlterationFactory(
private val tagFactory: GMTagFactory,
) {
fun filterAlteration( fun filterAlteration(
alterations: Collection<Alteration>, alterations: Collection<Alteration>,
@ -27,6 +29,7 @@ class GMAlterationFactory {
fun convertToGMAlterationUio( fun convertToGMAlterationUio(
alterations: List<Alteration>, alterations: List<Alteration>,
tags: Map<String, Tag>,
selectedTagId: String?, selectedTagId: String?,
): List<GMAlterationUio> { ): List<GMAlterationUio> {
return alterations return alterations
@ -34,28 +37,16 @@ class GMAlterationFactory {
GMAlterationUio( GMAlterationUio(
alterationId = alteration.id, alterationId = alteration.id,
label = alteration.metadata.name, label = alteration.metadata.name,
tags = alteration.tags.map { tag -> tags = alteration.tags.mapNotNull {
GMTagItemUio( tags[it]?.let { tag ->
id = tag, tagFactory.convertToGMTagItemUio(
label = tag, tag = tag,
highlight = tag == selectedTagId, selectedTagId = selectedTagId,
) )
}
} }
) )
} }
.sortedWith(compareBy(Collator.getInstance()) { it.label }) .sortedWith(compareBy(Collator.getInstance()) { it.label })
} }
fun convertToGMTagItemUio(
alterationTagIds: List<String>,
selectedTagId: String?,
): List<GMTagItemUio> {
return alterationTagIds.map {
GMTagItemUio(
id = it,
label = it,
highlight = it == selectedTagId,
)
}
}
} }

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -21,17 +21,20 @@ 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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
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.GMAlteration import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMAlterationUio import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterAlterationEditPage
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMFilterHeader import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMFilterHeader
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagItemUio import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__create
import lwacharactersheet.composeapp.generated.resources.game_master__create_character_sheet import lwacharactersheet.composeapp.generated.resources.game_master__create_character_sheet
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@ -40,8 +43,11 @@ import org.koin.compose.viewmodel.koinViewModel
fun GMAlterationPage( fun GMAlterationPage(
viewModel: GMAlterationViewModel = koinViewModel(), viewModel: GMAlterationViewModel = koinViewModel(),
) { ) {
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
val alterations = viewModel.alterations.collectAsState() val alterations = viewModel.alterations.collectAsState()
val tags = viewModel.alterationTags.collectAsState() val tags = viewModel.tags.collectAsState()
Box { Box {
GMAlterationContent( GMAlterationContent(
@ -50,9 +56,17 @@ fun GMAlterationPage(
tags = tags, tags = tags,
alterations = alterations, alterations = alterations,
onTag = viewModel::onTag, onTag = viewModel::onTag,
onAlterationEdit = { }, onAlterationEdit = {
onAlterationDelete = { }, screen.navigateToGameMasterAlterationEditPage(alterationId = it)
onAlterationCreate = { }, },
onAlterationDelete = {
scope.launch {
viewModel.deleteAlteration(alterationId = it)
}
},
onAlterationCreate = {
screen.navigateToGameMasterAlterationEditPage(alterationId = null)
},
) )
} }
} }
@ -63,7 +77,7 @@ private fun GMAlterationContent(
padding: Dp = 8.dp, padding: Dp = 8.dp,
spacing: Dp = 8.dp, spacing: Dp = 8.dp,
filter: LwaTextFieldUio, filter: LwaTextFieldUio,
tags: State<List<GMTagItemUio>>, tags: State<List<GMTagUio>>,
alterations: State<List<GMAlterationUio>>, alterations: State<List<GMAlterationUio>>,
onTag: (String) -> Unit, onTag: (String) -> Unit,
onAlterationEdit: (String) -> Unit, onAlterationEdit: (String) -> Unit,
@ -131,7 +145,7 @@ private fun GMAlterationContent(
) { ) {
Text( Text(
modifier = Modifier.padding(end = 4.dp), modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__create_character_sheet), text = stringResource(Res.string.game_master__alteration__create),
) )
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,

View file

@ -1,17 +1,17 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
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.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.utils.extention.unAccent import com.pixelized.desktop.lwa.utils.extention.unAccent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@ -20,33 +20,29 @@ import lwacharactersheet.composeapp.generated.resources.game_master__character__
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
class GMAlterationViewModel( class GMAlterationViewModel(
alterationRepository: AlterationRepository, private val alterationRepository: AlterationRepository,
alterationFactory: GMAlterationFactory, alterationFactory: GMAlterationFactory,
tagFactory: GMTagFactory,
) : ViewModel() { ) : ViewModel() {
private val _filter = MutableStateFlow("") private val selectedTagId = MutableStateFlow<String?>(null)
private val filterValue = MutableStateFlow("")
val filter = LwaTextFieldUio( val filter = LwaTextFieldUio(
enable = true, enable = true,
labelFlow = MutableStateFlow(runBlocking { getString(Res.string.game_master__character__filter) }), labelFlow = MutableStateFlow(runBlocking { getString(Res.string.game_master__character__filter) }),
valueFlow = _filter, valueFlow = filterValue,
isError = MutableStateFlow(false), isError = MutableStateFlow(false),
placeHolderFlow = MutableStateFlow(null), placeHolderFlow = MutableStateFlow(null),
onValueChange = { _filter.value = it }, onValueChange = { filterValue.value = it },
) )
@OptIn(ExperimentalCoroutinesApi::class) val tags: StateFlow<List<GMTagUio>> = combine(
private val alterationTagIds = alterationRepository.alterationFlow alterationRepository.tagsFlow,
.mapLatest { alterations -> alterations.values.flatMap { it.tags }.toSet().toList() }
.distinctUntilChanged()
private val selectedTagId = MutableStateFlow<String?>(null)
val alterationTags = combine(
alterationTagIds,
selectedTagId, selectedTagId,
) { alterationTagIds, selectedTagId -> ) { tags, selectedTagId ->
alterationFactory.convertToGMTagItemUio( tagFactory.convertToGMTagItemUio(
alterationTagIds = alterationTagIds, tags = tags.values,
selectedTagId = selectedTagId, selectedTagId = selectedTagId,
) )
}.stateIn( }.stateIn(
@ -55,22 +51,20 @@ class GMAlterationViewModel(
initialValue = emptyList(), initialValue = emptyList(),
) )
@OptIn(ExperimentalCoroutinesApi::class) val alterations: StateFlow<List<GMAlterationUio>> = combine(
val alterations = combine(
alterationRepository.alterationFlow, alterationRepository.alterationFlow,
alterationRepository.tagsFlow,
filter.valueFlow.map { it.unAccent() }, filter.valueFlow.map { it.unAccent() },
selectedTagId, selectedTagId,
transform = { alterations, unAccentFilter, selectedTagId -> ) { alterations, tags, unAccentFilter, selectedTagId ->
alterationFactory.filterAlteration( alterationFactory.convertToGMAlterationUio(
alterations = alterationFactory.filterAlteration(
alterations = alterations.values, alterations = alterations.values,
unAccentFilter = unAccentFilter, unAccentFilter = unAccentFilter,
selectedTagId = selectedTagId selectedTagId = selectedTagId,
) ),
} tags = tags,
).mapLatest { alterations -> selectedTagId = selectedTagId,
alterationFactory.convertToGMAlterationUio(
alterations = alterations,
selectedTagId = selectedTagId.value
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@ -86,4 +80,8 @@ class GMAlterationViewModel(
} }
} }
} }
suspend fun deleteAlteration(alterationId: String) {
alterationRepository.deleteAlteration(alterationId)
}
} }

View file

@ -1,133 +0,0 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.character
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterItemUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterItemUio.Action
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagItemUio
import com.pixelized.desktop.lwa.utils.extention.unAccent
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetPreview
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__character
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc
import org.jetbrains.compose.resources.getString
class GMCharacterFactory {
companion object {
const val PLAYER_ID = "PLAYER"
const val NPC_ID = "NPC"
}
suspend fun convertToGMCharacterPreviewUio(
campaign: Campaign,
characters: List<CharacterSheetPreview>,
filter: String,
tags: Map<String, Boolean>,
): List<GMCharacterItemUio> {
val normalizedFilter = filter.unAccent()
return characters.mapNotNull {
convertToGMCharacterPreviewUio(
campaign = campaign,
character = it,
filter = normalizedFilter,
tags = tags,
)
}
}
private suspend fun convertToGMCharacterPreviewUio(
campaign: Campaign,
character: CharacterSheetPreview,
filter: String,
tags: Map<String, Boolean>,
): GMCharacterItemUio? {
// get the characterInstanceId from the player list corresponding to this CharacterSheet if any
val isPlayer = campaign.characters.firstOrNull {
it == character.characterSheetId
} != null
// get all characterInstanceId from the npcs list corresponding to this CharacterSheet if any
val isNpc = campaign.npcs.firstOrNull {
it == character.characterSheetId
} != null
// Filter process : Name.
if (filter.isNotEmpty()) {
val normalizedName = character.name.unAccent()
// If the filter is not empty and the character is not
val nameHighlight = normalizedName.contains(other = filter, ignoreCase = true)
if (nameHighlight.not()) {
return null
}
}
// Tag filter process : Player.
if (tags[PLAYER_ID] == true && isPlayer.not()) {
return null
}
// Tag filter process : Npc.
if (tags[NPC_ID] == true && isNpc.not()) {
return null
}
// Build the call tag list.
val previewTagsList = buildList {
if (isPlayer) {
add(
GMTagItemUio(
id = PLAYER_ID,
label = getString(Res.string.game_master__character_tag__character),
highlight = tags[PLAYER_ID] ?: false,
)
)
}
if (isNpc) {
add(
GMTagItemUio(
id = NPC_ID,
label = getString(Res.string.game_master__character_tag__npc),
highlight = tags[NPC_ID] ?: false,
)
)
}
}
// build the cell action list
val actions = buildList {
add(Action.DisplayPortrait)
when {
isPlayer -> add(Action.RemoveFromGroup)
isNpc -> add(Action.RemoveFromNpc)
else -> {
add(Action.AddToGroup)
add(Action.AddToNpc)
}
}
}
// return the cell UIO.
return GMCharacterItemUio(
characterSheetId = character.characterSheetId,
name = character.name,
level = character.level,
tags = previewTagsList,
actions = actions,
)
}
suspend fun convertToGMTagItemUio(
id: String?,
highlight: Boolean,
): GMTagItemUio? {
return when (id) {
PLAYER_ID -> GMTagItemUio(
id = id,
label = getString(Res.string.game_master__character_tag__character),
highlight = highlight,
)
NPC_ID -> GMTagItemUio(
id = id,
label = getString(Res.string.game_master__character_tag__npc),
highlight = highlight,
)
else -> null
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.PointerMatcher import androidx.compose.foundation.PointerMatcher
@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues 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.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.onClick import androidx.compose.foundation.onClick
import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenu
@ -28,9 +27,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterItemUio.Action import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTag
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.lwa import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res 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_group
@ -54,7 +54,7 @@ data class GMCharacterItemUio(
val characterSheetId: String, val characterSheetId: String,
val name: String, val name: String,
val level: Int, val level: Int,
val tags: List<GMTagItemUio>, val tags: List<GMTagUio>,
val actions: List<Action>, val actions: List<Action>,
) { ) {
@Stable @Stable

View file

@ -0,0 +1,114 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.utils.extention.unAccent
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetPreview
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__character
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc
import org.jetbrains.compose.resources.getString
class GMCharacterFactory {
companion object {
const val PLAYER_ID = "PLAYER"
const val NPC_ID = "NPC"
}
fun filterCharacter(
campaign: Campaign,
characters: List<CharacterSheetPreview>,
unAccentFilter: String,
selectedTagId: String?,
): List<CharacterSheetPreview> {
return characters.filter {
val matchName = it.name.unAccent().contains(
other = unAccentFilter,
ignoreCase = true,
)
val matchTag = when (selectedTagId) {
null -> true
PLAYER_ID -> campaign.characters.contains(it.characterSheetId)
NPC_ID -> campaign.npcs.contains(it.characterSheetId)
else -> false
}
matchName && matchTag
}
}
suspend fun convertToGMCharacterPreviewUio(
campaign: Campaign,
characters: List<CharacterSheetPreview>,
tagIdMap: List<GMTagUio>,
): List<GMCharacterItemUio> {
return characters.map {
convertToGMCharacterPreviewUio(
campaign = campaign,
character = it,
tagIdMap = tagIdMap,
)
}
}
private suspend fun convertToGMCharacterPreviewUio(
campaign: Campaign,
character: CharacterSheetPreview,
tagIdMap: List<GMTagUio>,
): GMCharacterItemUio {
val isPlayer = campaign.characters.contains(character.characterSheetId)
val isNpc = campaign.npcs.contains(character.characterSheetId)
// Build the call tag list.
val tags = tagIdMap.filter {
when (it.id) {
PLAYER_ID -> isPlayer
NPC_ID -> isNpc
else -> false
}
}
// build the cell action list
val actions = buildList {
add(Action.DisplayPortrait)
when {
isPlayer -> add(Action.RemoveFromGroup)
isNpc -> add(Action.RemoveFromNpc)
else -> {
add(Action.AddToGroup)
add(Action.AddToNpc)
}
}
}
// return the cell UIO.
return GMCharacterItemUio(
characterSheetId = character.characterSheetId,
name = character.name,
level = character.level,
tags = tags,
actions = actions,
)
}
suspend fun convertToGMTagItemUio(
id: String?,
highlight: Boolean,
): GMTagUio? {
return when (id) {
PLAYER_ID -> GMTagUio(
id = id,
label = getString(Res.string.game_master__character_tag__character),
highlight = highlight,
)
NPC_ID -> GMTagUio(
id = id,
label = getString(Res.string.game_master__character_tag__npc),
highlight = highlight,
)
else -> null
}
}
}

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.character package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -44,10 +44,8 @@ import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetai
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailViewModel import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailViewModel
import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.rememberTransitionAnimation import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.rememberTransitionAnimation
import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog import com.pixelized.desktop.lwa.ui.screen.characterSheet.detail.dialog.DiminishedStatDialog
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacter import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMFilterHeader
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMCharacterItemUio import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMFilterHeader
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagItemUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
@ -163,7 +161,7 @@ fun GMCharacterContent(
padding: Dp = 8.dp, padding: Dp = 8.dp,
spacing: Dp = 8.dp, spacing: Dp = 8.dp,
filter: LwaTextFieldUio, filter: LwaTextFieldUio,
tags: State<List<GMTagItemUio>>, tags: State<List<GMTagUio>>,
characters: State<List<GMCharacterItemUio>>, characters: State<List<GMCharacterItemUio>>,
onTag: (String) -> Unit, onTag: (String) -> Unit,
onCharacterAction: (String, GMCharacterItemUio.Action) -> Unit, onCharacterAction: (String, GMCharacterItemUio.Action) -> Unit,

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.character package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -6,20 +6,20 @@ 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.repository.network.NetworkRepository import com.pixelized.desktop.lwa.repository.network.NetworkRepository
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.GMCharacterItemUio.Action import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMTagItemUio import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.utils.extention.unAccent
import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent import com.pixelized.shared.lwa.protocol.websocket.GameMasterEvent
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.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character__filter import lwacharactersheet.composeapp.generated.resources.game_master__character__filter
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__character
import lwacharactersheet.composeapp.generated.resources.game_master__character_tag__npc
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
class GMCharacterViewModel( class GMCharacterViewModel(
@ -27,41 +27,53 @@ class GMCharacterViewModel(
private val campaignRepository: CampaignRepository, private val campaignRepository: CampaignRepository,
characterSheetRepository: CharacterSheetRepository, characterSheetRepository: CharacterSheetRepository,
private val factory: GMCharacterFactory, private val factory: GMCharacterFactory,
private val tagFactory: GMTagFactory,
) : ViewModel() { ) : ViewModel() {
private val _filter = MutableStateFlow("") private val selectedTagId = MutableStateFlow<String?>(null)
private val filterValue = MutableStateFlow("")
val filter = LwaTextFieldUio( val filter = LwaTextFieldUio(
enable = true, enable = true,
labelFlow = MutableStateFlow(runBlocking { getString(Res.string.game_master__character__filter) }), labelFlow = MutableStateFlow(runBlocking { getString(Res.string.game_master__character__filter) }),
valueFlow = _filter, valueFlow = filterValue,
isError = MutableStateFlow(false), isError = MutableStateFlow(false),
placeHolderFlow = MutableStateFlow(null), placeHolderFlow = MutableStateFlow(null),
onValueChange = { _filter.value = it }, onValueChange = { filterValue.value = it },
) )
private val _tags = MutableStateFlow( val tags = combine(
mapOf( characterSheetRepository.tagsFlow,
GMCharacterFactory.PLAYER_ID to false, selectedTagId,
GMCharacterFactory.NPC_ID to false ) { tags, selectedTagId ->
tagFactory.convertToGMTagItemUio(
tags = tags.values,
selectedTagId = selectedTagId,
) )
)
val tags = _tags.map { it: Map<String, Boolean> ->
it.mapNotNull { (id, highlight) ->
factory.convertToGMTagItemUio(id = id, highlight = highlight)
}
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Lazily,
initialValue = emptyList(), initialValue = emptyList(),
) )
val characters = combine( val characters = combine(
campaignRepository.campaignFlow, campaignRepository.campaignFlow,
characterSheetRepository.characterSheetPreviewFlow, characterSheetRepository.characterSheetPreviewFlow,
filter.valueFlow, filter.valueFlow.map { it.unAccent() },
_tags, tags,
factory::convertToGMCharacterPreviewUio, selectedTagId,
).stateIn( ) { campaign, characters, unAccentFilter, tagIdMap, selectedTagId ->
factory.convertToGMCharacterPreviewUio(
characters = factory.filterCharacter(
campaign = campaign,
characters = characters,
unAccentFilter = unAccentFilter,
selectedTagId = selectedTagId,
),
campaign = campaign,
tagIdMap = tagIdMap,
)
}.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = emptyList(), initialValue = emptyList(),
@ -106,8 +118,11 @@ class GMCharacterViewModel(
fun onTag( fun onTag(
id: String, id: String,
) { ) {
_tags.value = _tags.value.toMutableMap().also { selectedTagId.update {
it[id] = it.getOrPut(id) { true }.not() when (it) {
id -> null
else -> id
}
} }
} }
} }

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items package com.pixelized.desktop.lwa.ui.screen.gamemaster.common
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -28,6 +28,8 @@ import androidx.compose.ui.unit.Dp
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.common.tag.GMTag
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.lwa import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.Res
@ -41,7 +43,7 @@ fun GMFilterHeader(
padding: Dp = 16.dp, padding: Dp = 16.dp,
spacing: Dp = 8.dp, spacing: Dp = 8.dp,
filter: LwaTextFieldUio, filter: LwaTextFieldUio,
tags: State<List<GMTagItemUio>>, tags: State<List<GMTagUio>>,
onTag: (String) -> Unit, onTag: (String) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -54,7 +56,6 @@ fun GMFilterHeader(
field = filter, field = filter,
trailingIcon = { trailingIcon = {
val value = filter.valueFlow.collectAsState() val value = filter.valueFlow.collectAsState()
AnimatedVisibility( AnimatedVisibility(
visible = value.value.isNotBlank(), visible = value.value.isNotBlank(),
enter = fadeIn(), enter = fadeIn(),
@ -72,28 +73,37 @@ fun GMFilterHeader(
} }
} }
) )
LazyRow( AnimatedVisibility(
modifier = Modifier.draggable( visible = tags.value.isNotEmpty(),
orientation = Orientation.Horizontal, enter = fadeIn(),
state = rememberDraggableState { delta -> exit = fadeOut(),
scope.launch {
lazyListState.scrollBy(-delta)
}
},
),
state = lazyListState,
contentPadding = remember(padding, spacing) {
PaddingValues(horizontal = padding, vertical = spacing)
},
horizontalArrangement = Arrangement.spacedBy(space = spacing),
) { ) {
items( LazyRow(
items = tags.value, modifier = Modifier.draggable(
) { tag -> orientation = Orientation.Horizontal,
GMTag( state = rememberDraggableState { delta ->
tag = tag, scope.launch {
onTag = { onTag(tag.id) }, lazyListState.scrollBy(-delta)
) }
},
),
state = lazyListState,
contentPadding = remember(padding, spacing) {
PaddingValues(
horizontal = padding,
vertical = spacing,
)
},
horizontalArrangement = Arrangement.spacedBy(space = spacing),
) {
items(
items = tags.value,
) { tag ->
GMTag(
tag = tag,
onTag = { onTag(tag.id) },
)
}
} }
} }
} }

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items package com.pixelized.desktop.lwa.ui.screen.gamemaster.common
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton

View file

@ -1,4 +1,4 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.items package com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -19,7 +19,7 @@ import com.pixelized.desktop.lwa.ui.theme.lwa
@Stable @Stable
data class GMTagItemUio( data class GMTagUio(
val id: String, val id: String,
val label: String, val label: String,
val highlight: Boolean, val highlight: Boolean,
@ -36,7 +36,7 @@ fun GMTag(
padding: PaddingValues = GmTagDefault.padding, padding: PaddingValues = GmTagDefault.padding,
shape: Shape = CircleShape, shape: Shape = CircleShape,
elevation: Dp = 2.dp, elevation: Dp = 2.dp,
tag: GMTagItemUio, tag: GMTagUio,
onTag: (() -> Unit)? = null, onTag: (() -> Unit)? = null,
) { ) {
val animatedColor = animateColorAsState( val animatedColor = animateColorAsState(

View file

@ -0,0 +1,29 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag
import com.pixelized.shared.lwa.model.tag.Tag
class GMTagFactory {
fun convertToGMTagItemUio(
tags: Collection<Tag>,
selectedTagId: String?,
): List<GMTagUio> {
return tags.map { tag ->
convertToGMTagItemUio(
tag = tag,
selectedTagId = selectedTagId,
)
}
}
fun convertToGMTagItemUio(
tag: Tag,
selectedTagId: String?,
): GMTagUio {
return GMTagUio(
id = tag.id,
label = tag.label,
highlight = tag.id == selectedTagId,
)
}
}

View file

@ -3,7 +3,7 @@ package com.pixelized.desktop.lwa.business
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import org.junit.Test import org.junit.Test
class SkillNormalizerUseCaseText { class SkillUioNormalizerUseCaseText {
@Test @Test
fun testNormalization() { fun testNormalization() {

View file

@ -4,7 +4,7 @@ import com.pixelized.shared.lwa.usecase.SkillStepUseCase
import com.pixelized.shared.lwa.usecase.SkillStepUseCase.SkillStep import com.pixelized.shared.lwa.usecase.SkillStepUseCase.SkillStep
import org.junit.Test import org.junit.Test
class SkillStepUseCaseTest { class SkillUioStepUseCaseTest {
@Test @Test
fun testStepForSkill() { fun testStepForSkill() {

View file

@ -4,6 +4,7 @@ import com.pixelized.server.lwa.model.campaign.CampaignService
import com.pixelized.server.lwa.model.campaign.CampaignStore import com.pixelized.server.lwa.model.campaign.CampaignStore
import com.pixelized.server.lwa.model.character.CharacterSheetService import com.pixelized.server.lwa.model.character.CharacterSheetService
import com.pixelized.server.lwa.model.character.CharacterSheetStore import com.pixelized.server.lwa.model.character.CharacterSheetStore
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.server.lwa.server.Engine import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.utils.PathProvider import com.pixelized.shared.lwa.utils.PathProvider
import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.createdAtStart
@ -35,6 +36,7 @@ val storeDependencies
singleOf(::CharacterSheetStore) singleOf(::CharacterSheetStore)
singleOf(::CampaignStore) singleOf(::CampaignStore)
singleOf(::AlterationStore) singleOf(::AlterationStore)
singleOf(::TagStore)
} }
val serviceDependencies val serviceDependencies

View file

@ -1,6 +1,9 @@
package com.pixelized.server.lwa.model.alteration package com.pixelized.server.lwa.model.alteration
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.shared.lwa.model.alteration.AlterationJson import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.model.tag.TagJson
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -9,25 +12,45 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
class AlterationService( class AlterationService(
store: AlterationStore, private val alterationStore: AlterationStore,
tagStore: TagStore,
factory: AlterationJsonFactory,
) { ) {
private val scope = CoroutineScope(Dispatchers.IO + Job()) private val scope = CoroutineScope(Dispatchers.IO + Job())
private val alterationsFlow = store.alterationsFlow() private val alterationHashFlow = alterationStore.alterationsFlow()
.map { alterations -> alterations.associate { it.id to factory.convertToJson(it) } }
private val alterationHashFlow = alterationsFlow
.map { data -> data.associateBy { it.id } }
.stateIn( .stateIn(
scope = scope, scope = scope,
started = SharingStarted.Eagerly, started = SharingStarted.Eagerly,
initialValue = emptyMap() initialValue = emptyMap()
) )
private val alterationTags = tagStore.alterationTags()
.map { it.values.toList() }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun alterations(): List<AlterationJson> { fun alterations(): List<AlterationJson> {
return alterationsFlow.value return alterationHashFlow.value.values.toList()
}
fun tags(): List<TagJson> {
return alterationTags.value
} }
fun alteration(alterationId: String): AlterationJson? { fun alteration(alterationId: String): AlterationJson? {
return alterationHashFlow.value[alterationId] return alterationHashFlow.value[alterationId]
} }
fun update(json: AlterationJson) {
alterationStore.save(alteration = json)
}
fun delete(alterationId: String): Boolean {
return alterationStore.delete(id = alterationId)
}
} }

View file

@ -1,22 +1,27 @@
package com.pixelized.server.lwa.model.alteration package com.pixelized.server.lwa.model.alteration
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJson import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
import com.pixelized.shared.lwa.utils.PathProvider import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.File import java.io.File
class AlterationStore( class AlterationStore(
private val pathProvider: PathProvider, private val pathProvider: PathProvider,
private val factory: AlterationJsonFactory,
private val json: Json, private val json: Json,
) { ) {
private val directory = File(pathProvider.alterationsPath()).also { it.mkdirs() } private val directory = File(pathProvider.alterationsPath()).also { it.mkdirs() }
private val alterationsFlow = MutableStateFlow<List<AlterationJson>>(emptyList())
private val alterationFlow = MutableStateFlow<List<Alteration>>(emptyList())
init { init {
// build a coroutine scope for async calls // build a coroutine scope for async calls
@ -27,10 +32,10 @@ class AlterationStore(
} }
} }
fun alterationsFlow(): StateFlow<List<AlterationJson>> = alterationsFlow fun alterationsFlow(): StateFlow<List<Alteration>> = alterationFlow
private fun updateAlterations() { private fun updateAlterations() {
alterationsFlow.value = try { alterationFlow.value = try {
loadAlterations() loadAlterations()
} catch (exception: Exception) { } catch (exception: Exception) {
println(exception) // TODO proper exception handling println(exception) // TODO proper exception handling
@ -42,7 +47,7 @@ class AlterationStore(
FileReadException::class, FileReadException::class,
JsonConversionException::class, JsonConversionException::class,
) )
private fun loadAlterations(): List<AlterationJson> { private fun loadAlterations(): List<Alteration> {
return directory return directory
.listFiles() .listFiles()
?.mapNotNull { file -> ?.mapNotNull { file ->
@ -56,7 +61,8 @@ class AlterationStore(
return@mapNotNull null return@mapNotNull null
} }
try { try {
this.json.decodeFromString<AlterationJson>(json) val data = this.json.decodeFromString<AlterationJson>(json)
factory.convertFromJson(data)
} catch (exception: Exception) { } catch (exception: Exception) {
throw JsonConversionException(root = exception) throw JsonConversionException(root = exception)
} }
@ -64,6 +70,73 @@ class AlterationStore(
?: emptyList() ?: emptyList()
} }
@Throws(JsonConversionException::class, FileWriteException::class)
fun save(
alteration: Alteration,
) {
val json = try {
factory.convertToJson(data = alteration)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
save(alteration = json)
}
@Throws(FileWriteException::class)
fun save(
alteration: AlterationJson,
) {
// encode the json into a string
val data = try {
json.encodeToString(alteration)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the alteration into a file.
try {
val file = alterationFile(id = alteration.id)
file.writeText(
text = data,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(
root = exception
)
}
// Update the dataflow.
alterationFlow.update { alterations ->
val index = alterations.indexOfFirst { it.id == alteration.id }
val alt = factory.convertFromJson(alteration)
alterations.toMutableList().also {
if (index >= 0) {
it[index] = alt
} else {
it.add(alt)
}
}
}
}
fun delete(id: String): Boolean {
val file = alterationFile(id = id)
val deleted = file.delete()
if (deleted) {
alterationFlow.update { alterations ->
alterations.toMutableList().also { alteration ->
alteration.removeIf { it.id == id }
}
}
}
return deleted
}
private fun alterationFile(id: String): File {
return File("${pathProvider.alterationsPath()}${id}.json")
}
sealed class AlterationStoreException(root: Exception) : Exception(root) sealed class AlterationStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : AlterationStoreException(root) class JsonConversionException(root: Exception) : AlterationStoreException(root)
class FileWriteException(root: Exception) : AlterationStoreException(root) class FileWriteException(root: Exception) : AlterationStoreException(root)

View file

@ -1,8 +1,10 @@
package com.pixelized.server.lwa.model.character package com.pixelized.server.lwa.model.character
import com.pixelized.server.lwa.model.tag.TagStore
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -12,12 +14,13 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
class CharacterSheetService( class CharacterSheetService(
private val store: CharacterSheetStore, private val characterStore: CharacterSheetStore,
private val tagStore: TagStore,
private val factory: CharacterSheetJsonFactory, private val factory: CharacterSheetJsonFactory,
) { ) {
private val scope = CoroutineScope(Dispatchers.IO + Job()) private val scope = CoroutineScope(Dispatchers.IO + Job())
private val sheets get() = sheetsFlow.value private val sheets get() = sheetsFlow.value
private val sheetsFlow = store.characterSheetsFlow() private val sheetsFlow = characterStore.characterSheetsFlow()
.map { entry -> entry.associateBy { character -> character.id } } .map { entry -> entry.associateBy { character -> character.id } }
.stateIn( .stateIn(
scope = scope, scope = scope,
@ -25,10 +28,22 @@ class CharacterSheetService(
initialValue = emptyMap() initialValue = emptyMap()
) )
private val alterationTags = tagStore.characterTags()
.map { it.values.toList() }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun character(characterSheetId: String): CharacterSheet? { fun character(characterSheetId: String): CharacterSheet? {
return sheets[characterSheetId] return sheets[characterSheetId]
} }
fun tags() : Collection<TagJson> {
return alterationTags.value
}
fun charactersJson(): List<CharacterPreviewJson> { fun charactersJson(): List<CharacterPreviewJson> {
return sheets.map { factory.convertToPreviewJson(sheet = it.value) } return sheets.map { factory.convertToPreviewJson(sheet = it.value) }
} }
@ -38,13 +53,13 @@ class CharacterSheetService(
} }
suspend fun updateCharacterSheet(character: CharacterSheetJson) { suspend fun updateCharacterSheet(character: CharacterSheetJson) {
return store.save( return characterStore.save(
sheet = factory.convertFromJson(character) sheet = factory.convertFromJson(character)
) )
} }
fun deleteCharacterSheet(characterSheetId: String): Boolean { fun deleteCharacterSheet(characterSheetId: String): Boolean {
return store.delete(id = characterSheetId) return characterStore.delete(id = characterSheetId)
} }
// Data manipulation through WebSocket. // Data manipulation through WebSocket.
@ -60,7 +75,7 @@ class CharacterSheetService(
val alterations = character.alterations.toMutableList().also { val alterations = character.alterations.toMutableList().also {
it.add(alterationId) it.add(alterationId)
} }
store.save( characterStore.save(
sheet = character.copy( sheet = character.copy(
alterations = alterations, alterations = alterations,
) )
@ -70,7 +85,7 @@ class CharacterSheetService(
val alterations = character.alterations.toMutableList().also { val alterations = character.alterations.toMutableList().also {
it.remove(alterationId) it.remove(alterationId)
} }
store.save( characterStore.save(
sheet = character.copy( sheet = character.copy(
alterations = alterations, alterations = alterations,
) )
@ -85,7 +100,7 @@ class CharacterSheetService(
) { ) {
sheets[characterSheetId]?.let { character -> sheets[characterSheetId]?.let { character ->
val update = character.copy(damage = damage) val update = character.copy(damage = damage)
store.save(sheet = update) characterStore.save(sheet = update)
} }
} }
@ -95,7 +110,7 @@ class CharacterSheetService(
) { ) {
sheets[characterSheetId]?.let { character -> sheets[characterSheetId]?.let { character ->
val update = character.copy(diminished = diminished) val update = character.copy(diminished = diminished)
store.save(sheet = update) characterStore.save(sheet = update)
} }
} }
@ -105,7 +120,7 @@ class CharacterSheetService(
) { ) {
sheets[characterSheetId]?.let { character -> sheets[characterSheetId]?.let { character ->
val update = character.copy(fatigue = fatigue) val update = character.copy(fatigue = fatigue)
store.save(sheet = update) characterStore.save(sheet = update)
} }
} }
@ -126,7 +141,7 @@ class CharacterSheetService(
skill.takeIf { skill.id == skillId }?.copy(used = used) ?: skill skill.takeIf { skill.id == skillId }?.copy(used = used) ?: skill
}, },
) )
store.save(sheet = update) characterStore.save(sheet = update)
} }
} }
} }

View file

@ -76,7 +76,7 @@ class CharacterSheetStore(
) )
fun save(sheet: CharacterSheet) { fun save(sheet: CharacterSheet) {
// convert the character sheet into json format. // convert the character sheet into json format.
val json = try { val data = try {
factory.convertToJson(sheet = sheet).let(json::encodeToString) factory.convertToJson(sheet = sheet).let(json::encodeToString)
} catch (exception: Exception) { } catch (exception: Exception) {
throw JsonConversionException(root = exception) throw JsonConversionException(root = exception)
@ -85,7 +85,7 @@ class CharacterSheetStore(
try { try {
val file = characterSheetFile(id = sheet.id) val file = characterSheetFile(id = sheet.id)
file.writeText( file.writeText(
text = json, text = data,
charset = Charsets.UTF_8, charset = Charsets.UTF_8,
) )
} catch (exception: Exception) { } catch (exception: Exception) {

View file

@ -0,0 +1,108 @@
package com.pixelized.server.lwa.model.tag
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
private const val CHARACTER = "character"
private const val ALTERATION = "alteration"
class TagStore(
private val pathProvider: PathProvider,
private val json: Json,
) {
private val alterationTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
private val characterTagsFlow = MutableStateFlow<Map<String, TagJson>>(emptyMap())
init {
// make the file path.
File(pathProvider.tagsPath()).mkdirs()
// build a coroutine scope for async calls
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
update(
flow = alterationTagsFlow,
file = alterationFile(),
)
update(
flow = characterTagsFlow,
file = characterFile(),
)
}
}
fun alterationTags(): StateFlow<Map<String, TagJson>> = alterationTagsFlow
fun characterTags(): StateFlow<Map<String, TagJson>> = characterTagsFlow
private fun update(
flow: MutableStateFlow<Map<String, TagJson>>,
file: File,
) {
flow.value = try {
file.readTags().associateBy { it.id }
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
emptyMap()
}
}
@Throws(FileReadException::class, JsonConversionException::class)
private fun File.readTags(): List<TagJson> {
// read the file (force the UTF8 format)
val data = try {
readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
// Guard, if the json is blank no alteration have been save, ignore this file.
if (data.isBlank()) {
return emptyList()
}
return try {
json.decodeFromString<List<TagJson>>(data)
} catch (exception: Exception) {
throw JsonConversionException(
root = exception
)
}
}
@Throws(JsonConversionException::class, FileWriteException::class)
private fun saveAlterationTags(tags: List<TagJson>) {
// convert the data to json format
val json = try {
this.json.encodeToString(tags)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the file
try {
val file = alterationFile()
file.writeText(
text = json,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
alterationTagsFlow.value = tags.associateBy { it.id }
}
private fun characterFile() = File("${pathProvider.tagsPath()}$CHARACTER.json")
private fun alterationFile() = File("${pathProvider.tagsPath()}$ALTERATION.json")
sealed class TagStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : TagStoreException(root)
class FileWriteException(root: Exception) : TagStoreException(root)
class FileReadException(root: Exception) : TagStoreException(root)
}

View file

@ -1,8 +1,11 @@
package com.pixelized.server.lwa.server package com.pixelized.server.lwa.server
import com.pixelized.server.lwa.server.rest.alteration.deleteAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlteration import com.pixelized.server.lwa.server.rest.alteration.getAlteration
import com.pixelized.server.lwa.server.rest.alteration.getAlterationTags
import com.pixelized.server.lwa.server.rest.alteration.getAlterations import com.pixelized.server.lwa.server.rest.alteration.getAlterations
import com.pixelized.server.lwa.server.rest.alteration.putAlteration
import com.pixelized.server.lwa.server.rest.campaign.removeCampaignNpc import com.pixelized.server.lwa.server.rest.campaign.removeCampaignNpc
import com.pixelized.server.lwa.server.rest.campaign.getCampaign import com.pixelized.server.lwa.server.rest.campaign.getCampaign
import com.pixelized.server.lwa.server.rest.campaign.putCampaignCharacter import com.pixelized.server.lwa.server.rest.campaign.putCampaignCharacter
@ -11,6 +14,7 @@ import com.pixelized.server.lwa.server.rest.campaign.putCampaignScene
import com.pixelized.server.lwa.server.rest.campaign.removeCampaignCharacter import com.pixelized.server.lwa.server.rest.campaign.removeCampaignCharacter
import com.pixelized.server.lwa.server.rest.character.deleteCharacter import com.pixelized.server.lwa.server.rest.character.deleteCharacter
import com.pixelized.server.lwa.server.rest.character.getCharacter import com.pixelized.server.lwa.server.rest.character.getCharacter
import com.pixelized.server.lwa.server.rest.character.getCharacterTags
import com.pixelized.server.lwa.server.rest.character.getCharacters import com.pixelized.server.lwa.server.rest.character.getCharacters
import com.pixelized.server.lwa.server.rest.character.putCharacter import com.pixelized.server.lwa.server.rest.character.putCharacter
import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration
@ -126,6 +130,18 @@ class LocalServer {
path = "/detail", path = "/detail",
body = engine.getAlteration(), body = engine.getAlteration(),
) )
get(
path = "/tags",
body = engine.getAlterationTags(),
)
put(
path = "/update",
body = engine.putAlteration()
)
delete(
path = "/delete",
body = engine.deleteAlteration()
)
} }
route( route(
path = "/character", path = "/character",
@ -134,6 +150,10 @@ class LocalServer {
path = "/all", path = "/all",
body = engine.getCharacters(), body = engine.getCharacters(),
) )
get(
path = "/tags",
body = engine.getCharacterTags(),
)
get( get(
path = "/detail", path = "/detail",
body = engine.getCharacter(), body = engine.getCharacter(),

View file

@ -0,0 +1,37 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.alterationId
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
fun Engine.deleteAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val alterationId = call.parameters.alterationId
val deleted = alterationService.delete(
alterationId = alterationId
)
if (deleted.not()) error("Unexpected error occurred")
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
)
webSocket.emit(
value = ApiSynchronisation.AlterationDelete(
timestamp = System.currentTimeMillis(),
alterationId = alterationId,
),
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -0,0 +1,12 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import io.ktor.server.response.respond
fun Engine.getAlterationTags(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
call.respond(
message = alterationService.tags(),
)
}
}

View file

@ -0,0 +1,34 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
fun Engine.putAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
try {
val form = call.receive<AlterationJson>()
alterationService.update(json = form)
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
)
webSocket.emit(
value = ApiSynchronisation.AlterationUpdate(
timestamp = System.currentTimeMillis(),
alterationId = form.id,
),
)
} catch (exception : Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
)
}
}
}

View file

@ -14,6 +14,7 @@ fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -
characterSheetId = characterSheetId characterSheetId = characterSheetId
) )
// Remove the character fom the campaign if needed. // Remove the character fom the campaign if needed.
// TODO probably useless because all data will not be cleaned up (all campaign / screnes)
campaignService.removeInstance( campaignService.removeInstance(
characterSheetId = characterSheetId, characterSheetId = characterSheetId,
) )

View file

@ -0,0 +1,12 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import io.ktor.server.response.respond
fun Engine.getCharacterTags(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
return {
call.respond(
message = characterService.tags(),
)
}
}

View file

@ -7,6 +7,7 @@ import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV1Factory
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV2Factory import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonV2Factory
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonFactory
import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonV1Factory import com.pixelized.shared.lwa.model.characterSheet.factory.CharacterSheetJsonV1Factory
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import com.pixelized.shared.lwa.parser.dice.DiceParser import com.pixelized.shared.lwa.parser.dice.DiceParser
import com.pixelized.shared.lwa.parser.expression.ExpressionParser import com.pixelized.shared.lwa.parser.expression.ExpressionParser
import com.pixelized.shared.lwa.parser.word.WordParser import com.pixelized.shared.lwa.parser.word.WordParser
@ -45,6 +46,7 @@ val factoryDependencies
factoryOf(::CampaignJsonV2Factory) factoryOf(::CampaignJsonV2Factory)
factoryOf(::AlteredCharacterSheetFactory) factoryOf(::AlteredCharacterSheetFactory)
factoryOf(::AlterationJsonFactory) factoryOf(::AlterationJsonFactory)
factoryOf(::TagJsonFactory)
} }
val parserDependencies val parserDependencies

View file

@ -0,0 +1,6 @@
package com.pixelized.shared.lwa.model.tag
data class Tag(
val id: String,
val label: String,
)

View file

@ -0,0 +1,8 @@
package com.pixelized.shared.lwa.model.tag
import kotlinx.serialization.Serializable
@Serializable
sealed interface TagJson {
val id: String
}

View file

@ -0,0 +1,24 @@
package com.pixelized.shared.lwa.model.tag
class TagJsonFactory {
fun convertFromJson(
json: TagJson,
): Tag {
return when (json) {
is TagJsonV1 -> Tag(
id = json.id,
label = json.label,
)
}
}
fun convertToJson(
tag: Tag,
): TagJson {
return TagJsonV1(
id = tag.id,
label = tag.label,
)
}
}

View file

@ -0,0 +1,9 @@
package com.pixelized.shared.lwa.model.tag
import kotlinx.serialization.Serializable
@Serializable
data class TagJsonV1(
override val id: String,
val label: String,
) : TagJson

View file

@ -16,4 +16,16 @@ sealed interface ApiSynchronisation : SocketMessage {
override val timestamp: Long, override val timestamp: Long,
override val characterSheetId: String, override val characterSheetId: String,
) : ApiSynchronisation, CharacterSheetIdMessage ) : ApiSynchronisation, CharacterSheetIdMessage
@Serializable
data class AlterationUpdate(
override val timestamp: Long,
val alterationId: String,
) : ApiSynchronisation
@Serializable
data class AlterationDelete(
override val timestamp: Long,
val alterationId: String,
) : ApiSynchronisation
} }

View file

@ -53,4 +53,14 @@ class PathProvider(
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}alterations/" OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}alterations/"
} }
} }
fun tagsPath(
os: OperatingSystem = this.operatingSystem,
app: String = this.appName,
): String {
return when (os) {
OperatingSystem.Windows -> "${storePath(os = os, app = app)}tags\\"
OperatingSystem.Macintosh -> "${storePath(os = os, app = app)}tags/"
}
}
} }