Merge branch 'feature/alterationEdit' into 'main'

tmp

See merge request pixelized/LwaCharacterSheet!5
This commit is contained in:
Thomas Andres Gomez 2025-03-29 14:15:31 +00:00
commit 3bb25f4e63
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__create_character_sheet">Créer un personnage</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__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>

View file

@ -41,7 +41,9 @@ class DataSyncViewModel(
.filter { status -> status == NetworkRepository.Status.CONNECTED }
.onEach {
alterationRepository.updateAlterations()
alterationRepository.updateTags()
characterRepository.updateCharacterPreviews()
characterRepository.updateTags()
campaignRepository.updateCampaign()
}
.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.gamemaster.GameMasterViewModel
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.GMAlterationViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.GMCharacterFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.GMCharacterViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit.GMAlterationEditFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list.GMAlterationFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list.GMAlterationViewModel
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.LevelUpViewModel
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel
@ -123,8 +126,10 @@ val factoryDependencies
factoryOf(::CharacterSheetDiminishedDialogFactory)
factoryOf(::TextMessageFactory)
factoryOf(::LevelUpFactory)
factoryOf(::GMTagFactory)
factoryOf(::GMCharacterFactory)
factoryOf(::GMAlterationFactory)
factoryOf(::GMAlterationEditFactory)
}
val viewModelDependencies
@ -149,6 +154,7 @@ val viewModelDependencies
viewModelOf(::GameMasterViewModel)
viewModelOf(::GMActionViewModel)
viewModelOf(::GMAlterationViewModel)
viewModelOf(::GMAlterationEditViewModel)
}
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.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
interface LwaClient {
@ -15,6 +16,16 @@ interface LwaClient {
alterationId: String,
): AlterationJson?
suspend fun updateAlteration(
alterationJson: AlterationJson
)
suspend fun deleteAlteration(
alterationId: String,
)
suspend fun alterationTags(): List<TagJson>
// Campaign
suspend fun campaign(): CampaignJson
@ -39,6 +50,8 @@ interface LwaClient {
suspend fun characters(): List<CharacterPreviewJson>
suspend fun characterTags(): List<TagJson>
suspend fun character(
characterSheetId: String,
): 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.campaign.CampaignJson
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 io.ktor.client.HttpClient
import io.ktor.client.call.body
@ -30,6 +31,23 @@ class LwaClientImpl(
.get("$root/alteration/detail?alterationId=$alterationId")
.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
.get("$root/campaign")
.body()
@ -62,6 +80,10 @@ class LwaClientImpl(
.get("$root/character/all")
.body()
override suspend fun characterTags(): List<TagJson> = client
.get("$root/character/tags")
.body()
override suspend fun character(
characterSheetId: String,
): CharacterSheetJson = client

View file

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

View file

@ -1,32 +1,92 @@
package com.pixelized.desktop.lwa.repository.alteration
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.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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AlterationStore(
private val networkRepository: NetworkRepository,
private val alterationFactory: AlterationJsonFactory,
private val tagFactory: TagJsonFactory,
private val client: LwaClient,
) {
private val _alterationsFlow = MutableStateFlow<Map<String, Alteration>>(emptyMap())
val alterationsFlow: StateFlow<Map<String, Alteration>> = _alterationsFlow
private val _tagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
val tagsFlow: StateFlow<Map<String, Tag>> = _tagsFlow
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch {
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() {
_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) {
println(exception.message) // TODO proper exception handling
emptyMap()
@ -34,17 +94,62 @@ class AlterationStore(
}
@Throws
private suspend fun loadAlteration(): Map<String, Alteration> {
private suspend fun getAlteration(): Map<String, Alteration> {
val request = client.alterations()
val data = request.map { alterationFactory.convertFromJson(json = it) }
return data.associateBy { it.id }
}
fun alterations(): Collection<Alteration> {
return alterationsFlow.value.values
@Throws
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? {
return alterationsFlow.value[alterationId]
@Throws
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 characterDetailFlow get() = store.detailFlow
val tagsFlow get() = store.tagsFlow
suspend fun updateCharacterPreviews() {
store.charactersPreview()
store.updateCharactersPreview()
}
suspend fun updateTags() {
store.updateTags()
}
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.CharacterSheetPreview
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.CharacterSheetEvent
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
@ -23,7 +25,7 @@ class CharacterSheetStore(
private val client: LwaClient,
private val network: NetworkRepository,
private val factory: CharacterSheetJsonFactory,
private val useCase: CharacterSheetUseCase,
private val tagFactory: TagJsonFactory,
) {
private val _previewFlow = MutableStateFlow<List<CharacterSheetPreview>>(value = emptyList())
val previewFlow: StateFlow<List<CharacterSheetPreview>> get() = _previewFlow
@ -31,11 +33,15 @@ class CharacterSheetStore(
private val _detailFlow = MutableStateFlow<Map<String, CharacterSheet>>(value = emptyMap())
val detailFlow: StateFlow<Map<String, CharacterSheet>> get() = _detailFlow
private val _tagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
val tagsFlow: StateFlow<Map<String, Tag>> = _tagsFlow
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
// initial data loading.
scope.launch {
charactersPreview()
updateCharactersPreview()
updateTags()
}
// data update through WebSocket.
scope.launch {
@ -45,7 +51,7 @@ class CharacterSheetStore(
// region Rest
suspend fun charactersPreview(): List<CharacterSheetPreview> {
suspend fun updateCharactersPreview(): List<CharacterSheetPreview> {
val request = try {
client.characters()
} catch (exception: Exception) {
@ -58,6 +64,21 @@ class CharacterSheetStore(
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(
characterSheetId: String,
forceUpdate: Boolean = false,
@ -110,7 +131,7 @@ class CharacterSheetStore(
forceUpdate = true,
)
if (_previewFlow.value.firstOrNull { it.characterSheetId == message.characterSheetId } == null) {
charactersPreview()
updateCharactersPreview()
}
}
@ -126,6 +147,9 @@ class CharacterSheetStore(
sheets.toMutableMap().also { it.remove(message.characterSheetId) }
}
}
is ApiSynchronisation.AlterationUpdate -> Unit
is ApiSynchronisation.AlterationDelete -> Unit
}
is CharacterSheetEvent -> when (message) {

View file

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

View file

@ -1,5 +1,6 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination
import androidx.compose.runtime.Stable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
@ -15,8 +16,7 @@ object CharacterSheetEditDestination {
fun baseRoute() = "$ROUTE?${CHARACTER_ID.ARG}"
fun navigationRoute(characterSheetId: String?) = ROUTE +
"?$CHARACTER_ID=$characterSheetId"
fun navigationRoute(characterSheetId: String?) = "$ROUTE?$CHARACTER_ID=$characterSheetId"
fun arguments() = listOf(
navArgument(CHARACTER_ID) {
@ -25,6 +25,7 @@ object CharacterSheetEditDestination {
},
)
@Stable
data class Argument(
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.NavHostController
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 {
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.NavHostController
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 {
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.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
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.destination.gamemaster.GMActionDestination
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.composableGameMasterCharacterPage
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.navigateToGameMasterCharacterPage
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.campaign.player.detail.CharacterDetailViewModel
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.screen.gamemaster.common.GMTab
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMTabUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaSwitchColors
import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res
@ -175,6 +163,7 @@ private fun GameMasterContent(
composableGameMasterActionPage()
composableGameMasterCharacterPage()
composableGameMasterAlterationPage()
composableGameMasterAlterationEditPage()
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.clickable
@ -6,10 +6,8 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.minimumInteractiveComponentSize
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.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMAction
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
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.clickable
@ -26,6 +26,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
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 lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__delete
@ -37,7 +39,7 @@ import org.jetbrains.compose.resources.stringResource
data class GMAlterationUio(
val alterationId: String,
val label: String,
val tags: List<GMTagItemUio>,
val tags: List<GMTagUio>,
)
@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.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.model.alteration.Alteration
import com.pixelized.shared.lwa.model.tag.Tag
import java.text.Collator
class GMAlterationFactory {
class GMAlterationFactory(
private val tagFactory: GMTagFactory,
) {
fun filterAlteration(
alterations: Collection<Alteration>,
@ -27,6 +29,7 @@ class GMAlterationFactory {
fun convertToGMAlterationUio(
alterations: List<Alteration>,
tags: Map<String, Tag>,
selectedTagId: String?,
): List<GMAlterationUio> {
return alterations
@ -34,28 +37,16 @@ class GMAlterationFactory {
GMAlterationUio(
alterationId = alteration.id,
label = alteration.metadata.name,
tags = alteration.tags.map { tag ->
GMTagItemUio(
id = tag,
label = tag,
highlight = tag == selectedTagId,
)
tags = alteration.tags.mapNotNull {
tags[it]?.let { tag ->
tagFactory.convertToGMTagItemUio(
tag = tag,
selectedTagId = selectedTagId,
)
}
}
)
}
.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.Box
@ -21,17 +21,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMAlteration
import com.pixelized.desktop.lwa.ui.screen.gamemaster.items.GMAlterationUio
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.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterAlterationEditPage
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMFilterHeader
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
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.game_master__alteration__create
import lwacharactersheet.composeapp.generated.resources.game_master__create_character_sheet
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@ -40,8 +43,11 @@ import org.koin.compose.viewmodel.koinViewModel
fun GMAlterationPage(
viewModel: GMAlterationViewModel = koinViewModel(),
) {
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
val alterations = viewModel.alterations.collectAsState()
val tags = viewModel.alterationTags.collectAsState()
val tags = viewModel.tags.collectAsState()
Box {
GMAlterationContent(
@ -50,9 +56,17 @@ fun GMAlterationPage(
tags = tags,
alterations = alterations,
onTag = viewModel::onTag,
onAlterationEdit = { },
onAlterationDelete = { },
onAlterationCreate = { },
onAlterationEdit = {
screen.navigateToGameMasterAlterationEditPage(alterationId = it)
},
onAlterationDelete = {
scope.launch {
viewModel.deleteAlteration(alterationId = it)
}
},
onAlterationCreate = {
screen.navigateToGameMasterAlterationEditPage(alterationId = null)
},
)
}
}
@ -63,7 +77,7 @@ private fun GMAlterationContent(
padding: Dp = 8.dp,
spacing: Dp = 8.dp,
filter: LwaTextFieldUio,
tags: State<List<GMTagItemUio>>,
tags: State<List<GMTagUio>>,
alterations: State<List<GMAlterationUio>>,
onTag: (String) -> Unit,
onAlterationEdit: (String) -> Unit,
@ -131,7 +145,7 @@ private fun GMAlterationContent(
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__create_character_sheet),
text = stringResource(Res.string.game_master__alteration__create),
)
Icon(
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.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
@ -20,33 +20,29 @@ import lwacharactersheet.composeapp.generated.resources.game_master__character__
import org.jetbrains.compose.resources.getString
class GMAlterationViewModel(
alterationRepository: AlterationRepository,
private val alterationRepository: AlterationRepository,
alterationFactory: GMAlterationFactory,
tagFactory: GMTagFactory,
) : ViewModel() {
private val _filter = MutableStateFlow("")
private val selectedTagId = MutableStateFlow<String?>(null)
private val filterValue = MutableStateFlow("")
val filter = LwaTextFieldUio(
enable = true,
labelFlow = MutableStateFlow(runBlocking { getString(Res.string.game_master__character__filter) }),
valueFlow = _filter,
valueFlow = filterValue,
isError = MutableStateFlow(false),
placeHolderFlow = MutableStateFlow(null),
onValueChange = { _filter.value = it },
onValueChange = { filterValue.value = it },
)
@OptIn(ExperimentalCoroutinesApi::class)
private val alterationTagIds = alterationRepository.alterationFlow
.mapLatest { alterations -> alterations.values.flatMap { it.tags }.toSet().toList() }
.distinctUntilChanged()
private val selectedTagId = MutableStateFlow<String?>(null)
val alterationTags = combine(
alterationTagIds,
val tags: StateFlow<List<GMTagUio>> = combine(
alterationRepository.tagsFlow,
selectedTagId,
) { alterationTagIds, selectedTagId ->
alterationFactory.convertToGMTagItemUio(
alterationTagIds = alterationTagIds,
) { tags, selectedTagId ->
tagFactory.convertToGMTagItemUio(
tags = tags.values,
selectedTagId = selectedTagId,
)
}.stateIn(
@ -55,22 +51,20 @@ class GMAlterationViewModel(
initialValue = emptyList(),
)
@OptIn(ExperimentalCoroutinesApi::class)
val alterations = combine(
val alterations: StateFlow<List<GMAlterationUio>> = combine(
alterationRepository.alterationFlow,
alterationRepository.tagsFlow,
filter.valueFlow.map { it.unAccent() },
selectedTagId,
transform = { alterations, unAccentFilter, selectedTagId ->
alterationFactory.filterAlteration(
) { alterations, tags, unAccentFilter, selectedTagId ->
alterationFactory.convertToGMAlterationUio(
alterations = alterationFactory.filterAlteration(
alterations = alterations.values,
unAccentFilter = unAccentFilter,
selectedTagId = selectedTagId
)
}
).mapLatest { alterations ->
alterationFactory.convertToGMAlterationUio(
alterations = alterations,
selectedTagId = selectedTagId.value
selectedTagId = selectedTagId,
),
tags = tags,
selectedTagId = selectedTagId,
)
}.stateIn(
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.PointerMatcher
@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.onClick
import androidx.compose.material.DropdownMenu
@ -28,9 +27,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.platform.LocalLayoutDirection
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 lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character_action__add_to_group
@ -54,7 +54,7 @@ data class GMCharacterItemUio(
val characterSheetId: String,
val name: String,
val level: Int,
val tags: List<GMTagItemUio>,
val tags: List<GMTagUio>,
val actions: List<Action>,
) {
@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.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.rememberTransitionAnimation
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.items.GMCharacterItemUio
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.screen.gamemaster.common.GMFilterHeader
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
@ -163,7 +161,7 @@ fun GMCharacterContent(
padding: Dp = 8.dp,
spacing: Dp = 8.dp,
filter: LwaTextFieldUio,
tags: State<List<GMTagItemUio>>,
tags: State<List<GMTagUio>>,
characters: State<List<GMCharacterItemUio>>,
onTag: (String) -> 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.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.network.NetworkRepository
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.items.GMTagItemUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action
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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import lwacharactersheet.composeapp.generated.resources.Res
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
class GMCharacterViewModel(
@ -27,41 +27,53 @@ class GMCharacterViewModel(
private val campaignRepository: CampaignRepository,
characterSheetRepository: CharacterSheetRepository,
private val factory: GMCharacterFactory,
private val tagFactory: GMTagFactory,
) : ViewModel() {
private val _filter = MutableStateFlow("")
private val selectedTagId = MutableStateFlow<String?>(null)
private val filterValue = MutableStateFlow("")
val filter = LwaTextFieldUio(
enable = true,
labelFlow = MutableStateFlow(runBlocking { getString(Res.string.game_master__character__filter) }),
valueFlow = _filter,
valueFlow = filterValue,
isError = MutableStateFlow(false),
placeHolderFlow = MutableStateFlow(null),
onValueChange = { _filter.value = it },
onValueChange = { filterValue.value = it },
)
private val _tags = MutableStateFlow(
mapOf(
GMCharacterFactory.PLAYER_ID to false,
GMCharacterFactory.NPC_ID to false
val tags = combine(
characterSheetRepository.tagsFlow,
selectedTagId,
) { 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(
scope = viewModelScope,
started = SharingStarted.Eagerly,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
val characters = combine(
campaignRepository.campaignFlow,
characterSheetRepository.characterSheetPreviewFlow,
filter.valueFlow,
_tags,
factory::convertToGMCharacterPreviewUio,
).stateIn(
filter.valueFlow.map { it.unAccent() },
tags,
selectedTagId,
) { 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,
started = SharingStarted.Eagerly,
initialValue = emptyList(),
@ -106,8 +118,11 @@ class GMCharacterViewModel(
fun onTag(
id: String,
) {
_tags.value = _tags.value.toMutableMap().also {
it[id] = it.getOrPut(id) { true }.not()
selectedTagId.update {
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.fadeIn
@ -28,6 +28,8 @@ 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.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 kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
@ -41,7 +43,7 @@ fun GMFilterHeader(
padding: Dp = 16.dp,
spacing: Dp = 8.dp,
filter: LwaTextFieldUio,
tags: State<List<GMTagItemUio>>,
tags: State<List<GMTagUio>>,
onTag: (String) -> Unit,
) {
val scope = rememberCoroutineScope()
@ -54,7 +56,6 @@ fun GMFilterHeader(
field = filter,
trailingIcon = {
val value = filter.valueFlow.collectAsState()
AnimatedVisibility(
visible = value.value.isNotBlank(),
enter = fadeIn(),
@ -72,28 +73,37 @@ fun GMFilterHeader(
}
}
)
LazyRow(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
lazyListState.scrollBy(-delta)
}
},
),
state = lazyListState,
contentPadding = remember(padding, spacing) {
PaddingValues(horizontal = padding, vertical = spacing)
},
horizontalArrangement = Arrangement.spacedBy(space = spacing),
AnimatedVisibility(
visible = tags.value.isNotEmpty(),
enter = fadeIn(),
exit = fadeOut(),
) {
items(
items = tags.value,
) { tag ->
GMTag(
tag = tag,
onTag = { onTag(tag.id) },
)
LazyRow(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
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.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.foundation.clickable
@ -19,7 +19,7 @@ import com.pixelized.desktop.lwa.ui.theme.lwa
@Stable
data class GMTagItemUio(
data class GMTagUio(
val id: String,
val label: String,
val highlight: Boolean,
@ -36,7 +36,7 @@ fun GMTag(
padding: PaddingValues = GmTagDefault.padding,
shape: Shape = CircleShape,
elevation: Dp = 2.dp,
tag: GMTagItemUio,
tag: GMTagUio,
onTag: (() -> Unit)? = null,
) {
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 org.junit.Test
class SkillNormalizerUseCaseText {
class SkillUioNormalizerUseCaseText {
@Test
fun testNormalization() {

View file

@ -4,7 +4,7 @@ import com.pixelized.shared.lwa.usecase.SkillStepUseCase
import com.pixelized.shared.lwa.usecase.SkillStepUseCase.SkillStep
import org.junit.Test
class SkillStepUseCaseTest {
class SkillUioStepUseCaseTest {
@Test
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.character.CharacterSheetService
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.shared.lwa.utils.PathProvider
import org.koin.core.module.dsl.createdAtStart
@ -35,6 +36,7 @@ val storeDependencies
singleOf(::CharacterSheetStore)
singleOf(::CampaignStore)
singleOf(::AlterationStore)
singleOf(::TagStore)
}
val serviceDependencies

View file

@ -1,6 +1,9 @@
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.AlterationJsonFactory
import com.pixelized.shared.lwa.model.tag.TagJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -9,25 +12,45 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class AlterationService(
store: AlterationStore,
private val alterationStore: AlterationStore,
tagStore: TagStore,
factory: AlterationJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val alterationsFlow = store.alterationsFlow()
private val alterationHashFlow = alterationsFlow
.map { data -> data.associateBy { it.id } }
private val alterationHashFlow = alterationStore.alterationsFlow()
.map { alterations -> alterations.associate { it.id to factory.convertToJson(it) } }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyMap()
)
private val alterationTags = tagStore.alterationTags()
.map { it.values.toList() }
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = emptyList()
)
fun alterations(): List<AlterationJson> {
return alterationsFlow.value
return alterationHashFlow.value.values.toList()
}
fun tags(): List<TagJson> {
return alterationTags.value
}
fun alteration(alterationId: String): AlterationJson? {
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
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.alteration.AlterationJsonFactory
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.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
class AlterationStore(
private val pathProvider: PathProvider,
private val factory: AlterationJsonFactory,
private val json: Json,
) {
private val directory = File(pathProvider.alterationsPath()).also { it.mkdirs() }
private val alterationsFlow = MutableStateFlow<List<AlterationJson>>(emptyList())
private val alterationFlow = MutableStateFlow<List<Alteration>>(emptyList())
init {
// 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() {
alterationsFlow.value = try {
alterationFlow.value = try {
loadAlterations()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
@ -42,7 +47,7 @@ class AlterationStore(
FileReadException::class,
JsonConversionException::class,
)
private fun loadAlterations(): List<AlterationJson> {
private fun loadAlterations(): List<Alteration> {
return directory
.listFiles()
?.mapNotNull { file ->
@ -56,7 +61,8 @@ class AlterationStore(
return@mapNotNull null
}
try {
this.json.decodeFromString<AlterationJson>(json)
val data = this.json.decodeFromString<AlterationJson>(json)
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
@ -64,6 +70,73 @@ class AlterationStore(
?: 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)
class JsonConversionException(root: Exception) : AlterationStoreException(root)
class FileWriteException(root: Exception) : AlterationStoreException(root)

View file

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

View file

@ -76,7 +76,7 @@ class CharacterSheetStore(
)
fun save(sheet: CharacterSheet) {
// convert the character sheet into json format.
val json = try {
val data = try {
factory.convertToJson(sheet = sheet).let(json::encodeToString)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
@ -85,7 +85,7 @@ class CharacterSheetStore(
try {
val file = characterSheetFile(id = sheet.id)
file.writeText(
text = json,
text = data,
charset = Charsets.UTF_8,
)
} 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
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.getAlterationTags
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.getCampaign
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.character.deleteCharacter
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.putCharacter
import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration
@ -126,6 +130,18 @@ class LocalServer {
path = "/detail",
body = engine.getAlteration(),
)
get(
path = "/tags",
body = engine.getAlterationTags(),
)
put(
path = "/update",
body = engine.putAlteration()
)
delete(
path = "/delete",
body = engine.deleteAlteration()
)
}
route(
path = "/character",
@ -134,6 +150,10 @@ class LocalServer {
path = "/all",
body = engine.getCharacters(),
)
get(
path = "/tags",
body = engine.getCharacterTags(),
)
get(
path = "/detail",
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
)
// Remove the character fom the campaign if needed.
// TODO probably useless because all data will not be cleaned up (all campaign / screnes)
campaignService.removeInstance(
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.characterSheet.factory.CharacterSheetJsonFactory
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.expression.ExpressionParser
import com.pixelized.shared.lwa.parser.word.WordParser
@ -45,6 +46,7 @@ val factoryDependencies
factoryOf(::CampaignJsonV2Factory)
factoryOf(::AlteredCharacterSheetFactory)
factoryOf(::AlterationJsonFactory)
factoryOf(::TagJsonFactory)
}
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 characterSheetId: String,
) : 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/"
}
}
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/"
}
}
}