Merge branch 'feature/errorManagement' into 'main'

Feature/error management

See merge request pixelized/LwaCharacterSheet!6
This commit is contained in:
Thomas Andres Gomez 2025-04-01 20:20:45 +00:00
commit 2eabb4f5e4
74 changed files with 1705 additions and 1128 deletions

View file

@ -242,6 +242,7 @@
<string name="game_master__character_action__add_to_npc">Ajouter aux Npcs</string>
<string name="game_master__character_action__remove_from_npc">Retirer des Npcs</string>
<string name="game_master__create_character_sheet">Créer un personnage</string>
<string name="game_master__alteration__title">Édition d'Altération</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>

View file

@ -1,5 +1,8 @@
package com.pixelized.desktop.lwa
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
@ -10,11 +13,13 @@ import androidx.compose.material.SnackbarDefaults
import androidx.compose.material.SnackbarDuration
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
@ -47,6 +52,7 @@ import com.pixelized.desktop.lwa.ui.navigation.window.destination.RollHistoryWin
import com.pixelized.desktop.lwa.ui.navigation.window.rememberMaxWindowHeight
import com.pixelized.desktop.lwa.ui.overlay.roll.RollHostState
import com.pixelized.desktop.lwa.ui.screen.characterSheet.CharacterSheetMainNavHost
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterNavHost
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterScreen
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryPage
import com.pixelized.desktop.lwa.ui.theme.LwaTheme
@ -146,7 +152,7 @@ private fun MainWindowScreen(
}
LwaScaffold(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize()
) {
MainNavHost()
@ -179,7 +185,7 @@ private fun WindowsHandler(
is RollHistoryWindow -> RollHistoryPage()
is GameMasterWindow -> LwaScaffold {
GameMasterScreen()
GameMasterNavHost()
}
}
}

View file

@ -6,6 +6,7 @@ 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.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@ -19,6 +20,7 @@ class DataSyncViewModel(
private val characterRepository: CharacterSheetRepository,
private val alterationRepository: AlterationRepository,
private val campaignRepository: CampaignRepository,
private val tagRepository: TagRepository,
private val settingsRepository: SettingsRepository,
private val networkRepository: NetworkRepository,
) : ViewModel() {
@ -40,10 +42,10 @@ class DataSyncViewModel(
networkRepository.status
.filter { status -> status == NetworkRepository.Status.CONNECTED }
.onEach {
tagRepository.updateAlterationTags()
alterationRepository.updateAlterations()
alterationRepository.updateTags()
tagRepository.updateCharacterTags()
characterRepository.updateCharacterPreviews()
characterRepository.updateTags()
campaignRepository.updateCampaign()
}
.launchIn(this)
@ -54,9 +56,8 @@ class DataSyncViewModel(
.distinctUntilChanged()
.onEach { instances ->
instances.forEach { characterSheetId ->
characterRepository.characterDetail(
characterRepository.updateCharacterSheet(
characterSheetId = characterSheetId,
forceUpdate = true,
)
}
}

View file

@ -13,6 +13,8 @@ import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsFactory
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsStore
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.repository.tag.TagStore
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterDetailCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogFactory
import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterSheetDiminishedDialogFactory
@ -37,9 +39,9 @@ import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.common.SkillField
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.edit.GMAlterationEditFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit.GMAlterationEditViewModel
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
@ -100,6 +102,7 @@ val storeDependencies
singleOf(::SettingsStore)
singleOf(::AlterationStore)
singleOf(::CampaignStore)
singleOf(::TagStore)
}
val repositoryDependencies
@ -110,6 +113,7 @@ val repositoryDependencies
singleOf(::SettingsRepository)
singleOf(::AlterationRepository)
singleOf(::CampaignRepository)
singleOf(::TagRepository)
}
val factoryDependencies

View file

@ -5,83 +5,89 @@ 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 com.pixelized.shared.lwa.protocol.rest.APIResponse
interface LwaClient {
// Alteration
suspend fun alterations(): List<AlterationJson>
suspend fun getAlterations(): APIResponse<List<AlterationJson>>
suspend fun alterations(
suspend fun getAlteration(
alterationId: String,
): AlterationJson?
): APIResponse<AlterationJson?>
suspend fun updateAlteration(
alterationJson: AlterationJson
)
suspend fun putAlteration(
alterationJson: AlterationJson,
create: Boolean,
): APIResponse<Unit>
suspend fun deleteAlteration(
alterationId: String,
)
): APIResponse<Unit>
suspend fun alterationTags(): List<TagJson>
suspend fun getAlterationTags(): APIResponse<List<TagJson>>
// Campaign
suspend fun campaign(): CampaignJson
suspend fun getCampaign(): APIResponse<CampaignJson>
suspend fun campaignAddCharacter(
suspend fun putCampaignCharacter(
characterSheetId: String,
)
): APIResponse<Unit>
suspend fun campaignRemoveCharacter(
suspend fun removeCampaignCharacter(
characterSheetId: String,
)
): APIResponse<Unit>
suspend fun campaignAddNpc(
suspend fun putCampaignNpc(
characterSheetId: String,
)
): APIResponse<Unit>
suspend fun campaignRemoveNpc(
suspend fun removeCampaignNpc(
characterSheetId: String,
)
): APIResponse<Unit>
// Character
suspend fun characters(): List<CharacterPreviewJson>
suspend fun getCharacters(): APIResponse<List<CharacterPreviewJson>>
suspend fun characterTags(): List<TagJson>
suspend fun getCharacterTags(): APIResponse<List<TagJson>>
suspend fun character(
suspend fun getCharacter(
characterSheetId: String,
): CharacterSheetJson
): APIResponse<CharacterSheetJson>
suspend fun updateCharacter(
suspend fun putCharacter(
sheet: CharacterSheetJson,
)
): APIResponse<Unit>
suspend fun updateCharacterDamage(
suspend fun putCharacterDamage(
characterSheetId: String,
damage: Int,
)
): APIResponse<Unit>
suspend fun updateCharacterFatigue(
suspend fun putCharacterFatigue(
characterSheetId: String,
fatigue: Int,
)
): APIResponse<Unit>
suspend fun updateCharacterDiminished(
suspend fun putCharacterDiminished(
characterSheetId: String,
diminished: Int,
)
): APIResponse<Unit>
suspend fun updateCharacterAlteration(
suspend fun putCharacterAlteration(
characterSheetId: String,
alterationId: Int,
active: Boolean,
)
): APIResponse<Unit>
suspend fun deleteCharacterSheet(
characterSheetId: String,
)
): APIResponse<Unit>
companion object {
fun error(error: APIResponse<*>): Nothing = throw LwaNetworkException(error)
}
}

View file

@ -5,6 +5,7 @@ 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.APIResponse
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@ -21,114 +22,134 @@ class LwaClientImpl(
) : LwaClient {
private val root = setting.settings().network.root
override suspend fun alterations(): List<AlterationJson> = client
@Throws
override suspend fun getAlterations(): APIResponse<List<AlterationJson>> = client
.get("$root/alteration/all")
.body()
override suspend fun alterations(
@Throws
override suspend fun getAlteration(
alterationId: String,
): AlterationJson? = client
): APIResponse<AlterationJson?> = client
.get("$root/alteration/detail?alterationId=$alterationId")
.body()
override suspend fun updateAlteration(
alterationJson: AlterationJson
@Throws
override suspend fun putAlteration(
alterationJson: AlterationJson,
create: Boolean,
) = client
.put("$root/alteration/update") {
.put("$root/alteration/update?create=$create") {
contentType(ContentType.Application.Json)
setBody(alterationJson)
}
.body<Unit>()
.body<APIResponse<Unit>>()
@Throws
override suspend fun deleteAlteration(alterationId: String) = client
.delete("$root/alteration/delete?alterationId=$alterationId")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun alterationTags(): List<TagJson> = client
@Throws
override suspend fun getAlterationTags(): APIResponse<List<TagJson>> = client
.get("$root/alteration/tags")
.body()
override suspend fun campaign(): CampaignJson = client
@Throws
override suspend fun getCampaign(): APIResponse<CampaignJson> = client
.get("$root/campaign")
.body()
override suspend fun campaignAddCharacter(
@Throws
override suspend fun putCampaignCharacter(
characterSheetId: String,
) = client
.put("$root/campaign/character/add?characterSheetId=$characterSheetId")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun campaignRemoveCharacter(
@Throws
override suspend fun removeCampaignCharacter(
characterSheetId: String,
) = client
.delete("$root/campaign/character/delete?characterSheetId=$characterSheetId")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun campaignAddNpc(
@Throws
override suspend fun putCampaignNpc(
characterSheetId: String,
) = client
.put("$root/campaign/npc/add?characterSheetId=$characterSheetId")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun campaignRemoveNpc(
@Throws
override suspend fun removeCampaignNpc(
characterSheetId: String,
) = client
.delete("$root/campaign/npc/delete?characterSheetId=$characterSheetId")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun characters(): List<CharacterPreviewJson> = client
@Throws
override suspend fun getCharacters(): APIResponse<List<CharacterPreviewJson>> = client
.get("$root/character/all")
.body()
override suspend fun characterTags(): List<TagJson> = client
@Throws
override suspend fun getCharacterTags(): APIResponse<List<TagJson>> = client
.get("$root/character/tags")
.body()
override suspend fun character(
@Throws
override suspend fun getCharacter(
characterSheetId: String,
): CharacterSheetJson = client
): APIResponse<CharacterSheetJson> = client
.get("$root/character/detail?characterSheetId=$characterSheetId")
.body()
override suspend fun updateCharacter(
@Throws
override suspend fun putCharacter(
sheet: CharacterSheetJson,
) = client
.put("$root/character/update/sheet") {
contentType(ContentType.Application.Json)
setBody(sheet)
}
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun updateCharacterDamage(
@Throws
override suspend fun putCharacterDamage(
characterSheetId: String,
damage: Int,
) = client
.put("$root/character/update/damage?characterSheetId=$characterSheetId&damage=$damage")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun updateCharacterFatigue(
@Throws
override suspend fun putCharacterFatigue(
characterSheetId: String,
fatigue: Int,
) = client
.put("$root/character/update/fatigue?characterSheetId=$characterSheetId&fatigue=$fatigue")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun updateCharacterDiminished(
@Throws
override suspend fun putCharacterDiminished(
characterSheetId: String,
diminished: Int,
) = client
.put("$root/character/update/diminished?characterSheetId=$characterSheetId&diminished=$diminished")
.body<Unit>()
.body<APIResponse<Unit>>()
override suspend fun updateCharacterAlteration(
@Throws
override suspend fun putCharacterAlteration(
characterSheetId: String,
alterationId: Int,
active: Boolean,
) = client
.put("$root/character/update/alteration?characterSheetId=$characterSheetId&alterationId=$alterationId&active=$active")
.body<Unit>()
.body<APIResponse<Unit>>()
@Throws
override suspend fun deleteCharacterSheet(characterSheetId: String) = client
.delete("$root/character/delete?characterSheetId=$characterSheetId")
.body<Unit>()
.body<APIResponse<Unit>>()
}

View file

@ -0,0 +1,15 @@
package com.pixelized.desktop.lwa.network
import com.pixelized.shared.lwa.protocol.rest.APIResponse
class LwaNetworkException(
val status: Int,
val code: APIResponse.ErrorCode?,
message: String,
) : Exception(message) {
constructor(error: APIResponse<*>) : this(
status = error.status,
code = error.code,
message = error.message ?: "An unknown error occurred"
)
}

View file

@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.stateIn
class AlterationRepository(
private val alterationStore: AlterationStore,
campaignRepository: CampaignRepository,
characterRepository: CharacterSheetRepository,
) {
@ -26,8 +27,6 @@ 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>>.
@ -66,11 +65,7 @@ class AlterationRepository(
)
suspend fun updateAlterations() {
alterationStore.updateAlterations()
}
suspend fun updateTags() {
alterationStore.updateTags()
alterationStore.updateAlterationsFlow()
}
fun alteration(
@ -91,10 +86,15 @@ class AlterationRepository(
return activeAlterationMapFlow.map { it[characterSheetId] ?: emptyMap() }
}
@Throws
suspend fun updateAlteration(
alteration: Alteration
alteration: Alteration,
create: Boolean,
) {
alterationStore.putAlteration(alteration)
alterationStore.putAlteration(
alteration = alteration,
create = create,
)
}
suspend fun deleteAlteration(

View file

@ -4,8 +4,6 @@ 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
@ -17,26 +15,18 @@ 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 network: NetworkRepository,
private val factory: AlterationJsonFactory,
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)
network.data.collect(::handleMessage)
}
}
@ -44,19 +34,11 @@ class AlterationStore(
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 updateAlterationsFlow() {
_alterationsFlow.value = try {
getAlteration()
} catch (exception: Exception) {
@ -65,7 +47,7 @@ class AlterationStore(
}
}
suspend fun updateAlteration(
private suspend fun updateAlterationFlow(
alterationId: String,
) {
val alteration = try {
@ -74,9 +56,9 @@ class AlterationStore(
println(exception.message) // TODO proper exception handling
null
}
// guard case if getAlteration failed
if (alteration == null) return
// update the flow with the alteration.
_alterationsFlow.update { alterations ->
alterations.toMutableMap().also {
it[alterationId] = alteration
@ -84,60 +66,59 @@ class AlterationStore(
}
}
suspend fun updateTags() {
_tagsFlow.value = try {
getAlterationTag()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
@Throws
private suspend fun getAlteration(): Map<String, Alteration> {
val request = client.alterations()
val data = request.map { alterationFactory.convertFromJson(json = it) }
return data.associateBy { it.id }
val request = client.getAlterations()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(json = it) }
?.associateBy { it.id }
?: emptyMap()
else -> LwaClient.error(error = request)
}
}
@Throws
private suspend fun getAlteration(
alterationId: String,
): Alteration? {
val request = client.alterations(alterationId = alterationId)
return request?.let { alterationFactory.convertFromJson(json = it) }
}
@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 }
val request = client.getAlteration(alterationId = alterationId)
return when (request.success) {
true -> request.data?.let { factory.convertFromJson(json = it) }
else -> LwaClient.error(error = request)
}
}
@Throws
suspend fun putAlteration(
alteration: Alteration,
create: Boolean,
) {
client.updateAlteration(
alterationJson = alterationFactory.convertToJson(data = alteration)
val request = client.putAlteration(
alterationJson = factory.convertToJson(data = alteration),
create = create,
)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
@Throws
suspend fun deleteAlteration(
alterationId: String
alterationId: String,
) {
client.deleteAlteration(
alterationId = alterationId
)
val request = client.deleteAlteration(alterationId = alterationId)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
// region: WebSocket & data update.
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ApiSynchronisation.AlterationUpdate -> updateAlteration(
is ApiSynchronisation.AlterationUpdate -> updateAlterationFlow(
alterationId = message.alterationId,
)

View file

@ -3,33 +3,45 @@ package com.pixelized.desktop.lwa.repository.campaign
class CampaignRepository(
private val store: CampaignStore,
) {
val campaignFlow get() = store.campaignFlow
val campaignFlow = store.campaignFlow()
suspend fun updateCampaign() {
store.campaign(update = true)
store.updateCampaignFlow()
}
@Throws
suspend fun addCharacter(
characterSheetId: String,
) = store.addCharacter(
characterSheetId = characterSheetId,
)
) {
store.addCharacter(
characterSheetId = characterSheetId,
)
}
@Throws
suspend fun removeCharacter(
characterSheetId: String,
) = store.removeCharacter(
characterSheetId = characterSheetId,
)
) {
store.removeCharacter(
characterSheetId = characterSheetId,
)
}
@Throws
suspend fun addNpc(
characterSheetId: String,
) = store.addNpc(
characterSheetId = characterSheetId,
)
) {
store.addNpc(
characterSheetId = characterSheetId,
)
}
@Throws
suspend fun removeNpc(
characterSheetId: String,
) = store.removeNpc(
characterSheetId = characterSheetId,
)
) {
store.removeNpc(
characterSheetId = characterSheetId,
)
}
}

View file

@ -16,84 +16,79 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class CampaignStore(
private val client: LwaClient,
private val network: NetworkRepository,
private val factory: CampaignJsonFactory,
) {
private val _campaignFlow = MutableStateFlow(value = Campaign.empty())
val campaignFlow: StateFlow<Campaign> get() = _campaignFlow
private val client: LwaClient,
) {
private val campaignFlow = MutableStateFlow(value = Campaign.empty())
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
scope.launch {
campaign(update = true)
}
scope.launch {
network.data.collect(::handleMessage)
}
}
suspend fun campaign(update: Boolean): Campaign {
val campaign = _campaignFlow.value
if (update || campaign == Campaign.empty()) {
val data = try {
val request = client.campaign()
factory.convertFromJson(json = request)
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
Campaign.empty()
}
return _campaignFlow.update(data)
fun campaignFlow(): StateFlow<Campaign> = campaignFlow
suspend fun updateCampaignFlow() {
campaignFlow.value = try {
getCampaign()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
Campaign.empty()
}
return campaign
}
@Throws
suspend fun addCharacter(
characterSheetId: String,
) {
try {
client.campaignAddCharacter(
characterSheetId = characterSheetId
)
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
val request = client.putCampaignCharacter(characterSheetId = characterSheetId)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
@Throws
suspend fun removeCharacter(
characterSheetId: String,
) {
try {
client.campaignRemoveCharacter(
characterSheetId = characterSheetId,
)
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
val request = client.removeCampaignCharacter(characterSheetId = characterSheetId)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
@Throws
suspend fun addNpc(
characterSheetId: String,
) {
try {
client.campaignAddNpc(
characterSheetId = characterSheetId
)
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
val request = client.putCampaignNpc(characterSheetId = characterSheetId)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
@Throws
suspend fun removeNpc(
characterSheetId: String,
) {
try {
client.campaignRemoveNpc(
characterSheetId = characterSheetId,
)
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
val request = client.removeCampaignNpc(characterSheetId = characterSheetId)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
@Throws
private suspend fun getCampaign(): Campaign {
val request = client.getCampaign()
return when (request.success) {
true -> request.data
?.let { factory.convertFromJson(json = it) }
?: Campaign.empty()
else -> LwaClient.error(error = request)
}
}
@ -103,7 +98,7 @@ class CampaignStore(
when (message) {
is CampaignEvent -> {
when (message) {
is CampaignEvent.CharacterAdded -> _campaignFlow.update { campaign ->
is CampaignEvent.CharacterAdded -> campaignFlow.update { campaign ->
campaign.copy(
characters = campaign.characters.toMutableSet().also {
it.add(message.characterSheetId)
@ -111,7 +106,7 @@ class CampaignStore(
)
}
is CampaignEvent.CharacterRemoved -> _campaignFlow.update { campaign ->
is CampaignEvent.CharacterRemoved -> campaignFlow.update { campaign ->
campaign.copy(
characters = campaign.characters.toMutableSet().also {
it.remove(message.characterSheetId)
@ -119,7 +114,7 @@ class CampaignStore(
)
}
is CampaignEvent.NpcAdded -> _campaignFlow.update { campaign ->
is CampaignEvent.NpcAdded -> campaignFlow.update { campaign ->
campaign.copy(
npcs = campaign.npcs.toMutableSet().also {
it.add(message.characterSheetId)
@ -127,7 +122,7 @@ class CampaignStore(
)
}
is CampaignEvent.NpcRemoved -> _campaignFlow.update { campaign ->
is CampaignEvent.NpcRemoved -> campaignFlow.update { campaign ->
campaign.copy(
npcs = campaign.npcs.toMutableSet().also {
it.remove(message.characterSheetId)
@ -135,7 +130,7 @@ class CampaignStore(
)
}
is CampaignEvent.UpdateScene -> _campaignFlow.update { campaign ->
is CampaignEvent.UpdateScene -> campaignFlow.update { campaign ->
campaign.copy(
scene = Campaign.Scene(name = message.name)
)
@ -145,17 +140,17 @@ class CampaignStore(
is GameMasterEvent -> when (message) {
is GameMasterEvent.ToggleNpc -> {
_campaignFlow.value = _campaignFlow.value.copy(
options = _campaignFlow.value.options.copy(
showNpcs = _campaignFlow.value.options.showNpcs.not()
campaignFlow.value = campaignFlow.value.copy(
options = campaignFlow.value.options.copy(
showNpcs = campaignFlow.value.options.showNpcs.not()
)
)
}
is GameMasterEvent.TogglePlayer -> {
_campaignFlow.value = _campaignFlow.value.copy(
options = _campaignFlow.value.options.copy(
showParty = _campaignFlow.value.options.showParty.not()
campaignFlow.value = campaignFlow.value.copy(
options = campaignFlow.value.options.copy(
showParty = campaignFlow.value.options.showParty.not()
)
)
}
@ -168,9 +163,4 @@ class CampaignStore(
}
// endregion
private fun MutableStateFlow<Campaign>.update(campaign: Campaign): Campaign {
value = campaign
return campaign
}
}

View file

@ -18,46 +18,27 @@ class CharacterSheetRepository(
val characterSheetPreviewFlow get() = store.previewFlow
val characterDetailFlow get() = store.detailFlow
val tagsFlow get() = store.tagsFlow
suspend fun updateCharacterPreviews() {
store.updateCharactersPreview()
store.updateCharactersPreviewFlow()
}
suspend fun updateTags() {
store.updateTags()
suspend fun updateCharacterSheet(characterSheetId: String) {
store.updateCharacterSheetDetailFlow(characterSheetId = characterSheetId)
}
fun characterPreview(characterId: String?): CharacterSheetPreview? {
return characterSheetPreviewFlow.value.firstOrNull { it.characterSheetId == characterId }
}
suspend fun characterDetail(
fun characterDetail(
characterSheetId: String?,
forceUpdate: Boolean = false,
): CharacterSheet? {
return try {
characterSheetId?.let {
store.getCharacterSheet(
characterSheetId = it,
forceUpdate = forceUpdate
)
}
} catch (exception: Exception) {
null
}
return store.detailFlow.value[characterSheetId]
}
fun characterDetailFlow(
characterSheetId: String?,
): StateFlow<CharacterSheet?> {
val initial = store.detailFlow.value[characterSheetId]
if (initial == null) {
scope.launch { characterDetail(characterSheetId = characterSheetId) }
}
return store.detailFlow
.map { sheets ->
sheets[characterSheetId]
@ -65,16 +46,18 @@ class CharacterSheetRepository(
.stateIn(
scope = scope,
started = SharingStarted.Eagerly,
initialValue = initial,
initialValue = store.detailFlow.value[characterSheetId],
)
}
@Throws
suspend fun updateCharacter(
sheet: CharacterSheet,
) {
store.updateCharacterSheet(sheet = sheet)
}
@Throws
suspend fun deleteCharacter(
characterSheetId: String,
) {

View file

@ -6,12 +6,9 @@ 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
import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -22,10 +19,9 @@ import kotlinx.coroutines.launch
class CharacterSheetStore(
private val alterationStore: AlterationStore,
private val client: LwaClient,
private val network: NetworkRepository,
private val factory: CharacterSheetJsonFactory,
private val tagFactory: TagJsonFactory,
private val client: LwaClient,
) {
private val _previewFlow = MutableStateFlow<List<CharacterSheetPreview>>(value = emptyList())
val previewFlow: StateFlow<List<CharacterSheetPreview>> get() = _previewFlow
@ -33,16 +29,8 @@ 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 {
updateCharactersPreview()
updateTags()
}
// data update through WebSocket.
scope.launch {
network.data.collect(::handleMessage)
@ -51,71 +39,73 @@ class CharacterSheetStore(
// region Rest
suspend fun updateCharactersPreview(): List<CharacterSheetPreview> {
val request = try {
client.characters()
suspend fun updateCharactersPreviewFlow() {
_previewFlow.value = try {
getCharacters()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
println(exception.message) // TODO proper exception handling
emptyList()
}
val characters = request.map {
factory.convertFromJson(it)
}
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 updateCharacterSheetDetailFlow(
characterSheetId: String,
forceUpdate: Boolean = false,
): CharacterSheet? {
val character = _detailFlow.value[characterSheetId]
if (forceUpdate || character == null) {
try {
val request = client.character(characterSheetId = characterSheetId)
val data = factory.convertFromJson(json = request)
return _detailFlow.update(data)
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
return null
) {
val characterSheet = try {
getCharacterSheet(characterSheetId = characterSheetId)
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
null
}
if (characterSheet == null) return
_detailFlow.update { data ->
data.toMutableMap().also {
it[characterSheetId] = characterSheet
}
}
return character
}
@Throws
suspend fun getCharacters(): List<CharacterSheetPreview> {
val request = client.getCharacters()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(it) }
?: emptyList()
else -> LwaClient.error(error = request)
}
}
@Throws
suspend fun getCharacterSheet(
characterSheetId: String,
): CharacterSheet {
val request = client.getCharacter(characterSheetId = characterSheetId)
return when (request.success) {
true -> request.data!!.let { factory.convertFromJson(json = it) }
else -> LwaClient.error(error = request)
}
}
@Throws
suspend fun updateCharacterSheet(
sheet: CharacterSheet,
) {
val json = factory.convertToJson(sheet = sheet)
try {
client.updateCharacter(sheet = json)
} catch (exception: Exception) {
println(exception) // TODO
val request = client.putCharacter(sheet = json)
if (request.success) {
LwaClient.error(error = request)
}
_detailFlow.update(sheet = sheet)
}
@Throws
suspend fun deleteCharacterSheet(
characterSheetId: String,
) {
try {
client.deleteCharacterSheet(characterSheetId = characterSheetId)
} catch (exception: Exception) {
println(exception) // TODO
val request = client.deleteCharacterSheet(characterSheetId = characterSheetId)
if (request.success) {
LwaClient.error(error = request)
}
}
@ -124,32 +114,35 @@ class CharacterSheetStore(
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ApiSynchronisation -> when (message) {
is ApiSynchronisation.CharacterSheetUpdate -> {
getCharacterSheet(
characterSheetId = message.characterSheetId,
forceUpdate = true,
)
if (_previewFlow.value.firstOrNull { it.characterSheetId == message.characterSheetId } == null) {
updateCharactersPreview()
}
}
is ApiSynchronisation.CharacterSheetDelete -> {
_previewFlow.update { previews ->
previews.toMutableList().also { sheet ->
sheet.removeIf {
it.characterSheetId == message.characterSheetId
}
is ApiSynchronisation -> try {
when (message) {
is ApiSynchronisation.CharacterSheetUpdate -> {
_detailFlow.update(
sheet = getCharacterSheet(characterSheetId = message.characterSheetId)
)
if (_previewFlow.value.firstOrNull { it.characterSheetId == message.characterSheetId } == null) {
_previewFlow.value = getCharacters()
}
}
_detailFlow.update { sheets ->
sheets.toMutableMap().also { it.remove(message.characterSheetId) }
}
}
is ApiSynchronisation.AlterationUpdate -> Unit
is ApiSynchronisation.AlterationDelete -> Unit
is ApiSynchronisation.CharacterSheetDelete -> {
_previewFlow.update { previews ->
previews.toMutableList().also { sheet ->
sheet.removeIf {
it.characterSheetId == message.characterSheetId
}
}
}
_detailFlow.update { sheets ->
sheets.toMutableMap().also { it.remove(message.characterSheetId) }
}
}
is ApiSynchronisation.AlterationUpdate -> Unit
is ApiSynchronisation.AlterationDelete -> Unit
}
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}
is CharacterSheetEvent -> when (message) {
@ -190,7 +183,7 @@ class CharacterSheetStore(
skillId: String,
used: Boolean,
) {
val sheet = getCharacterSheet(characterSheetId = characterSheetId) ?: return
val sheet = getCharacterSheet(characterSheetId = characterSheetId)
val character = sheet.copy(
commonSkills = sheet.commonSkills.map { skill ->
skill.takeIf { skill.id == skillId }?.copy(used = used) ?: skill
@ -211,7 +204,7 @@ class CharacterSheetStore(
active: Boolean,
) {
if (alterationStore.alteration(alterationId = alterationId) == null) return
val sheet = getCharacterSheet(characterSheetId = characterSheetId) ?: return
val sheet = getCharacterSheet(characterSheetId = characterSheetId)
val containAlteration = sheet.alterations.contains(alterationId)
@ -233,7 +226,7 @@ class CharacterSheetStore(
characterSheetId: String,
damage: Int,
) {
val sheet = getCharacterSheet(characterSheetId = characterSheetId) ?: return
val sheet = getCharacterSheet(characterSheetId = characterSheetId)
_detailFlow.update(sheet.copy(damage = damage))
}
@ -241,7 +234,7 @@ class CharacterSheetStore(
characterSheetId: String,
diminished: Int,
) {
val sheet = getCharacterSheet(characterSheetId = characterSheetId) ?: return
val sheet = getCharacterSheet(characterSheetId = characterSheetId)
_detailFlow.update(sheet.copy(diminished = diminished))
}
@ -249,18 +242,12 @@ class CharacterSheetStore(
characterSheetId: String,
fatigue: Int,
) {
val sheet = getCharacterSheet(characterSheetId = characterSheetId) ?: return
val sheet = getCharacterSheet(characterSheetId = characterSheetId)
_detailFlow.update(sheet.copy(fatigue = fatigue))
}
// endregion
private fun MutableStateFlow<List<CharacterSheetPreview>>.update(
previews: List<CharacterSheetPreview>,
): List<CharacterSheetPreview> {
value = previews
return previews
}
private fun MutableStateFlow<Map<String, CharacterSheet>>.update(
sheet: CharacterSheet,

View file

@ -0,0 +1,16 @@
package com.pixelized.desktop.lwa.repository.tag
import com.pixelized.shared.lwa.model.tag.Tag
import kotlinx.coroutines.flow.StateFlow
class TagRepository(
private val store: TagStore,
) {
suspend fun updateCharacterTags() = store.updateCharacterTagsFlow()
suspend fun updateAlterationTags() = store.updateAlterationTagsFlow()
fun charactersTagFlow(): StateFlow<Map<String, Tag>> = store.charactersTagFlow()
fun alterationsTagFlow(): StateFlow<Map<String, Tag>> = store.alterationsTagFlow()
}

View file

@ -0,0 +1,79 @@
package com.pixelized.desktop.lwa.repository.tag
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.shared.lwa.model.tag.Tag
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class TagStore(
private val factory: TagJsonFactory,
private val client: LwaClient,
) {
private val characterTagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
private val alterationTagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
fun charactersTagFlow(): StateFlow<Map<String, Tag>> = characterTagsFlow
fun characters(): Collection<Tag> {
return characterTagsFlow.value.values
}
fun character(tagId: String): Tag? {
return characterTagsFlow.value[tagId]
}
fun alterationsTagFlow(): StateFlow<Map<String, Tag>> = alterationTagsFlow
fun alterations(): Collection<Tag> {
return alterationTagsFlow.value.values
}
fun alteration(tagId: String): Tag? {
return alterationTagsFlow.value[tagId]
}
suspend fun updateCharacterTagsFlow() {
characterTagsFlow.value = try {
getCharacterTag()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
suspend fun updateAlterationTagsFlow() {
alterationTagsFlow.value = try {
getAlterationTag()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
@Throws
private suspend fun getCharacterTag(): Map<String, Tag> {
val request = client.getCharacterTags()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(json = it) }
?.associateBy { it.id }
?: emptyMap()
else -> LwaClient.error(error = request)
}
}
@Throws
private suspend fun getAlterationTag(): Map<String, Tag> {
val request = client.getAlterationTags()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(json = it) }
?.associateBy { it.id }
?: emptyMap()
else -> LwaClient.error(error = request)
}
}
}

View file

@ -7,6 +7,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import com.pixelized.desktop.lwa.LocalErrorSnackHost
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.error__default__action
import org.jetbrains.compose.resources.getString
@Stable
class ErrorSnackUio(
@ -15,21 +19,21 @@ class ErrorSnackUio(
val duration: SnackbarDuration,
) {
companion object {
fun from(exception: Exception) = ErrorSnackUio(
suspend fun from(exception: Exception) = ErrorSnackUio(
message = exception.localizedMessage,
action = "Ok",
duration = SnackbarDuration.Indefinite
action = getString(Res.string.error__default__action),
duration = SnackbarDuration.Long,
)
}
}
@Composable
fun ErrorSnack(
fun ErrorSnackHandler(
snack: SnackbarHostState = LocalErrorSnackHost.current,
error: SharedFlow<ErrorSnackUio>,
) {
LaunchedEffect(Unit) {
error.collect {
error.collectLatest {
snack.showSnackbar(
message = it.message,
actionLabel = it.action,

View file

@ -1,6 +1,7 @@
package com.pixelized.desktop.lwa.ui.composable.textfield
import androidx.compose.foundation.layout.height
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.TextFieldColors
@ -9,20 +10,21 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaTextFieldColors
import com.pixelized.desktop.lwa.utils.rememberKeyboardActions
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.MutableStateFlow
@Stable
data class LwaTextFieldUio(
val enable: Boolean = true,
val isError: StateFlow<Boolean>,
val labelFlow: StateFlow<String?>?,
val valueFlow: StateFlow<String>,
val placeHolderFlow: StateFlow<String?>?,
val isError: MutableStateFlow<Boolean>,
val labelFlow: MutableStateFlow<String?>?,
val valueFlow: MutableStateFlow<String>,
val placeHolderFlow: MutableStateFlow<String?>?,
val onValueChange: (String) -> Unit,
)
@ -30,6 +32,7 @@ data class LwaTextFieldUio(
fun LwaTextField(
modifier: Modifier = Modifier,
colors: TextFieldColors = LwaTextFieldColors(),
shape: Shape = MaterialTheme.shapes.small,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
singleLine: Boolean = true,
@ -51,6 +54,7 @@ fun LwaTextField(
TextField(
modifier = localModifier.then(other = modifier),
colors = colors,
shape = shape,
keyboardActions = rememberKeyboardActions {
focus.moveFocus(FocusDirection.Next)
},

View file

@ -7,7 +7,7 @@ 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.ui.screen.gamemaster.alteration.edit.GMAlterationEditScreen
import com.pixelized.desktop.lwa.utils.extention.ARG
@Stable
@ -44,7 +44,7 @@ fun NavGraphBuilder.composableGameMasterAlterationEditPage() {
route = GMAlterationEditDestination.baseRoute(),
arguments = GMAlterationEditDestination.arguments(),
) {
GMAlterationEditPage()
GMAlterationEditScreen()
}
}

View file

@ -0,0 +1,30 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster
import androidx.compose.runtime.Stable
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.ui.screen.gamemaster.GameMasterScreen
@Stable
object GameMasterDestination {
private const val ROUTE = "GameMasterMain"
fun baseRoute() = ROUTE
@Stable
fun navigationRoute() = ROUTE
}
fun NavGraphBuilder.composableGameMasterMainPage() {
composable(
route = GameMasterDestination.baseRoute(),
) {
GameMasterScreen()
}
}
fun NavHostController.navigateToGameMasterMainPage() {
val route = GameMasterDestination.navigationRoute()
navigate(route = route)
}

View file

@ -43,7 +43,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.LocalSnackHost
import com.pixelized.desktop.lwa.ui.composable.blur.BlurContent
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnack
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
@ -148,7 +148,7 @@ fun NetworkDialog(
}
}
ErrorSnack(
ErrorSnackHandler(
error = viewModel.networkError,
)
}

View file

@ -0,0 +1,42 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.rememberNavController
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GameMasterDestination
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterAlterationEditPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterMainPage
val LocalGMScreenController = compositionLocalOf<NavHostController> {
error("GameMaster NavHost controller is not yet ready")
}
@Composable
fun GameMasterNavHost() {
val controller = rememberNavController()
CompositionLocalProvider(
LocalScreenController provides controller,
LocalGMScreenController provides rememberNavController(),
) {
Surface(
modifier = Modifier.fillMaxSize()
) {
NavHost(
modifier = Modifier.fillMaxSize(),
navController = controller,
startDestination = GameMasterDestination.navigationRoute(),
) {
composableGameMasterMainPage()
composableGameMasterAlterationEditPage()
}
}
}
}

View file

@ -17,7 +17,6 @@ import androidx.compose.material.Switch
import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
@ -28,7 +27,6 @@ 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.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
@ -52,35 +50,24 @@ import org.koin.compose.viewmodel.koinViewModel
@Composable
fun GameMasterScreen(
gameMasterViewModel: GameMasterViewModel = koinViewModel(),
controller: NavHostController = LocalGMScreenController.current,
) {
val screen = rememberNavController()
val gameMaster = gameMasterViewModel.isGameMaster.collectAsState()
CompositionLocalProvider(
LocalScreenController provides screen,
) {
Surface(
modifier = Modifier.fillMaxSize()
) {
Box {
GameMasterContent(
modifier = Modifier.fillMaxSize(),
controller = screen,
gameMaster = gameMaster,
onGameMaster = gameMasterViewModel::onGameMaster,
onTab = {
when (it) {
GMTabUio.Actions -> screen.navigateToGameMasterActionPage()
GMTabUio.Characters -> screen.navigateToGameMasterCharacterPage()
GMTabUio.Alterations -> screen.navigateToGameMasterAlterationPage()
GMTabUio.Objects -> screen.navigateToGameMasterObjectPage()
}
},
)
GameMasterContent(
modifier = Modifier.fillMaxSize(),
controller = controller,
gameMaster = gameMaster,
onGameMaster = gameMasterViewModel::onGameMaster,
onTab = {
when (it) {
GMTabUio.Actions -> controller.navigateToGameMasterActionPage()
GMTabUio.Characters -> controller.navigateToGameMasterCharacterPage()
GMTabUio.Alterations -> controller.navigateToGameMasterAlterationPage()
GMTabUio.Objects -> controller.navigateToGameMasterObjectPage()
}
}
}
},
)
}
@Composable

View file

@ -1,34 +1,39 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.shared.lwa.model.alteration.Alteration
import com.pixelized.shared.lwa.model.tag.Tag
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_description
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,
private val tagFactory: GMTagFactory,
) {
suspend fun createForm(
originId: String?,
alteration: Alteration?,
tags: Collection<Tag>,
): 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)))
val fields = MutableStateFlow(alteration?.fields?.map { createField(it) } ?: listOf(
createField(null)
))
return GMAlterationEditPageUio(
id = LwaTextFieldUio(
enable = true,
enable = originId == null,
isError = MutableStateFlow(false),
labelFlow = MutableStateFlow(getString(Res.string.game_master__alteration__edit_id)),
valueFlow = id,
@ -51,13 +56,9 @@ class GMAlterationEditFactory(
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 },
tags = tagFactory.convertToGMTagItemUio(
tags = tags,
selectedTagIds = alteration?.tags ?: emptyList(),
),
fields = fields,
)
@ -101,7 +102,7 @@ class GMAlterationEditFactory(
name = form.label.valueFlow.value,
description = form.description.valueFlow.value,
),
tags = form.tags.valueFlow.value.split(","),
tags = form.tags.filter { it.highlight }.map { it.id },
fields = form.fields.value.mapNotNull { field ->
expressionParser.parse(input = field.expression.valueFlow.value)?.let {
Alteration.Field(

View file

@ -1,9 +1,16 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -11,21 +18,31 @@ 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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
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.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.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.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -34,19 +51,27 @@ 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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler
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.navigation.screen.destination.gamemaster.GMAlterationEditDestination
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagButton
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.lwa
import kotlinx.coroutines.CoroutineScope
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.game_master__alteration__title
import lwacharactersheet.composeapp.generated.resources.ic_save_24dp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@ -57,7 +82,7 @@ data class GMAlterationEditPageUio(
val id: LwaTextFieldUio,
val label: LwaTextFieldUio,
val description: LwaTextFieldUio,
val tags: LwaTextFieldUio,
val tags: List<GMTagUio>,
val fields: MutableStateFlow<List<SkillUio>>,
) {
@Stable
@ -74,44 +99,45 @@ object GMAlterationEditPageDefault {
}
@Composable
fun GMAlterationEditPage(
fun GMAlterationEditScreen(
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()
},
)
}
}
GMAlterationEditContent(
modifier = Modifier.fillMaxSize(),
form = form,
paddings = GMAlterationEditPageDefault.paddings,
onBack = {
screen.navigateBack()
},
addField = {
scope.launch {
viewModel.addField()
}
},
removeField = viewModel::removeField,
onSave = {
scope.launch {
if (viewModel.save()) {
screen.navigateBack()
}
}
},
onTag = { tag ->
viewModel.addTag(tag = tag)
},
)
ErrorSnackHandler(
error = viewModel.error,
)
AlterationEditKeyHandler(
onDismissRequest = {
screen.popBackStack()
screen.navigateBack()
}
)
}
@ -119,148 +145,218 @@ fun GMAlterationEditPage(
@Composable
private fun GMAlterationEditContent(
modifier: Modifier = Modifier,
form: GMAlterationEditPageUio,
scope: CoroutineScope = rememberCoroutineScope(),
tagsState: LazyListState = rememberLazyListState(),
form: State<GMAlterationEditPageUio?>,
paddings: PaddingValues,
onBack: () -> Unit,
addField: () -> Unit,
removeField: (index: Int) -> Unit,
onSave: () -> Unit,
onCancel: () -> Unit,
onTag: (GMTagUio) -> Unit,
) {
val fields = form.fields.collectAsState()
LazyColumn(
Scaffold(
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,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.game_master__alteration__title),
)
},
navigationIcon = {
IconButton(
onClick = onBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null,
)
}
},
actions = {
TextButton(
onClick = onSave,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
color = MaterialTheme.lwa.colorScheme.base.primary,
fontWeight = FontWeight.SemiBold,
text = stringResource(Res.string.game_master__alteration__edit_field_save),
)
Icon(
painter = painterResource(Res.drawable.ic_save_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
}
}
)
},
content = {
AnimatedContent(
targetState = form.value,
transitionSpec = {
if (initialState?.id == targetState?.id) {
EnterTransition.None togetherWith ExitTransition.None
} else {
fadeIn() togetherWith fadeOut()
}
}
) {
when (it) {
null -> Box(
modifier = Modifier.fillMaxSize(),
)
else -> {
val fields = it.fields.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = paddings,
verticalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
item(
key = "Id",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.id,
singleLine = true,
)
}
item(
key = "Name",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.label,
singleLine = true,
)
}
item(
key = "Description",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.description,
singleLine = false,
)
}
item(
key = "Tags",
) {
LazyRow(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
tagsState.scrollBy(-delta)
}
},
),
state = tagsState,
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
items(
items = it.tags,
) { tag ->
GMTagButton(
modifier = Modifier.height(48.dp),
tag = tag,
onTag = { onTag(tag) }
)
}
}
}
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(
modifier = Modifier
.size(size = 56.dp)
.background(
color = MaterialTheme.lwa.colorScheme.elevated.base1dp,
shape = MaterialTheme.shapes.small,
),
onClick = { removeField(index) },
) {
Icon(
imageVector = Icons.Default.Close,
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
}
}
}
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,
)
}
}
}
}
}
}
}
}
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
@ -277,4 +373,9 @@ private fun AlterationEditKeyHandler(
else -> false
}
}
}
}
private fun NavHostController.navigateBack() = popBackStack(
route = GMAlterationEditDestination.baseRoute(),
inclusive = true,
)

View file

@ -3,15 +3,23 @@ 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.network.LwaNetworkException
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMAlterationEditDestination
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.shared.lwa.protocol.rest.APIResponse.ErrorCode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GMAlterationEditViewModel(
private val alterationRepository: AlterationRepository,
private val tagRepository: TagRepository,
private val factory: GMAlterationEditFactory,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@ -20,24 +28,40 @@ class GMAlterationEditViewModel(
private val _form = MutableStateFlow<GMAlterationEditPageUio?>(null)
val form: StateFlow<GMAlterationEditPageUio?> get() = _form
private val _error = MutableSharedFlow<ErrorSnackUio>()
val error: SharedFlow<ErrorSnackUio> get() = _error
init {
viewModelScope.launch {
_form.value = factory.createForm(
alteration = alterationRepository.alteration(alterationId = argument.id)
originId = argument.id,
alteration = alterationRepository.alteration(alterationId = argument.id),
tags = tagRepository.alterationsTagFlow().value.values,
)
}
}
suspend fun save() {
val edited = factory.createAlteration(form = form.value)
suspend fun save(): Boolean {
val edited = factory.createAlteration(form = form.value) ?: return false
val actual = alterationRepository.alterationFlow.value[edited?.id]
try {
alterationRepository.updateAlteration(
alteration = edited,
create = argument.id == null,
)
return true
} catch (exception: LwaNetworkException) {
_form.value?.id?.isError?.value = exception.code == ErrorCode.AlterationId
_form.value?.label?.isError?.value = exception.code == ErrorCode.AlterationName
// TODO if argument.id == null et actual?.id != null on créer et on écrase existant !!!
if (edited != null)
alterationRepository.updateAlteration(edited)
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
return false
} catch (exception: Exception) {
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
return false
}
}
suspend fun addField() {
@ -55,4 +79,16 @@ class GMAlterationEditViewModel(
}
}
}
fun addTag(tag: GMTagUio) {
_form.update {
it?.copy(
tags = it.tags.toMutableList().also { tags ->
val index = tags.indexOf(tag)
if (index > -1)
tags[index] = tag.copy(highlight = tag.highlight.not())
}
)
}
}
}

View file

@ -3,6 +3,7 @@ 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.repository.tag.TagRepository
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
@ -22,6 +23,7 @@ import org.jetbrains.compose.resources.getString
class GMAlterationViewModel(
private val alterationRepository: AlterationRepository,
alterationFactory: GMAlterationFactory,
tagRepository: TagRepository,
tagFactory: GMTagFactory,
) : ViewModel() {
@ -38,7 +40,7 @@ class GMAlterationViewModel(
)
val tags: StateFlow<List<GMTagUio>> = combine(
alterationRepository.tagsFlow,
tagRepository.alterationsTagFlow(),
selectedTagId,
) { tags, selectedTagId ->
tagFactory.convertToGMTagItemUio(
@ -53,7 +55,7 @@ class GMAlterationViewModel(
val alterations: StateFlow<List<GMAlterationUio>> = combine(
alterationRepository.alterationFlow,
alterationRepository.tagsFlow,
tagRepository.alterationsTagFlow(),
filter.valueFlow.map { it.unAccent() },
selectedTagId,
) { alterations, tags, unAccentFilter, selectedTagId ->

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
@ -27,6 +28,7 @@ class GMCharacterViewModel(
private val networkRepository: NetworkRepository,
private val campaignRepository: CampaignRepository,
characterSheetRepository: CharacterSheetRepository,
tagRepository: TagRepository,
private val factory: GMCharacterFactory,
private val tagFactory: GMTagFactory,
) : ViewModel() {
@ -44,7 +46,7 @@ class GMCharacterViewModel(
)
val tags = combine(
characterSheetRepository.tagsFlow,
tagRepository.charactersTagFlow(),
selectedTagId,
) { tags, selectedTagId ->
tagFactory.convertToGMTagItemUio(

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
@ -15,6 +16,7 @@ import androidx.compose.ui.graphics.Shape
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.theme.color.component.LwaButtonColors
import com.pixelized.desktop.lwa.ui.theme.lwa
@ -61,4 +63,36 @@ fun GMTag(
text = tag.label,
)
}
}
@Composable
fun GMTagButton(
modifier: Modifier = Modifier,
padding: PaddingValues = GmTagDefault.padding,
shape: Shape = MaterialTheme.shapes.small,
tag: GMTagUio,
onTag: (() -> Unit)? = null,
) {
val animatedColor = animateColorAsState(
when (tag.highlight) {
true -> MaterialTheme.lwa.colorScheme.base.secondary
else -> MaterialTheme.lwa.colorScheme.base.onSurface
}
)
Button(
modifier = modifier,
colors = LwaButtonColors(contentColor = animatedColor.value),
shape = shape,
enabled = onTag != null,
onClick = { onTag?.invoke() },
) {
Text(
modifier = Modifier.padding(paddingValues = padding),
color = animatedColor.value,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = tag.label,
)
}
}

View file

@ -5,6 +5,23 @@ import java.text.Collator
class GMTagFactory {
fun convertToGMTagItemUio(
tags: Collection<Tag>,
selectedTagIds: List<String>,
): List<GMTagUio> {
return tags
.map { tag ->
GMTagUio(
id = tag.id,
label = tag.label,
highlight = selectedTagIds.contains(tag.id),
)
}
.sortedWith(
comparator = compareBy(Collator.getInstance()) { it.label }
)
}
fun convertToGMTagItemUio(
tags: Collection<Tag>,
selectedTagId: String?,

View file

@ -56,7 +56,7 @@ fun LwaTheme(
MaterialTheme(
colors = lwaColors.base,
typography = MaterialTheme.typography,
shapes = MaterialTheme.shapes,
shapes = lwaShapes.base,
content = content,
)
}

View file

@ -6,12 +6,14 @@ import androidx.compose.material.ContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
@Composable
@Stable
fun LwaButtonColors(): ButtonColors = ButtonDefaults.buttonColors(
fun LwaButtonColors(
contentColor: Color = MaterialTheme.colors.primary,
): ButtonColors = ButtonDefaults.buttonColors(
backgroundColor = MaterialTheme.colors.surface,
contentColor = MaterialTheme.colors.primary,
contentColor = contentColor,
disabledContentColor = MaterialTheme.colors.surface.copy(alpha = ContentAlpha.disabled),
)
)

View file

@ -1,6 +1,7 @@
package com.pixelized.desktop.lwa.ui.theme.shapes
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
@ -9,6 +10,7 @@ import androidx.compose.ui.unit.dp
@Stable
data class LwaShapes(
val base: Shapes,
val portrait: Shape,
val panel: Shape,
val settings: Shape,
@ -18,12 +20,16 @@ data class LwaShapes(
@Stable
@Composable
fun lwaShapes(
base: Shapes = Shapes(
small = RoundedCornerShape(4.dp),
),
portrait: Shape = RoundedCornerShape(8.dp),
panel: Shape = RoundedCornerShape(8.dp),
settings: Shape = RoundedCornerShape(8.dp),
gameMaster: Shape = RoundedCornerShape(8.dp),
): LwaShapes = remember {
LwaShapes(
base = base,
portrait = portrait,
panel = panel,
settings = settings,

View file

@ -1,9 +1,6 @@
package com.pixelized.server.lwa
import com.pixelized.server.lwa.server.LocalServer
import com.pixelized.shared.lwa.sharedModuleDependencies
import org.koin.core.context.startKoin
import org.koin.java.KoinJavaComponent.inject
fun main() {
LocalServer().create().start()

View file

@ -46,11 +46,21 @@ class AlterationService(
return alterationHashFlow.value[alterationId]
}
fun update(json: AlterationJson) {
alterationStore.save(alteration = json)
@Throws
fun save(
json: AlterationJson,
create: Boolean,
) {
alterationStore.save(
json = json,
create = create,
)
}
fun delete(alterationId: String): Boolean {
return alterationStore.delete(id = alterationId)
@Throws
fun delete(alterationId: String) {
return alterationStore.delete(
id = alterationId,
)
}
}

View file

@ -1,8 +1,14 @@
package com.pixelized.server.lwa.model.alteration
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonCodingException
import com.pixelized.server.lwa.server.exception.JsonConversionException
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.protocol.rest.APIResponse
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -13,6 +19,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
import java.text.Collator
class AlterationStore(
private val pathProvider: PathProvider,
@ -28,17 +35,17 @@ class AlterationStore(
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
updateAlterations()
updateAlterationFlow()
}
}
fun alterationsFlow(): StateFlow<List<Alteration>> = alterationFlow
private fun updateAlterations() {
private fun updateAlterationFlow() {
alterationFlow.value = try {
loadAlterations()
load()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
println(exception.message) // TODO proper exception handling
emptyList()
}
}
@ -47,7 +54,9 @@ class AlterationStore(
FileReadException::class,
JsonConversionException::class,
)
private fun loadAlterations(): List<Alteration> {
private fun load(
directory: File = this.directory,
): List<Alteration> {
return directory
.listFiles()
?.mapNotNull { file ->
@ -60,85 +69,118 @@ class AlterationStore(
if (json.isBlank()) {
return@mapNotNull null
}
// decode the file
val data = try {
this.json.decodeFromString<AlterationJson>(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// parse the json string.
try {
val data = this.json.decodeFromString<AlterationJson>(json)
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
}
?.sortedWith(compareBy(Collator.getInstance()) { it.metadata.name })
?: emptyList()
}
@Throws(JsonConversionException::class, FileWriteException::class)
@Throws(
BusinessException::class,
JsonConversionException::class,
JsonCodingException::class,
FileWriteException::class,
)
fun save(
alteration: Alteration,
json: AlterationJson,
create: Boolean,
) {
val json = try {
factory.convertToJson(data = alteration)
val file = alterationFile(id = json.id)
// Guard case on update alteration
if (create && file.exists()) {
throw BusinessException(
message = "Alteration already exist, creation is impossible.",
)
}
// Transform the json into the model.
val alteration = try {
factory.convertFromJson(json)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
save(alteration = json)
}
@Throws(FileWriteException::class)
fun save(
alteration: AlterationJson,
) {
// encode the json into a string
if (alteration.id.isEmpty()) {
throw BusinessException(
message = "Alteration 'id' is a mandatory field.",
code = APIResponse.ErrorCode.AlterationId,
)
}
if (alteration.metadata.name.isEmpty()) {
throw BusinessException(
message = "Alteration 'name' is a mandatory field.",
code = APIResponse.ErrorCode.AlterationName,
)
}
// Encode the json into a string.
val data = try {
json.encodeToString(alteration)
this.json.encodeToString(json)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
throw JsonCodingException(root = exception)
}
// write the alteration into a file.
// 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
)
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)
val index = alterations.indexOfFirst { it.id == json.id }
alterations.toMutableList()
.also {
if (index >= 0) {
it[index] = alteration
} else {
it.add(alteration)
}
}
}
.sortedWith(compareBy(Collator.getInstance()) {
it.metadata.name
})
}
}
fun delete(id: String): Boolean {
@Throws(BusinessException::class)
fun delete(id: String) {
val file = alterationFile(id = id)
val deleted = file.delete()
if (deleted) {
alterationFlow.update { alterations ->
alterations.toMutableList().also { alteration ->
// Guard case on the file existence.
if (file.exists().not()) {
throw BusinessException(
message = "Alteration doesn't not exist, deletion is impossible.",
)
}
// Guard case on the file deletion
if (file.delete().not()) {
throw BusinessException(
message = "Alteration file have not been deleted for unknown reason.",
)
}
// Update the data model with the deleted alteration.
alterationFlow.update { alterations ->
alterations.toMutableList()
.also { alteration ->
alteration.removeIf { it.id == id }
}
}
.sortedWith(compareBy(Collator.getInstance()) {
it.metadata.name
})
}
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)
class FileReadException(root: Exception) : AlterationStoreException(root)
}

View file

@ -1,5 +1,6 @@
package com.pixelized.server.lwa.model.campaign
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
@ -37,223 +38,91 @@ class CampaignService(
return campaignJsonFlow.value
}
@Throws
fun addCharacter(
characterSheetId: String,
): Boolean {
// check if the character is already in the campaign.
if (campaign.characters.contains(characterSheetId)) return false
// update the corresponding instance
val characters = campaign.characters.toMutableSet().also { it.add(characterSheetId) }
// save the campaign to the disk (update the flow).
return try {
store.save(
campaign = campaign.copy(characters = characters)
)
true
} catch (exception: Exception) {
false
) {
// Check if the character is already in the campaign.
if (campaign.instances.contains(characterSheetId)) {
throw BusinessException(message = "Character with id:$characterSheetId is already in the campaign.")
}
}
suspend fun addNpc(
characterSheetId: String,
): Boolean {
// check if the character is already in the campaign.
if (campaign.npcs.contains(characterSheetId)) return false
// update the corresponding instance
val characters = campaign.npcs.toMutableSet().also { it.add(characterSheetId) }
// save the campaign to the disk (update the flow).
return try {
store.save(
campaign = campaign.copy(npcs = characters)
)
true
} catch (exception: Exception) {
false
// Update the corresponding instance
val characters = campaign.characters.toMutableSet().also {
it.add(characterSheetId)
}
// Save the campaign to the disk (update the flow).
store.save(
campaign = campaign.copy(characters = characters)
)
}
suspend fun removeCharacter(
@Throws
fun addNpc(
characterSheetId: String,
): Boolean {
// check if the character is in the campaign.
if (campaign.characters.contains(characterSheetId).not()) return false
// update the corresponding instance
val characters = campaign.characters.toMutableSet().also { it.remove(characterSheetId) }
// save the campaign to the disk + update the flow.
return try {
store.save(
campaign = campaign.copy(characters = characters)
)
true
} catch (exception: Exception) {
false
) {
// Check if the character is already in the campaign.
if (campaign.instances.contains(characterSheetId)) {
throw BusinessException(message = "Character with id:$characterSheetId is already in the campaign.")
}
}
suspend fun removeNpc(
characterSheetId: String,
): Boolean {
// check if the character is in the campaign.
if (campaign.npcs.contains(characterSheetId).not()) return false
// update the corresponding instance
val characters = campaign.npcs.toMutableSet().also { it.remove(characterSheetId) }
// save the campaign to the disk + update the flow.
return try {
store.save(
campaign = campaign.copy(npcs = characters)
)
true
} catch (exception: Exception) {
false
// Update the corresponding instance
val characters = campaign.npcs.toMutableSet().also {
it.add(characterSheetId)
}
// Save the campaign to the disk (update the flow).
store.save(
campaign = campaign.copy(npcs = characters)
)
}
suspend fun removeInstance(
@Throws
fun removeCharacter(
characterSheetId: String,
): Boolean {
return removeCharacter(characterSheetId) || removeNpc(characterSheetId)
) {
// Check if the character is in the campaign.
if (campaign.characters.contains(characterSheetId).not()) {
throw BusinessException(message = "Character with id:$characterSheetId is not in the party.")
}
// Update the corresponding instance
val characters = campaign.characters.toMutableSet().also {
it.remove(characterSheetId)
}
// Save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(characters = characters)
)
}
suspend fun setScene(
@Throws
fun removeNpc(
characterSheetId: String,
) {
// Check if the character is in the campaign.
if (campaign.npcs.contains(characterSheetId).not()) {
throw BusinessException(message = "Character with id:$characterSheetId is not in the npcs.")
}
// Update the corresponding instance
val characters = campaign.npcs.toMutableSet().also {
it.remove(characterSheetId)
}
// Save the campaign to the disk + update the flow.
store.save(
campaign = campaign.copy(npcs = characters)
)
}
@Throws
fun setScene(
scene: Campaign.Scene,
): Boolean {
) {
// save the campaign to the disk + update the flow.
return try {
store.save(
campaign.copy(scene = scene)
)
true
} catch (exception: Exception) {
false
}
store.save(
campaign = campaign.copy(scene = scene)
)
}
// Data manipulation through WebSocket.
// suspend fun updateCharacteristic(
// characterInstanceId: Campaign.CharacterInstance.Id,
// characteristic: Campaign.CharacterInstance.Characteristic,
// value: Int,
// ) {
// when (characterInstanceId.prefix) {
// Campaign.CharacterInstance.Id.PLAYER -> {
// // fetch all the current campaign character
// val characters = campaign.characters.toMutableMap()
// // update the corresponding character using the use case.
// characters[characterInstanceId] = useCase.updateCharacteristic(
// instance = campaign.character(id = characterInstanceId),
// characteristic = characteristic,
// value = value,
// )
// // save the campaign to the disk + update the flow.
// store.save(
// campaign = campaign.copy(characters = characters)
// )
// }
//
// Campaign.CharacterInstance.Id.NPC -> {
// // fetch all the current campaign character
// val npcs = campaign.npcs.toMutableMap()
// // update the corresponding character using the use case.
// npcs[characterInstanceId] = useCase.updateCharacteristic(
// instance = campaign.npc(id = characterInstanceId),
// characteristic = characteristic,
// value = value,
// )
// // save the campaign to the disk + update the flow.
// store.save(
// campaign = campaign.copy(npcs = npcs)
// )
// }
// }
// }
//
// suspend fun updateDiminished(
// characterInstanceId: Campaign.CharacterInstance.Id,
// diminished: Int,
// ) {
// when (characterInstanceId.prefix) {
// Campaign.CharacterInstance.Id.PLAYER -> {
// // fetch all the current campaign character
// val characters = campaign.characters.toMutableMap()
// // update the corresponding character using the use case.
// characters[characterInstanceId] = useCase.updateDiminished(
// instance = campaign.character(id = characterInstanceId),
// diminished = diminished,
// )
// // save the campaign to the disk + update the flow.
// store.save(
// campaign = campaign.copy(characters = characters)
// )
// }
//
// Campaign.CharacterInstance.Id.NPC -> {
// // fetch all the current campaign character
// val npcs = campaign.npcs.toMutableMap()
// // update the corresponding character using the use case.
// npcs[characterInstanceId] = useCase.updateDiminished(
// instance = campaign.npc(id = characterInstanceId),
// diminished = diminished,
// )
// // save the campaign to the disk + update the flow.
// store.save(
// campaign = campaign.copy(npcs = npcs)
// )
// }
// }
// }
//
// suspend fun toggleAlteration(
// characterInstanceId: Campaign.CharacterInstance.Id,
// alterationId: String,
// ) {
// when (characterInstanceId.prefix) {
// Campaign.CharacterInstance.Id.PLAYER -> {
// // fetch all the current campaign character
// val characters = campaign.characters.toMutableMap()
// // update the corresponding character alterations
// characters[characterInstanceId]?.let { character ->
// characters[characterInstanceId] = character.copy(
// alterations = character.alterations.toMutableList().also { alterations ->
// if (alterations.contains(alterationId)) {
// alterations.remove(alterationId)
// } else {
// alterations.add(alterationId)
// }
// },
// )
// }
// // save the campaign to the disk + update the flow.
// store.save(
// campaign = campaign.copy(characters = characters)
// )
// }
//
// Campaign.CharacterInstance.Id.NPC -> {
// // fetch all the current campaign character
// val characters = campaign.npcs.toMutableMap()
// // update the corresponding character alterations
// characters[characterInstanceId]?.let { character ->
// characters[characterInstanceId] = character.copy(
// alterations = character.alterations.toMutableList().also { alterations ->
// if (alterations.contains(alterationId)) {
// alterations.remove(alterationId)
// } else {
// alterations.add(alterationId)
// }
// },
// )
// }
// // save the campaign to the disk + update the flow.
// store.save(
// campaign = campaign.copy(npcs = characters)
// )
// }
// }
// }
suspend fun updateToggleParty() {
fun updateToggleParty() {
store.save(
campaign = campaign.copy(
options = campaign.options.copy(
@ -263,7 +132,7 @@ class CampaignService(
)
}
suspend fun updateToggleNpc() {
fun updateToggleNpc() {
store.save(
campaign = campaign.copy(
options = campaign.options.copy(

View file

@ -1,5 +1,9 @@
package com.pixelized.server.lwa.model.campaign
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonCodingException
import com.pixelized.server.lwa.server.exception.JsonConversionException
import com.pixelized.shared.lwa.model.campaign.Campaign
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.campaign.factory.CampaignJsonFactory
@ -9,6 +13,7 @@ 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
@ -27,77 +32,84 @@ class CampaignStore(
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
updateCampaignFromDisk()
updateCampaignFlow()
}
}
fun campaignFlow(): StateFlow<Campaign> = campaignFlow
private fun updateCampaignFromDisk() {
private fun updateCampaignFlow() {
campaignFlow.value = try {
loadCampaign()
load()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
println(exception.message) // TODO proper exception handling
Campaign.empty()
}
}
@Throws(
FileReadException::class,
JsonCodingException::class,
JsonConversionException::class,
)
fun loadCampaign(): Campaign {
val file = file()
val json = try {
fun load(): Campaign {
val file = campaignFile()
// Read the campaign file.
val data = try {
file.readText(charset = Charsets.UTF_8)
} catch (exception: Exception) {
throw FileReadException(root = exception)
}
// Guard, if the file is empty we load a default campaign.
if (json.isBlank()) return Campaign.empty()
if (data.isBlank()) return Campaign.empty()
// Decode the json into a string.
val json = try {
this.json.decodeFromString<CampaignJson>(data)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// Convert from the Json format
val campaign = try {
val data = this.json.decodeFromString<CampaignJson>(json)
factory.convertFromJson(data)
factory.convertFromJson(json)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
return campaign
}
@Throws(
JsonConversionException::class,
JsonCodingException::class,
FileWriteException::class,
)
fun save(campaign: Campaign) {
// convert the data to json format
// Transform the json into the model.
val json = try {
factory.convertToJson(campaign = campaign).let(json::encodeToString)
factory.convertToJson(campaign = campaign)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// write the file
// Encode the json into a string.
val data = try {
this.json.encodeToString(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// Write the file
try {
val file = file()
val file = campaignFile()
file.writeText(
text = json,
text = data,
charset = Charsets.UTF_8,
)
} catch (exception: Exception) {
throw FileWriteException(root = exception)
}
// Update the dataflow.
campaignFlow.value = campaign
campaignFlow.update { campaign }
}
sealed class CampaignStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : CampaignStoreException(root)
class FileWriteException(root: Exception) : CampaignStoreException(root)
class FileReadException(root: Exception) : CampaignStoreException(root)
private fun file(): File {
private fun campaignFile(): File {
return File("${pathProvider.campaignPath()}campaign.json")
}
}

View file

@ -19,7 +19,9 @@ class CharacterSheetService(
private val factory: CharacterSheetJsonFactory,
) {
private val scope = CoroutineScope(Dispatchers.IO + Job())
private val sheets get() = sheetsFlow.value
private val sheetsFlow = characterStore.characterSheetsFlow()
.map { entry -> entry.associateBy { character -> character.id } }
.stateIn(
@ -40,7 +42,7 @@ class CharacterSheetService(
return sheets[characterSheetId]
}
fun tags() : Collection<TagJson> {
fun tags(): Collection<TagJson> {
return alterationTags.value
}
@ -52,14 +54,24 @@ class CharacterSheetService(
return sheets[characterSheetId]?.let(factory::convertToJson)
}
suspend fun updateCharacterSheet(character: CharacterSheetJson) {
@Throws
suspend fun save(
character: CharacterSheetJson,
create: Boolean,
) {
return characterStore.save(
sheet = factory.convertFromJson(character)
sheet = factory.convertFromJson(character),
create = create,
)
}
fun deleteCharacterSheet(characterSheetId: String): Boolean {
return characterStore.delete(id = characterSheetId)
@Throws
fun deleteCharacterSheet(
characterSheetId: String,
) {
characterStore.delete(
characterSheetId = characterSheetId
)
}
// Data manipulation through WebSocket.
@ -78,7 +90,8 @@ class CharacterSheetService(
characterStore.save(
sheet = character.copy(
alterations = alterations,
)
),
create = false,
)
}
if (active.not() && contain) {
@ -88,7 +101,8 @@ class CharacterSheetService(
characterStore.save(
sheet = character.copy(
alterations = alterations,
)
),
create = false,
)
}
}
@ -100,7 +114,10 @@ class CharacterSheetService(
) {
sheets[characterSheetId]?.let { character ->
val update = character.copy(damage = damage)
characterStore.save(sheet = update)
characterStore.save(
sheet = update,
create = false,
)
}
}
@ -110,7 +127,10 @@ class CharacterSheetService(
) {
sheets[characterSheetId]?.let { character ->
val update = character.copy(diminished = diminished)
characterStore.save(sheet = update)
characterStore.save(
sheet = update,
create = false,
)
}
}
@ -120,7 +140,10 @@ class CharacterSheetService(
) {
sheets[characterSheetId]?.let { character ->
val update = character.copy(fatigue = fatigue)
characterStore.save(sheet = update)
characterStore.save(
sheet = update,
create = false,
)
}
}
@ -141,7 +164,10 @@ class CharacterSheetService(
skill.takeIf { skill.id == skillId }?.copy(used = used) ?: skill
},
)
characterStore.save(sheet = update)
characterStore.save(
sheet = update,
create = false,
)
}
}
}

View file

@ -1,14 +1,21 @@
package com.pixelized.server.lwa.model.character
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonCodingException
import com.pixelized.server.lwa.server.exception.JsonConversionException
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.protocol.rest.APIResponse
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
@ -27,26 +34,29 @@ class CharacterSheetStore(
val scope = CoroutineScope(Dispatchers.IO + Job())
// load the initial data
scope.launch {
updateCharacterSheets()
updateCharacterFlow()
}
}
fun characterSheetsFlow(): StateFlow<List<CharacterSheet>> = characterSheetsFlow
private suspend fun updateCharacterSheets() {
private suspend fun updateCharacterFlow() {
characterSheetsFlow.value = try {
loadCharacterSheets()
load()
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
println(exception.message) // TODO proper exception handling
emptyList()
}
}
@Throws(
FileReadException::class,
JsonCodingException::class,
JsonConversionException::class,
)
suspend fun loadCharacterSheets(): List<CharacterSheet> {
suspend fun load(
directory: File = this.directory,
): List<CharacterSheet> {
return directory
.listFiles()
?.mapNotNull { file ->
@ -59,9 +69,15 @@ class CharacterSheetStore(
if (json.isBlank()) {
return@mapNotNull null
}
// decode the file
val data = try {
this.json.decodeFromString<CharacterSheetJson>(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// parse the json string.
try {
val sheet = this.json.decodeFromString<CharacterSheetJson>(json)
factory.convertFromJson(sheet)
factory.convertFromJson(data)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
@ -71,19 +87,34 @@ class CharacterSheetStore(
}
@Throws(
FileWriteException::class,
BusinessException::class,
JsonConversionException::class,
JsonCodingException::class,
FileWriteException::class,
)
fun save(sheet: CharacterSheet) {
// convert the character sheet into json format.
val data = try {
factory.convertToJson(sheet = sheet).let(json::encodeToString)
fun save(
sheet: CharacterSheet,
create: Boolean,
) {
val file = characterSheetFile(id = sheet.id)
// Guard case on update alteration
if (create && file.exists()) {
throw BusinessException(message = "Character already exist, creation is impossible.")
}
// Transform the json into the model.
val json = try {
factory.convertToJson(sheet = sheet)
} catch (exception: Exception) {
throw JsonConversionException(root = exception)
}
// Encode the json into a string.
val data = try {
this.json.encodeToString(json)
} catch (exception: Exception) {
throw JsonCodingException(root = exception)
}
// write the character file.
try {
val file = characterSheetFile(id = sheet.id)
file.writeText(
text = data,
charset = Charsets.UTF_8,
@ -92,40 +123,47 @@ class CharacterSheetStore(
throw FileWriteException(root = exception)
}
// Update the dataflow.
characterSheetsFlow.value = characterSheetsFlow.value
.toMutableList()
.also { data ->
val index = data.indexOfFirst { it.id == sheet.id }
if (index >= 0) {
data[index] = sheet
} else {
data.add(sheet)
characterSheetsFlow.update { characters ->
characters.toMutableList()
.also { data ->
val index = data.indexOfFirst { it.id == sheet.id }
if (index >= 0) {
data[index] = sheet
} else {
data.add(sheet)
}
}
}
.sortedWith(compareBy(Collator.getInstance()) { it.name })
.sortedWith(compareBy(Collator.getInstance()) {
it.name
})
}
}
fun delete(id: String): Boolean {
val file = characterSheetFile(id = id)
val deleted = file.delete()
if (deleted) {
characterSheetsFlow.value = characterSheetsFlow.value.toMutableList()
.also { data ->
data.removeIf { it.id == id }
}
.sortedBy {
it.name
}
@Throws(BusinessException::class)
fun delete(characterSheetId: String) {
val file = characterSheetFile(id = characterSheetId)
// Guard case on the file existence.
if (file.exists().not()) {
throw BusinessException(
message = "Character file with id:$characterSheetId doesn't not exist.",
code = APIResponse.ErrorCode.CharacterSheetId
)
}
// Guard case on the file deletion
if (file.delete().not()) {
throw BusinessException(
message = "Character file have not been deleted for unknown reason.",
)
}
// Update the data model with the deleted character.
characterSheetsFlow.update { characters ->
characters.toMutableList()
.also { data -> data.removeIf { it.id == characterSheetId } }
.sortedWith(compareBy(Collator.getInstance()) { it.name })
}
return deleted
}
private fun characterSheetFile(id: String): File {
return File("${pathProvider.characterStorePath()}${id}.json")
}
sealed class CharacterSheetStoreException(root: Exception) : Exception(root)
class JsonConversionException(root: Exception) : CharacterSheetStoreException(root)
class FileWriteException(root: Exception) : CharacterSheetStoreException(root)
class FileReadException(root: Exception) : CharacterSheetStoreException(root)
}

View file

@ -1,5 +1,8 @@
package com.pixelized.server.lwa.model.tag
import com.pixelized.server.lwa.server.exception.FileReadException
import com.pixelized.server.lwa.server.exception.FileWriteException
import com.pixelized.server.lwa.server.exception.JsonConversionException
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.utils.PathProvider
import kotlinx.coroutines.CoroutineScope
@ -49,7 +52,7 @@ class TagStore(
flow.value = try {
file.readTags().associateBy { it.id }
} catch (exception: Exception) {
println(exception) // TODO proper exception handling
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
@ -100,9 +103,4 @@ class TagStore(
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

@ -21,6 +21,14 @@ class Engine(
val webSocket = MutableSharedFlow<SocketMessage>()
suspend fun handle(message: SocketMessage) {
try {
unSafeHandle(message)
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
}
}
private suspend fun unSafeHandle(message: SocketMessage) {
when (message) {
is RollEvent -> Unit // Nothing to do here.
@ -61,10 +69,22 @@ class Engine(
}
is CampaignEvent -> when (message) {
is CampaignEvent.CharacterAdded -> Unit // TODO
is CampaignEvent.CharacterRemoved -> Unit // TODO
is CampaignEvent.NpcAdded -> Unit // TODO
is CampaignEvent.NpcRemoved -> Unit // TODO
is CampaignEvent.CharacterAdded -> campaignService.addCharacter(
characterSheetId = message.characterSheetId,
)
is CampaignEvent.CharacterRemoved -> campaignService.removeCharacter(
characterSheetId = message.characterSheetId,
)
is CampaignEvent.NpcAdded -> campaignService.addNpc(
characterSheetId = message.characterSheetId,
)
is CampaignEvent.NpcRemoved -> campaignService.removeNpc(
characterSheetId = message.characterSheetId,
)
is CampaignEvent.UpdateScene -> Unit // TODO
}

View file

@ -6,12 +6,12 @@ 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
import com.pixelized.server.lwa.server.rest.campaign.putCampaignNpc
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.removeCampaignNpc
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

View file

@ -0,0 +1,10 @@
package com.pixelized.server.lwa.server.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
class BusinessException(
message: String,
val code: APIResponse.ErrorCode? = null,
) : ServerException(
root = Exception(message)
)

View file

@ -0,0 +1,3 @@
package com.pixelized.server.lwa.server.exception
class FileReadException(root: Exception) : ServerException(root)

View file

@ -0,0 +1,3 @@
package com.pixelized.server.lwa.server.exception
class FileWriteException(root: Exception) : ServerException(root)

View file

@ -0,0 +1,3 @@
package com.pixelized.server.lwa.server.exception
class JsonCodingException(root: Exception) : ServerException(root)

View file

@ -0,0 +1,3 @@
package com.pixelized.server.lwa.server.exception
class JsonConversionException(root: Exception) : ServerException(root)

View file

@ -0,0 +1,4 @@
package com.pixelized.server.lwa.server.exception
class MissingParameterException(name: String) :
ServerException(root = Exception("Missing '$name' parameter."))

View file

@ -0,0 +1,3 @@
package com.pixelized.server.lwa.server.exception
sealed class ServerException(root: Exception) : Exception(root)

View file

@ -2,24 +2,24 @@ 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.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.deleteAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.deleteAlteration(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val alterationId = call.parameters.alterationId
val deleted = alterationService.delete(
// delete the alteration.
alterationService.delete(
alterationId = alterationId
)
if (deleted.not()) error("Unexpected error occurred")
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
// API & WebSocket responses.
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.AlterationDelete(
@ -28,9 +28,8 @@ fun Engine.deleteAlteration(): suspend io.ktor.server.routing.RoutingContext.()
),
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception,
)
}
}

View file

@ -2,11 +2,12 @@ package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.alterationId
import io.ktor.http.HttpStatusCode
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.RoutingContext
fun Engine.getAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getAlteration(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
@ -17,12 +18,13 @@ fun Engine.getAlteration(): suspend io.ktor.server.routing.RoutingContext.() ->
?: error("Alteration with id:$alterationId not found.")
// send it back to the user.
call.respond(
message = alteration,
message = APIResponse.success(
data = alteration
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception,
)
}
}

View file

@ -1,12 +1,23 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getAlterationTags(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getAlterationTags(): suspend RoutingContext.() -> Unit {
return {
call.respond(
message = alterationService.tags(),
)
try {
call.respond(
message = APIResponse.success(
data = alterationService.tags(),
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -1,12 +1,23 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getAlterations(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getAlterations(): suspend RoutingContext.() -> Unit {
return {
call.respond(
message = alterationService.alterations(),
)
try {
call.respond(
message = APIResponse.success(
data = alterationService.alterations(),
)
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -1,34 +0,0 @@
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

@ -0,0 +1,38 @@
package com.pixelized.server.lwa.server.rest.alteration
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.create
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putAlteration(): suspend RoutingContext.() -> Unit {
return {
try {
val form = call.receive<AlterationJson>()
val create = call.queryParameters.create
alterationService.save(
json = form,
create = create,
)
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = ApiSynchronisation.AlterationUpdate(
timestamp = System.currentTimeMillis(),
alterationId = form.id,
),
)
} catch (exception: Exception) {
call.exception(
exception = exception,
)
}
}
}

View file

@ -2,27 +2,24 @@ package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.removeCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.removeCampaignCharacter(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// remove the character form the party
val updated = campaignService.removeCharacter(
campaignService.removeCharacter(
characterSheetId = characterSheetId,
)
// error case
if (updated.not()) {
error("Unexpected error when removing character (characterSheetId:$characterSheetId) from party.")
}
// API & WebSocket responses
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.CharacterRemoved(
@ -31,9 +28,8 @@ fun Engine.removeCampaignCharacter(): suspend io.ktor.server.routing.RoutingCont
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -2,25 +2,24 @@ package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.removeCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.removeCampaignNpc(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// remove the character form the party
val updated = campaignService.removeNpc(characterSheetId = characterSheetId)
// error case
if (updated.not()) {
error("Unexpected error when removing character (characterSheetId:$characterSheetId) from npcs.")
}
campaignService.removeNpc(
characterSheetId = characterSheetId,
)
// API & WebSocket responses
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.NpcRemoved(
@ -29,9 +28,8 @@ fun Engine.removeCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.()
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,12 +1,23 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getCampaign(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getCampaign(): suspend RoutingContext.() -> Unit {
return {
call.respond(
message = campaignService.campaignJson(),
)
try {
call.respond(
message = APIResponse.success(
data = campaignService.campaignJson(),
),
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -2,25 +2,24 @@ package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCampaignCharacter(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// add the character to the party.
val update = campaignService.addCharacter(characterSheetId = characterSheetId)
// error case
if (update.not()) {
error("Unexpected error occurred when the character instance was added to the party")
}
campaignService.addCharacter(
characterSheetId = characterSheetId,
)
// API & WebSocket responses.
call.respondText(
text = "Character $characterSheetId successfully added to the party",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.CharacterAdded(
@ -29,9 +28,8 @@ fun Engine.putCampaignCharacter(): suspend io.ktor.server.routing.RoutingContext
)
)
} catch (exception: Exception) {
call.respondText(
text = "${exception.message}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -2,25 +2,24 @@ package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCampaignNpc(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
// add the character to the npcs.
val update = campaignService.addNpc(characterSheetId = characterSheetId)
// error case
if (update.not()) {
error("Unexpected error occurred when the character instance was added to the npcs")
}
campaignService.addNpc(
characterSheetId = characterSheetId,
)
// API & WebSocket responses.
call.respondText(
text = "Character $characterSheetId successfully added to the npcs",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.NpcAdded(
@ -29,9 +28,8 @@ fun Engine.putCampaignNpc(): suspend io.ktor.server.routing.RoutingContext.() ->
)
)
} catch (exception: Exception) {
call.respondText(
text = "${exception.message}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,13 +1,15 @@
package com.pixelized.server.lwa.server.rest.campaign
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.campaign.CampaignJsonV2
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CampaignEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCampaignScene(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCampaignScene(): suspend RoutingContext.() -> Unit {
return {
try {
// Get the scene json from the body of the request
@ -15,15 +17,12 @@ fun Engine.putCampaignScene(): suspend io.ktor.server.routing.RoutingContext.()
// convert the scene into the a usable data model.
val scene = campaignJsonFactory.convertFromJson(json = form)
// update the campaign.
val updated = campaignService.setScene(scene = scene)
// error case
if (updated.not()) {
error("Unexpected error when updating the scene.")
}
campaignService.setScene(
scene = scene,
)
// API & WebSocket responses
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success(),
)
webSocket.emit(
value = CampaignEvent.UpdateScene(
@ -32,9 +31,8 @@ fun Engine.putCampaignScene(): suspend io.ktor.server.routing.RoutingContext.()
)
)
} catch (exception: Exception) {
call.respondText(
text = exception.localizedMessage,
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -2,27 +2,24 @@ package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.deleteCharacter(): suspend RoutingContext.() -> Unit {
return {
val characterSheetId = call.parameters.characterSheetId
val deleted = characterService.deleteCharacterSheet(
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,
)
if (deleted) {
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
try {
// get the query parameter
val characterSheetId = call.parameters.characterSheetId
// delete the character sheet.
characterService.deleteCharacterSheet(
characterSheetId = characterSheetId
)
// API & WebSocket responses.
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = ApiSynchronisation.CharacterSheetDelete(
@ -30,10 +27,9 @@ fun Engine.deleteCharacter(): suspend io.ktor.server.routing.RoutingContext.() -
characterSheetId = characterSheetId,
),
)
} else {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}

View file

@ -2,11 +2,12 @@ package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import io.ktor.http.HttpStatusCode
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.RoutingContext
fun Engine.getCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getCharacter(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
@ -17,12 +18,13 @@ fun Engine.getCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> U
?: error("CharacterSheet with id:$characterSheetId not found.")
// send it back to the user.
call.respond(
message = characterSheet,
message = APIResponse.success(
data = characterSheet,
),
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity
call.exception(
exception = exception
)
}
}

View file

@ -1,12 +1,23 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getCharacterTags(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getCharacterTags(): suspend RoutingContext.() -> Unit {
return {
call.respond(
message = characterService.tags(),
)
try {
call.respond(
message = APIResponse.success(
data = characterService.tags(),
),
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -1,12 +1,23 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.getCharacters(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.getCharacters(): suspend RoutingContext.() -> Unit {
return {
call.respond(
message = characterService.charactersJson(),
)
try {
call.respond(
message = APIResponse.success(
data = characterService.charactersJson(),
),
)
} catch (exception: Exception) {
call.exception(
exception = exception
)
}
}
}

View file

@ -1,22 +1,26 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.create
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
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
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCharacter(): suspend RoutingContext.() -> Unit {
return {
try {
val form = call.receive<CharacterSheetJson>()
characterService.updateCharacterSheet(
val create = call.queryParameters.create
characterService.save(
character = form,
create = create,
)
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = ApiSynchronisation.CharacterSheetUpdate(
@ -25,9 +29,8 @@ fun Engine.putCharacter(): suspend io.ktor.server.routing.RoutingContext.() -> U
),
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,20 +1,22 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.utils.extentions.active
import com.pixelized.server.lwa.utils.extentions.alterationId
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCharacterAlteration(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCharacterAlteration(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
val alterationId = call.queryParameters.alterationId
val active = call.queryParameters["active"]?.toBooleanStrictOrNull()
?: error("Missing active parameter.")
val active = call.queryParameters.active
// Update the character damage
characterService.updateAlteration(
characterSheetId = characterSheetId,
@ -22,9 +24,8 @@ fun Engine.putCharacterAlteration(): suspend io.ktor.server.routing.RoutingConte
active = active
)
// API & WebSocket responses.
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = CharacterSheetEvent.UpdateAlteration(
@ -35,9 +36,8 @@ fun Engine.putCharacterAlteration(): suspend io.ktor.server.routing.RoutingConte
)
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,30 +1,32 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.server.exception.MissingParameterException
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCharacterDamage(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCharacterDamage(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
val damage = call.queryParameters["damage"]?.toIntOrNull()
?: error("Missing damage parameter.")
?: throw MissingParameterException(name = "damage")
// fetch the character sheet
val characterSheet = characterService.character(characterSheetId)
?: error("Character sheet not found for characterSheetId: $characterSheetId")
?: error("CharacterSheet with id:$characterSheetId not found.")
// Update the character damage
characterService.updateDamage(
characterSheetId = characterSheetId,
damage = damage
)
// API & WebSocket responses.
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = CharacterSheetEvent.UpdateDamage(
@ -35,9 +37,8 @@ fun Engine.putCharacterDamage(): suspend io.ktor.server.routing.RoutingContext.(
)
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,27 +1,29 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.server.exception.MissingParameterException
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCharacterDiminished(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCharacterDiminished(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
val diminished = call.queryParameters["diminished"]?.toIntOrNull()
?: error("Missing diminished parameter.")
?: throw MissingParameterException(name = "diminished")
// Update the character damage
characterService.updateDiminished(
characterSheetId = characterSheetId,
diminished = diminished
)
// API & WebSocket responses.
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = CharacterSheetEvent.UpdateDiminished(
@ -31,9 +33,8 @@ fun Engine.putCharacterDiminished(): suspend io.ktor.server.routing.RoutingConte
)
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,30 +1,32 @@
package com.pixelized.server.lwa.server.rest.character
import com.pixelized.server.lwa.server.Engine
import com.pixelized.server.lwa.server.exception.MissingParameterException
import com.pixelized.server.lwa.utils.extentions.characterSheetId
import com.pixelized.server.lwa.utils.extentions.exception
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingContext
fun Engine.putCharacterFatigue(): suspend io.ktor.server.routing.RoutingContext.() -> Unit {
fun Engine.putCharacterFatigue(): suspend RoutingContext.() -> Unit {
return {
try {
// get the query parameter
val characterSheetId = call.queryParameters.characterSheetId
val fatigue = call.queryParameters["fatigue"]?.toIntOrNull()
?: error("Missing fatigue parameter.")
?: throw MissingParameterException(name = "fatigue")
// fetch the character sheet
val characterSheet = characterService.character(characterSheetId)
?: error("Character sheet not found for characterSheetId: $characterSheetId")
?: error("CharacterSheet with id:$characterSheetId not found.")
// Update the character damage
characterService.updateFatigue(
characterSheetId = characterSheetId,
fatigue = fatigue
)
// API & WebSocket responses.
call.respondText(
text = "${HttpStatusCode.OK}",
status = HttpStatusCode.OK,
call.respond(
message = APIResponse.success()
)
webSocket.emit(
value = CharacterSheetEvent.UpdateFatigue(
@ -35,9 +37,8 @@ fun Engine.putCharacterFatigue(): suspend io.ktor.server.routing.RoutingContext.
)
)
} catch (exception: Exception) {
call.respondText(
text = "${HttpStatusCode.UnprocessableEntity}",
status = HttpStatusCode.UnprocessableEntity,
call.exception(
exception = exception
)
}
}

View file

@ -1,9 +1,24 @@
package com.pixelized.server.lwa.utils.extentions
import com.pixelized.server.lwa.server.exception.MissingParameterException
import io.ktor.http.Parameters
val Parameters.characterSheetId
get() = this["characterSheetId"] ?: error("Missing characterSheetId parameter.")
get() = "characterSheetId".let { param ->
this[param] ?: throw MissingParameterException(name = param)
}
val Parameters.alterationId
get() = this["alterationId"] ?: error("Missing alterationId parameter.")
get() = "alterationId".let { param ->
this[param] ?: throw MissingParameterException(name = param)
}
val Parameters.create
get() = "create".let { param ->
this[param]?.toBooleanStrictOrNull() ?: throw MissingParameterException(name = param)
}
val Parameters.active
get() = "active".let { param ->
this[param]?.toBooleanStrictOrNull() ?: throw MissingParameterException(name = param)
}

View file

@ -0,0 +1,40 @@
package com.pixelized.server.lwa.utils.extentions
import com.pixelized.server.lwa.server.exception.BusinessException
import com.pixelized.server.lwa.server.exception.MissingParameterException
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import io.ktor.server.response.respond
import io.ktor.server.routing.RoutingCall
suspend inline fun <reified T : Exception> RoutingCall.exception(exception: T) {
when (exception) {
is MissingParameterException -> {
respond(
message = APIResponse.error(
status = APIResponse.BAD_REQUEST,
message = exception.message ?: "?",
code = APIResponse.ErrorCode.AlterationName,
)
)
}
is BusinessException -> {
respond(
message = APIResponse.error(
status = APIResponse.INTERNAL_ERROR,
message = exception.message ?: "?",
code = exception.code,
)
)
}
else -> respond(
message = APIResponse.error(
status = APIResponse.INTERNAL_ERROR,
message = exception.message ?: "?",
)
)
}
}

View file

@ -31,6 +31,7 @@ val toolsDependencies
get() = module {
factory {
Json {
encodeDefaults = true
explicitNulls = false
prettyPrint = true
}

View file

@ -0,0 +1,58 @@
package com.pixelized.shared.lwa.protocol.rest
import kotlinx.serialization.Serializable
@Serializable
data class APIResponse<T>(
val success: Boolean,
val status: Int,
val message: String?,
val code: ErrorCode?,
val data: T?,
) {
@Serializable
enum class ErrorCode {
AlterationId,
AlterationName,
CharacterSheetId,
}
companion object {
fun error(
status: Int,
code: ErrorCode? = null,
message: String?,
) = APIResponse(
success = false,
status = status,
code = code,
message = message,
data = null,
)
fun success(
status: Int = OK,
) = APIResponse(
success = true,
status = status,
code = null,
message = null,
data = null,
)
inline fun <reified T> success(
status: Int = OK,
data: T? = null,
) = APIResponse(
success = true,
status = status,
code = null,
message = null,
data = data,
)
const val OK = 200
const val BAD_REQUEST = 400
const val INTERNAL_ERROR = 500
}
}