diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 7506605..4e5d68a 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -59,6 +59,7 @@ Création de personnage Édition de personnage + Identifiant du personnage Nom Sauvegarder Caractéristiques @@ -69,9 +70,11 @@ Intelligence Pouvoir Charisme - Level + Niveau Portrait - Thumbnail + Vignette + Occupation + Lien externe Caractéristiques dérivées Déplacement Points de vie maximum @@ -87,8 +90,10 @@ Ajouter une compétence spéciale Compétences magiques Ajouter une compétence magique - Description + Identifiant de la compétence + Label Base + Description Bonus Niveau Compétences @@ -143,6 +148,7 @@ Level Up Occupations Ajouter une occupation + Identifiant de l'action Nom Description Action normal @@ -273,15 +279,18 @@ Admin GameMaster Sauvegarder + Sauvegarder sous Filtrer par nom : niv: %1$d Joueur Npc Afficher le portrait + Lien externe Ajouter au groupe Retirer du groupe Ajouter aux Npcs Retirer des Npcs + Supprimer le personnage Créer un personnage Édition d'Altération Filtrer par nom : @@ -304,5 +313,6 @@ Empilable Équipable Consommable + Édition de personnage \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt index db2533a..543ac83 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/Module.kt @@ -56,6 +56,10 @@ import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.edit.GMAlterati 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.character.edit.GMCharacterEditFactory +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.GMCharacterEditViewModel +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog.GMCharacterSheetCopyDialogFactory +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog.GMCharacterSheetCopyDialogViewModel 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 @@ -156,10 +160,12 @@ val factoryDependencies factoryOf(::InventoryDialogFactory) factoryOf(::ItemDetailDialogFactory) factoryOf(::PurseDialogFactory) + factoryOf(::GMCharacterSheetCopyDialogFactory) factoryOf(::TextMessageFactory) factoryOf(::LevelUpFactory) factoryOf(::GMTagFactory) factoryOf(::GMCharacterFactory) + factoryOf(::GMCharacterEditFactory) factoryOf(::GMAlterationFactory) factoryOf(::GMAlterationEditFactory) factoryOf(::GMItemFactory) @@ -184,12 +190,14 @@ val viewModelDependencies viewModelOf(::InventoryDialogViewModel) viewModelOf(::ItemDetailDialogViewModel) viewModelOf(::PurseDialogViewModel) + viewModelOf(::GMCharacterSheetCopyDialogViewModel) viewModelOf(::CampaignChatViewModel) viewModelOf(::SettingsViewModel) viewModelOf(::LevelUpViewModel) viewModelOf(::PortraitOverlayViewModel) - viewModelOf(::GMCharacterViewModel) viewModelOf(::GameMasterViewModel) + viewModelOf(::GMCharacterViewModel) + viewModelOf(::GMCharacterEditViewModel) viewModelOf(::GMActionViewModel) viewModelOf(::GMAlterationViewModel) viewModelOf(::GMAlterationEditViewModel) diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClient.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClient.kt index dff8ec1..e8c7ec4 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClient.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClient.kt @@ -3,7 +3,6 @@ package com.pixelized.desktop.lwa.network import com.pixelized.shared.lwa.model.alteration.AlterationJson import com.pixelized.shared.lwa.model.campaign.CampaignJson import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson -import com.pixelized.shared.lwa.model.inventory.Inventory import com.pixelized.shared.lwa.model.inventory.InventoryJson import com.pixelized.shared.lwa.model.item.ItemJson import com.pixelized.shared.lwa.model.tag.TagJson @@ -51,7 +50,9 @@ interface LwaClient { // Character - suspend fun getCharacters(): APIResponse> + suspend fun getCharacterPreviews(): APIResponse> + + suspend fun getCharacterPreview(characterSheetId: String): APIResponse suspend fun getCharacter( characterSheetId: String, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClientImpl.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClientImpl.kt index 17a7e4e..381cfd8 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClientImpl.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/network/LwaClientImpl.kt @@ -86,8 +86,15 @@ class LwaClientImpl( .body>() @Throws - override suspend fun getCharacters(): APIResponse> = client - .get("$root/character/all") + override suspend fun getCharacterPreviews(): APIResponse> = client + .get("$root/character/preview/all") + .body() + + @Throws + override suspend fun getCharacterPreview( + characterSheetId: String, + ): APIResponse = client + .get("$root/character/preview/detail?characterSheetId=$characterSheetId") .body() @Throws diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/characterSheet/CharacterSheetStore.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/characterSheet/CharacterSheetStore.kt index f842148..54cd7b2 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/characterSheet/CharacterSheetStore.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/characterSheet/CharacterSheetStore.kt @@ -1,6 +1,5 @@ package com.pixelized.desktop.lwa.repository.characterSheet -import androidx.compose.runtime.clearCompositionErrors import com.pixelized.desktop.lwa.network.LwaClient import com.pixelized.desktop.lwa.repository.alteration.AlterationStore import com.pixelized.desktop.lwa.repository.network.NetworkRepository @@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json class CharacterSheetStore( private val alterationStore: AlterationStore, @@ -42,7 +42,7 @@ class CharacterSheetStore( suspend fun updateCharactersPreviewFlow() { _previewFlow.value = try { - getCharacters() + getCharacterPreviews() } catch (exception: Exception) { println(exception.message) // TODO proper exception handling emptyList() @@ -67,8 +67,8 @@ class CharacterSheetStore( } @Throws - suspend fun getCharacters(): List { - val request = client.getCharacters() + suspend fun getCharacterPreviews(): List { + val request = client.getCharacterPreviews() return when (request.success) { true -> request.data ?.map { factory.convertFromJson(it) } @@ -78,6 +78,17 @@ class CharacterSheetStore( } } + @Throws + suspend fun getCharacterPreview( + characterSheetId: String, + ): CharacterSheetPreview { + val request = client.getCharacterPreview(characterSheetId = characterSheetId) + return when (request.success) { + true -> factory.convertFromJson(request.data!!) + else -> LwaClient.error(error = request) + } + } + @Throws suspend fun getCharacterSheet( characterSheetId: String, @@ -135,12 +146,15 @@ class CharacterSheetStore( is ApiSynchronisation.CharacterSheetApiSynchronisation -> try { when (message) { is ApiSynchronisation.CharacterSheetUpdate -> { - _detailFlow.update( - sheet = getCharacterSheet(characterSheetId = message.characterSheetId) + val characterSheet = getCharacterSheet( + characterSheetId = message.characterSheetId, + ) + _detailFlow.update( + sheet = characterSheet + ) + _previewFlow.update( + preview = factory.convertToPreview(sheet = characterSheet), ) - if (_previewFlow.value.firstOrNull { it.characterSheetId == message.characterSheetId } == null) { - _previewFlow.value = getCharacters() - } } is ApiSynchronisation.CharacterSheetDelete -> { @@ -267,7 +281,6 @@ class CharacterSheetStore( // endregion - private fun MutableStateFlow>.update( sheet: CharacterSheet, ): CharacterSheet { @@ -276,4 +289,18 @@ class CharacterSheetStore( } return sheet } + + private fun MutableStateFlow>.update( + preview: CharacterSheetPreview, + ): CharacterSheetPreview { + val index = value.indexOfFirst { + it.characterSheetId == preview.characterSheetId + } + value = if (index >= 0) { + value.toMutableList().also { it[index] = preview } + } else { + value.toMutableList().also { it.add(preview) } + } + return preview + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt index 734db3d..85c148a 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/network/NetworkRepository.kt @@ -68,7 +68,7 @@ class NetworkRepository( } } }.onFailure { exception -> - // TODO + // TODO proper exception handling println("WebSocket exception: ${exception.localizedMessage}") }.also { job.cancel() diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/settings/SettingsStore.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/settings/SettingsStore.kt index 8975b68..2bfc10f 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/settings/SettingsStore.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/settings/SettingsStore.kt @@ -27,7 +27,7 @@ class SettingsStore( try { _settingsFlow.value = load() } catch (exception: Exception) { - println(exception) // TODO + println(exception) // TODO proper exception handling } } } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/alterteration/CharacterSheetAlterationDialogViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/alterteration/CharacterSheetAlterationDialogViewModel.kt index 0a3354b..80fcf16 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/alterteration/CharacterSheetAlterationDialogViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/alterteration/CharacterSheetAlterationDialogViewModel.kt @@ -6,7 +6,8 @@ import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository import com.pixelized.desktop.lwa.repository.tag.TagRepository import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio -import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio import com.pixelized.desktop.lwa.utils.extention.unAccent @@ -35,25 +36,20 @@ class CharacterSheetAlterationDialogViewModel( ) : ViewModel() { private val selectedCharacterSheetIdFlow = MutableStateFlow(null) - private val selectedAlterationNameFlow = MutableStateFlow("") private val selectedTagIdFlow = MutableStateFlow(null) + private val _filter = createLwaTextFieldFlow( + label = runBlocking { getString(Res.string.game_master__alteration__filter) }, + ) + private val filter = _filter.createLwaTextField() + private val selectedAlterationsFlow = combine( alterationRepository.alterationFlow.map { it.values }, - selectedAlterationNameFlow.map { it.unAccent() }, + _filter.valueFlow.map { it.unAccent() }, selectedTagIdFlow, dialogFactory::filterAlteration ) - private val filter = LwaTextFieldUio( - enable = true, - isError = MutableStateFlow(false), - valueFlow = selectedAlterationNameFlow, - label = runBlocking { getString(Res.string.game_master__alteration__filter) }, - placeHolder = null, - onValueChange = { selectedAlterationNameFlow.value = it }, - ) - private val tags: StateFlow> = combine( tagRepository.alterationsTagFlow(), selectedTagIdFlow, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogFactory.kt index e889858..bd3dffb 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogFactory.kt @@ -4,10 +4,6 @@ import com.pixelized.desktop.lwa.repository.settings.model.Settings import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio import com.pixelized.desktop.lwa.utils.extention.unAccent import com.pixelized.shared.lwa.model.item.Item -import kotlinx.coroutines.flow.MutableStateFlow -import lwacharactersheet.composeapp.generated.resources.Res -import lwacharactersheet.composeapp.generated.resources.character__inventory__filter_item_inventory__label -import org.jetbrains.compose.resources.getString class InventoryDialogFactory { @@ -29,18 +25,16 @@ class InventoryDialogFactory { } } - suspend fun convertToDialogUio( + fun convertToDialogUio( items: Collection, - filterFlow: Pair, MutableStateFlow>, + filter: LwaTextFieldUio, characterSheetId: String?, ): InventoryDialogUio? { if (characterSheetId == null) return null return InventoryDialogUio( characterSheetId = characterSheetId, - filter = filterFlow.createTextField( - label = getString(Res.string.character__inventory__filter_item_inventory__label), - ), + filter = filter, items = items.map { item -> InventoryDialogUio.Item( itemId = item.id, @@ -57,25 +51,6 @@ class InventoryDialogFactory { ) } - fun createTextFieldFlow( - value: String = "", - error: Boolean = false, - ): Pair, MutableStateFlow> { - return MutableStateFlow(value) to MutableStateFlow(error) - } - - private fun Pair, MutableStateFlow>.createTextField( - enable: Boolean = true, - label: String, - ) = LwaTextFieldUio( - enable = enable, - isError = second, - valueFlow = first, - label = label, - placeHolder = null, - onValueChange = { first.value = it }, - ) - companion object { const val ADDABLE_TAG_ID = "META:ADDABLE" } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogViewModel.kt index c37796a..a8caac9 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialogViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pixelized.desktop.lwa.repository.item.ItemRepository import com.pixelized.desktop.lwa.repository.settings.SettingsRepository +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.utils.extention.unAccent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -11,6 +13,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.character__inventory__filter_item_inventory__label +import org.jetbrains.compose.resources.getString class InventoryDialogViewModel( itemRepository: ItemRepository, @@ -19,12 +25,15 @@ class InventoryDialogViewModel( ) : ViewModel() { private val selectedCharacterSheetId = MutableStateFlow(null) - private val filterFlow = factory.createTextFieldFlow() + private val filterFlow = createLwaTextFieldFlow( + label = runBlocking { getString(Res.string.character__inventory__filter_item_inventory__label) }, + ) + private val filterField = filterFlow.createLwaTextField() val inventoryDialog = combine( itemRepository.itemFlow().map { it.values }, settingRepository.settingsFlow(), - filterFlow.first.map { it.unAccent() }, + filterFlow.valueFlow.map { it.unAccent() }, selectedCharacterSheetId, ) { items, settings, filter, characterSheetId -> factory.convertToDialogUio( @@ -33,7 +42,7 @@ class InventoryDialogViewModel( filter = filter, setting = settings, ), - filterFlow = filterFlow, + filter = filterField, characterSheetId = characterSheetId, ) }.stateIn( @@ -43,7 +52,7 @@ class InventoryDialogViewModel( ) fun showInventoryDialog(characterSheetId: String?) { - filterFlow.first.update { "" } + filterFlow.valueFlow.update { "" } selectedCharacterSheetId.update { characterSheetId } } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialog.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialog.kt index ed6ee36..7d21ec5 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialog.kt @@ -193,7 +193,7 @@ fun ItemDetailDialog( color = MaterialTheme.lwa.colorScheme.elevated.base2dp, shape = MaterialTheme.shapes.small, ), - enabled = state.countable.isError.collectAsState().value.not(), + enabled = state.countable.errorFlow.collectAsState().value.not(), onClick = { onConfirm(state) } ) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogFactory.kt index 65f0499..f397a97 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogFactory.kt @@ -1,6 +1,8 @@ package com.pixelized.desktop.lwa.ui.composable.character.item import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.shared.lwa.model.item.Item import kotlinx.coroutines.flow.MutableStateFlow import lwacharactersheet.composeapp.generated.resources.Res @@ -36,20 +38,22 @@ class ItemDetailDialogFactory { image = item.metadata.image, count = count, countable = takeIf { item.options.stackable } - ?.let { createFieldFlow(value = format.format(count)) } - ?.createTextField(label = getString(Res.string.character__inventory__inventory__dialog__count)), + ?.let { + val flow = createLwaTextFieldFlow( + label = getString(Res.string.character__inventory__inventory__dialog__count), + value = format.format(count), + ) + flow.createLwaTextField { + flow.errorFlow.value = isError(value = it) + flow.valueFlow.value = it + } + }, consumable = item.options.consumable, equipped = equipped, equipable = item.options.equipable, ) } - private fun createFieldFlow( - value: String = "", - error: Boolean = false, - ): Pair, MutableStateFlow> { - return MutableStateFlow(value) to MutableStateFlow(error) - } fun parse( quantity: String, @@ -60,19 +64,4 @@ class ItemDetailDialogFactory { } private fun isError(value: String): Boolean = floatChecker.matches(value).not() - - private fun Pair, MutableStateFlow>.createTextField( - enable: Boolean = true, - label: String, - ) = LwaTextFieldUio( - enable = enable, - isError = second, - valueFlow = first, - label = label, - placeHolder = null, - onValueChange = { - second.value = isError(value = it) - first.value = it - }, - ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogViewModel.kt index e0ea22b..e9aea1e 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/item/ItemDetailDialogViewModel.kt @@ -162,7 +162,7 @@ class ItemDetailDialogViewModel( suspend fun changeInventoryItemQuantity( dialog: ItemDetailDialogUio, ): Boolean { - if (dialog.countable?.isError?.value == true) return false + if (dialog.countable?.errorFlow?.value == true) return false val characterSheetId = dialog.characterSheetId val inventoryId = dialog.inventoryId ?: return false diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogFactory.kt index a6195e9..b149651 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogFactory.kt @@ -1,6 +1,8 @@ package com.pixelized.desktop.lwa.ui.composable.character.purse import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -26,13 +28,18 @@ class PurseDialogFactory { ): PurseDialogUio? { if (characterSheetId == null) return null - val gold = createFlows(initialValue = "") - .createCoinTextField(label = getString(Res.string.generic__gold__singular)) - val silver = createFlows(initialValue = "") - .createCoinTextField(label = getString(Res.string.generic__silver__singular)) - val copper = createFlows(initialValue = "") - .createCoinTextField(label = getString(Res.string.generic__copper__singular)) - + val gold = createLwaTextFieldFlow( + label = getString(Res.string.generic__gold__singular), + value = "", + ) + val silver = createLwaTextFieldFlow( + label = getString(Res.string.generic__silver__singular), + value = "", + ) + val copper = createLwaTextFieldFlow( + label = getString(Res.string.generic__copper__singular), + value = "", + ) return PurseDialogUio( characterSheetId = characterSheetId, label = signFlow.map { @@ -46,46 +53,31 @@ class PurseDialogFactory { initialValue = getString(Res.string.character__inventory__add_to_purse__title), ), add = signFlow, - gold = gold, - silver = silver, - copper = copper, + gold = gold.createLwaTextField { + gold.errorFlow.value = check(it) + gold.valueFlow.value = it + }, + silver = silver.createLwaTextField { + silver.errorFlow.value = check(it) + silver.valueFlow.value = it + }, + copper = copper.createLwaTextField { + copper.errorFlow.value = check(it) + copper.valueFlow.value = it + }, enableConfirm = combine( - gold.isError, - silver.isError, - copper.isError + gold.errorFlow, + silver.errorFlow, + copper.errorFlow ) { goldError, silverError, copperError -> !goldError && !silverError && !copperError }.stateIn( scope = scope, started = SharingStarted.Lazily, - initialValue = !gold.isError.value && !silver.isError.value && !copper.isError.value, + initialValue = !gold.errorFlow.value && !silver.errorFlow.value && !copper.errorFlow.value, ), ) } private fun check(value: String): Boolean = !decimalChecker.matches(value) - - private fun Pair, MutableStateFlow>.createCoinTextField( - label: String, - ): LwaTextFieldUio { - val (valueFlow, errorFlow) = this - return LwaTextFieldUio( - enable = true, - isError = errorFlow, - valueFlow = valueFlow, - label = label, - placeHolder = null, - onValueChange = { - errorFlow.value = check(it) - valueFlow.value = it - }, - ) - } - - private fun createFlows( - initialValue: String, - initialError: Boolean = false, - ): Pair, MutableStateFlow> { - return MutableStateFlow(initialValue) to MutableStateFlow(initialError) - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/checkbox/LwaCheckBox.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/checkbox/LwaCheckBox.kt index dbe22c3..0529635 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/checkbox/LwaCheckBox.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/checkbox/LwaCheckBox.kt @@ -1,12 +1,17 @@ package com.pixelized.desktop.lwa.ui.composable.checkbox +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.material.Checkbox import androidx.compose.material.CheckboxColors import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager import com.pixelized.desktop.lwa.ui.theme.color.component.LwaCheckboxColors +import com.pixelized.desktop.lwa.utils.extention.isElementVisible import kotlinx.coroutines.flow.StateFlow @Stable @@ -19,13 +24,26 @@ data class LwaCheckBoxUio( @Composable fun LwaCheckBox( modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, colors: CheckboxColors = LwaCheckboxColors(), field: LwaCheckBoxUio, ) { + val focus = LocalFocusManager.current + + val isFocused = interactionSource.collectIsFocusedAsState() val checked = field.checked.collectAsState() Checkbox( - modifier = modifier, + modifier = Modifier + .isElementVisible { visible -> + if (visible.not() && isFocused.value) { + focus.clearFocus() + } + } + .then(other = modifier), + interactionSource = interactionSource, + enabled = enabled, checked = checked.value, colors = colors, onCheckedChange = field.onCheckedChange, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/decoratedBox/DecoratedBox.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/decoratedBox/DecoratedBox.kt index c965f8b..5d48aca 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/decoratedBox/DecoratedBox.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/decoratedBox/DecoratedBox.kt @@ -4,12 +4,14 @@ import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -21,14 +23,15 @@ import com.pixelized.desktop.lwa.utils.preview.ContentPreview fun DecoratedBox( modifier: Modifier = Modifier, border: Color = Color(0xFF909090), + padding: PaddingValues = remember { PaddingValues() }, content: @Composable BoxScope.() -> Unit, ) { Box( - modifier = Modifier + modifier = modifier .border(width = 1.dp, color = border, shape = RoundedCornerShape(size = 16.dp)) .padding(all = 2.dp) .border(width = 1.dp, color = border, shape = RectangleShape) - .then(other = modifier), + .padding(paddingValues = padding), content = content, ) } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextField.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextField.kt index cd9cf61..b58dbb5 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextField.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextField.kt @@ -1,5 +1,7 @@ package com.pixelized.desktop.lwa.ui.composable.textfield +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.height import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -8,6 +10,7 @@ import androidx.compose.material.TextFieldColors import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.graphics.Shape @@ -15,15 +18,17 @@ 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.extention.isElementVisible +import com.pixelized.desktop.lwa.utils.extention.thenIf import com.pixelized.desktop.lwa.utils.rememberKeyboardActions -import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow @Stable data class LwaTextFieldUio( val enable: Boolean = true, - val isError: MutableStateFlow, - val valueFlow: MutableStateFlow, - val label: String?, + val errorFlow: StateFlow, + val valueFlow: StateFlow, + val labelFlow: StateFlow, val placeHolder: String?, val onValueChange: (String) -> Unit, ) @@ -33,6 +38,7 @@ fun LwaTextField( modifier: Modifier = Modifier, colors: TextFieldColors = LwaTextFieldColors(), shape: Shape = MaterialTheme.shapes.small, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, leadingIcon: @Composable (() -> Unit)? = null, trailingIcon: @Composable (() -> Unit)? = null, singleLine: Boolean = true, @@ -40,17 +46,20 @@ fun LwaTextField( ) { val focus = LocalFocusManager.current - val localModifier = if (singleLine) { - Modifier.height(height = 56.dp) - } else { - Modifier - } - + val isFocused = interactionSource.collectIsFocusedAsState() + val label = field.labelFlow.collectAsState() val value = field.valueFlow.collectAsState() - val isError = field.isError.collectAsState() + val error = field.errorFlow.collectAsState() TextField( - modifier = localModifier.then(other = modifier), + modifier = Modifier + .thenIf(singleLine) { height(height = 56.dp) } + .isElementVisible { visible -> + if (visible.not() && isFocused.value) { + focus.clearFocus() + } + } + .then(other = modifier), colors = colors, shape = shape, keyboardActions = rememberKeyboardActions { @@ -67,8 +76,8 @@ fun LwaTextField( ) } }, - isError = isError.value, - label = field.label?.let { + isError = error.value, + label = label.value?.let { { Text( overflow = TextOverflow.Ellipsis, @@ -77,6 +86,7 @@ fun LwaTextField( ) } }, + interactionSource = interactionSource, leadingIcon = leadingIcon, trailingIcon = trailingIcon, onValueChange = { field.onValueChange(it) }, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextFieldHelper.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextFieldHelper.kt new file mode 100644 index 0000000..9bc4fe0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/textfield/LwaTextFieldHelper.kt @@ -0,0 +1,54 @@ +package com.pixelized.desktop.lwa.ui.composable.textfield + +import androidx.compose.runtime.Stable +import kotlinx.coroutines.flow.MutableStateFlow + +@Stable +data class LwaTextFieldFlow( + val errorFlow: MutableStateFlow, + val valueFlow: MutableStateFlow, + val labelFlow: MutableStateFlow, +) + +fun createLwaTextFieldFlow( + error: Boolean = false, + label: String, + value: String = "", +): LwaTextFieldFlow { + return createLwaTextFieldFlow( + errorFlow = MutableStateFlow(error), + valueFlow = MutableStateFlow(value), + labelFlow = MutableStateFlow(label), + ) +} + +fun createLwaTextFieldFlow( + errorFlow: MutableStateFlow = MutableStateFlow(false), + valueFlow: MutableStateFlow, + labelFlow: MutableStateFlow, +): LwaTextFieldFlow { + return LwaTextFieldFlow( + errorFlow = errorFlow, + valueFlow = valueFlow, + labelFlow = labelFlow, + ) +} + +fun LwaTextFieldFlow.createLwaTextField( + enable: Boolean = true, + placeHolder: String? = null, + checkForError: ((String) -> Boolean)? = null, + onValueChange: (String) -> Unit = { + errorFlow.value = checkForError?.invoke(it) ?: errorFlow.value + valueFlow.value = it + }, +): LwaTextFieldUio { + return LwaTextFieldUio( + enable = enable, + errorFlow = errorFlow, + valueFlow = valueFlow, + labelFlow = labelFlow, + placeHolder = placeHolder, + onValueChange = onValueChange, + ) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/navigation/screen/destination/gamemaster/GMCharacterEditDestination.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/navigation/screen/destination/gamemaster/GMCharacterEditDestination.kt new file mode 100644 index 0000000..e66dda7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/navigation/screen/destination/gamemaster/GMCharacterEditDestination.kt @@ -0,0 +1,58 @@ +package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster + +import androidx.compose.runtime.Stable +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.GMCharacterEditPage +import com.pixelized.desktop.lwa.utils.extention.ARG + +@Stable +object GMCharacterEditDestination { + private const val ROUTE = "GameMasterCharacterEdit" + private const val CHARACTER_ID = "id" + + @Stable + fun baseRoute() = "$ROUTE?${CHARACTER_ID.ARG}" + + @Stable + fun navigationRoute(characterSheetId: String?) = "$ROUTE?$CHARACTER_ID=$characterSheetId" + + @Stable + fun arguments() = listOf( + navArgument(CHARACTER_ID) { + nullable = true + type = NavType.StringType + } + ) + + @Stable + data class Argument( + val characterSheetId: String?, + ) { + constructor(savedStateHandle: SavedStateHandle) : this( + characterSheetId = savedStateHandle.get(CHARACTER_ID), + ) + } +} + +fun NavGraphBuilder.composableGameMasterCharacterEditPage() { + composable( + route = GMCharacterEditDestination.baseRoute(), + arguments = GMCharacterEditDestination.arguments(), + ) { + GMCharacterEditPage() + } +} + +fun NavHostController.navigateToGameMasterCharacterEditPage( + characterSheetId: String?, +) { + val route = GMCharacterEditDestination.navigationRoute( + characterSheetId = characterSheetId, + ) + navigate(route = route) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/CharacterDetailPanelViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/CharacterDetailPanelViewModel.kt index 38abd46..f19b235 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/CharacterDetailPanelViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/CharacterDetailPanelViewModel.kt @@ -2,8 +2,10 @@ package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.navigation.NavHostController import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository import com.pixelized.desktop.lwa.repository.network.NetworkRepository +import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterCharacterEditPage import com.pixelized.desktop.lwa.ui.navigation.window.WindowController import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheetEdit import com.pixelized.desktop.lwa.ui.overlay.roll.RollResult @@ -88,14 +90,13 @@ class CharacterDetailPanelViewModel( suspend fun editCharacter( characterSheetId: String, - windows: WindowController, + screens: NavHostController, ) { if (characterSheetRepository.characterDetail(characterSheetId = characterSheetId) == null) { characterSheetRepository.updateCharacterSheet(characterSheetId = characterSheetId) } - windows.navigateToCharacterSheetEdit( - characterId = characterSheetId, - title = getString(Res.string.character_sheet_edit__edit__title), + screens.navigateToGameMasterCharacterEditPage( + characterSheetId = characterSheetId, ) } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventoryFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventoryFactory.kt index 27f3cbb..ac22b8a 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventoryFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventoryFactory.kt @@ -2,9 +2,10 @@ package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory import com.pixelized.desktop.lwa.repository.inventory.InventoryRepository import com.pixelized.desktop.lwa.repository.item.ItemRepository -import com.pixelized.desktop.lwa.repository.tag.TagRepository import com.pixelized.desktop.lwa.ui.composable.character.inventory.InventoryDialogFactory import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory.item.InventoryItemUio import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory.item.PurseUio import com.pixelized.desktop.lwa.utils.extention.unAccent @@ -45,23 +46,18 @@ class CharacterDetailInventoryFactory( addItemAction: StateFlow, initialValue: () -> CharacterDetailInventoryUio?, ): StateFlow { - val filterFlow = MutableStateFlow("") - val filterField = LwaTextFieldUio( - enable = true, - isError = MutableStateFlow(false), - valueFlow = filterFlow, + val filterFlow = createLwaTextFieldFlow( label = getString(Res.string.character__inventory__filter_inventory__label), - placeHolder = null, - onValueChange = { filterFlow.value = it }, + value = "", ) return combine( inventoryRepository.inventoryFlow(characterSheetId = characterSheetId), itemRepository.itemFlow(), - filterFlow.map { it.unAccent() }, + filterFlow.valueFlow.map { it.unAccent() }, ) { inventory, items, filter -> convertToCharacterInventoryUio( characterSheetId = characterSheetId, - filter = filterField, + filter = filterFlow.createLwaTextField(), addItemAction = addItemAction, purse = inventory.purse, inventory = inventory.items, @@ -74,7 +70,7 @@ class CharacterDetailInventoryFactory( ) } - private suspend fun convertToCharacterInventoryUio( + private fun convertToCharacterInventoryUio( characterSheetId: String?, filter: LwaTextFieldUio, addItemAction: StateFlow, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/CharacterDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/CharacterDetailSheet.kt index ae4c846..d289e6c 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/CharacterDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/CharacterDetailSheet.kt @@ -15,6 +15,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -80,7 +81,8 @@ fun CharacterDetailSheet( verticalArrangement = Arrangement.spacedBy(space = 8.dp) ) { DecoratedBox( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), + padding = remember { PaddingValues(vertical = 8.dp) }, ) { Column { Text( @@ -105,7 +107,8 @@ fun CharacterDetailSheet( visible = sheet.value?.specialSkills?.isNotEmpty() ?: false, ) { DecoratedBox( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), + padding = remember { PaddingValues(vertical = 8.dp) }, ) { Column { Text( @@ -131,7 +134,8 @@ fun CharacterDetailSheet( visible = sheet.value?.magicSkills?.isNotEmpty() ?: false, ) { DecoratedBox( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + modifier = Modifier.fillMaxWidth(), + padding = remember { PaddingValues(vertical = 8.dp) }, ) { Column { Text( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/item/CharacterDetailSheetCharacteristic.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/item/CharacterDetailSheetCharacteristic.kt index 549857f..7c316e9 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/item/CharacterDetailSheetCharacteristic.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/sheet/item/CharacterDetailSheetCharacteristic.kt @@ -8,6 +8,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow @@ -29,7 +30,7 @@ data class CharacterDetailSheetCharacteristicUio( @Composable fun CharacterDetailSheetCharacteristic( modifier: Modifier = Modifier, - paddingValues: PaddingValues = PaddingValues(all = 8.dp), + paddingValues: PaddingValues = remember { PaddingValues(all = 8.dp) }, characteristic: CharacterDetailSheetCharacteristicUio, onClick: () -> Unit, ) { @@ -39,8 +40,8 @@ fun CharacterDetailSheetCharacteristic( DecoratedBox( modifier = Modifier .clickable(onClick = onClick) - .padding(paddingValues = paddingValues) .then(other = modifier), + padding = paddingValues, ) { Text( modifier = Modifier.align(alignment = Alignment.TopCenter), diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/text/TextMessageFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/text/TextMessageFactory.kt index 5bf3b03..ee9b771 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/text/TextMessageFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/text/TextMessageFactory.kt @@ -126,15 +126,14 @@ class TextMessageFactory( ) } - is CharacterSheetEvent.UpdateSkillUsageEvent -> null // TODO ? - - is CharacterSheetEvent.UpdateAlteration -> null // TODO ? + is CharacterSheetEvent.UpdateSkillUsageEvent -> null + is CharacterSheetEvent.UpdateAlteration -> null } is CampaignEvent -> when (message) { - is CampaignEvent.CharacterAdded -> null // TODO ? - is CampaignEvent.CharacterRemoved -> null // TODO ? - is CampaignEvent.UpdateScene -> null // TODO ? + is CampaignEvent.CharacterAdded -> null + is CampaignEvent.CharacterRemoved -> null + is CampaignEvent.UpdateScene -> null is CampaignEvent.NpcAdded -> null is CampaignEvent.NpcRemoved -> null } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/copy/CharacterSheetCopyDialog.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/copy/CharacterSheetCopyDialog.kt index ffadf4b..d913e8d 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/copy/CharacterSheetCopyDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/copy/CharacterSheetCopyDialog.kt @@ -135,7 +135,7 @@ private fun Dialog( ) AnimatedVisibility( - visible = dialog.value.isError.collectAsState().value, + visible = dialog.value.errorFlow.collectAsState().value, enter = fadeIn(), exit = fadeOut(), ) { diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditFactory.kt index 18e963b..fa41f4d 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditFactory.kt @@ -4,7 +4,8 @@ import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio -import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.composable.tooltip.TooltipUio import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.common.SkillFieldFactory import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.common.occupation @@ -127,8 +128,10 @@ class CharacterSheetEditFactory( return CharacterSheet( id = editedSheet.id.value, name = editedSheet.name.value.value, + job = "", portrait = editedSheet.portrait.value.value, thumbnail = editedSheet.thumbnail.value.value, + externalLink = "", level = level, shouldLevelUp = editedSheet.levelUp.checked.value, strength = strength, @@ -200,6 +203,7 @@ class CharacterSheetEditFactory( critical = it.critical?.valueFlow?.value, ) }, + tags = emptyList(), ) } @@ -464,29 +468,29 @@ class CharacterSheetEditFactory( actions = sheet?.actions?.map { action -> ActionFieldUio( id = action.id, - label = createLwaTextField( + label = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__name_label), value = action.label, - ), - description = createLwaTextField( + ).createLwaTextField(), + description = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__description_label), value = action.description ?: "", - ), + ).createLwaTextField(), canBeCritical = createLwaBox( checked = action.canBeCritical, ), - default = createLwaTextField( + default = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__default_action_label), value = action.default, - ), - special = createLwaTextField( + ).createLwaTextField(), + special = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__spacial_action_label), - value = action.special, - ), - critical = createLwaTextField( + value = action.special ?: "", + ).createLwaTextField(), + critical = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__critical_action_label), - value = action.critical, - ), + value = action.critical ?: "", + ).createLwaTextField(), option = skillFieldFactory.deleteOption { onDeleteSkill(action.id) }, ) } ?: emptyList(), @@ -494,26 +498,6 @@ class CharacterSheetEditFactory( } } - fun createLwaTextField( - enable: Boolean = true, - isError: Boolean = false, - value: String? = null, - label: String? = null, - placeholder: String? = null, - ): LwaTextFieldUio { - val valueFlow = MutableStateFlow(value ?: "") - val isErrorFlow = MutableStateFlow(isError) - - return LwaTextFieldUio( - enable = enable, - isError = isErrorFlow, - valueFlow = valueFlow, - label = label, - placeHolder = placeholder, - onValueChange = { valueFlow.value = it }, - ) - } - fun createLwaBox( enable: Boolean = true, checked: Boolean, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditViewModel.kt index 7dd43f6..9b7dc49 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/characterSheet/edit/CharacterSheetEditViewModel.kt @@ -5,13 +5,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository -import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.navigation.screen.destination.CharacterSheetEditDestination import com.pixelized.desktop.lwa.ui.screen.characterSheet.copy.CharacterSheetCopyDialogUio import com.pixelized.desktop.lwa.ui.screen.characterSheet.delete.CharacterSheetDeleteDialogUio import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.common.SkillFieldFactory import com.pixelized.desktop.lwa.ui.screen.characterSheet.edit.composable.ActionFieldUio -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.runBlocking import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__critical_action_label @@ -52,22 +52,18 @@ class CharacterSheetEditViewModel( val characterSheet: State get() = _characterSheet suspend fun showCopyCharacterSheetDialog() { - val characterSheetId = MutableStateFlow(argument.id ?: "") - val error = MutableStateFlow(false) + val idFieldFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__copy__label), + value = argument.id ?: "", + ) copyDialog.value = CharacterSheetCopyDialogUio( label = getString(Res.string.character_sheet_edit__copy__title), - value = LwaTextFieldUio( - label = getString(Res.string.character_sheet_edit__copy__label), - isError = error, - valueFlow = characterSheetId, - placeHolder = null, - onValueChange = { characterSheetId.value = it } - ), + value = idFieldFlow.createLwaTextField(), validate = { characterSheetRepository.checkCharacterSheetIdValidity( - characterSheetId = characterSheetId.value + characterSheetId = idFieldFlow.valueFlow.value ).also { - error.value = it.not() + idFieldFlow.errorFlow.value = it.not() } } ) @@ -128,29 +124,29 @@ class CharacterSheetEditViewModel( val id = UUID.randomUUID().toString() val field = ActionFieldUio( id = id, - label = sheetFactory.createLwaTextField( + label = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__name_label), value = "", - ), - description = sheetFactory.createLwaTextField( + ).createLwaTextField(), + description = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__description_label), value = "", - ), + ).createLwaTextField(), canBeCritical = sheetFactory.createLwaBox( checked = false, ), - default = sheetFactory.createLwaTextField( + default = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__default_action_label), value = "", - ), - special = sheetFactory.createLwaTextField( + ).createLwaTextField(), + special = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__spacial_action_label), value = "", - ), - critical = sheetFactory.createLwaTextField( + ).createLwaTextField(), + critical = createLwaTextFieldFlow( label = getString(Res.string.character_sheet_edit__actions__critical_action_label), value = "", - ), + ).createLwaTextField(), option = skillFactory.deleteOption { deleteSkill(id) }, ) val actions = _characterSheet.value.actions.toMutableList().also { diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/GameMasterNavHost.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/GameMasterNavHost.kt index 26d9613..e66aa64 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/GameMasterNavHost.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/GameMasterNavHost.kt @@ -13,6 +13,7 @@ import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController import com.pixelized.desktop.lwa.ui.navigation.screen.destination.composableLevelUp 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.composableGameMasterCharacterEditPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterItemEditPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterMainPage @@ -37,6 +38,7 @@ fun GameMasterNavHost() { startDestination = GameMasterDestination.navigationRoute(), ) { composableGameMasterMainPage() + composableGameMasterCharacterEditPage() composableGameMasterAlterationEditPage() composableGameMasterItemEditPage() diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditFactory.kt index daea8fa..dea8aac 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditFactory.kt @@ -1,6 +1,7 @@ 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.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow 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 @@ -20,13 +21,21 @@ class GMAlterationEditFactory( private val tagFactory: GMTagFactory, ) { suspend fun createForm( - originId: String?, alteration: Alteration?, tags: Collection, - ): GMAlterationEditPageUio { - val idFlow = MutableStateFlow(alteration?.id ?: "") - val labelFlow = MutableStateFlow(alteration?.metadata?.name ?: "") - val descriptionFlow = MutableStateFlow(alteration?.metadata?.description ?: "") + ): GMAlterationEditViewModel.GMAlterationEditForm { + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__alteration__edit_id), + value = alteration?.id ?: "", + ) + val labelFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__alteration__edit_label), + value = alteration?.metadata?.name ?: "", + ) + val descriptionFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__alteration__edit_description), + value = alteration?.metadata?.description ?: "", + ) val tagFlow = MutableStateFlow( tagFactory.convertToGMTagItemUio( tags = tags, @@ -35,61 +44,43 @@ class GMAlterationEditFactory( ) val fieldsFlow = MutableStateFlow(alteration?.fields?.map { createField(it) } ?: listOf(createField(null))) + return GMAlterationEditViewModel.GMAlterationEditForm( + idFlow = idFlow, + labelFlow = labelFlow, + descriptionFlow = descriptionFlow, + tagFlow = tagFlow, + fieldsFlow = fieldsFlow, + ) + } + suspend fun createForm( + originId: String?, + form: GMAlterationEditViewModel.GMAlterationEditForm, + ): GMAlterationEditPageUio { return GMAlterationEditPageUio( - id = LwaTextFieldUio( - enable = originId == null, - isError = MutableStateFlow(false), - label = getString(Res.string.game_master__alteration__edit_id), - valueFlow = idFlow, - placeHolder = null, - onValueChange = { idFlow.value = it }, - ), - label = LwaTextFieldUio( - enable = true, - isError = MutableStateFlow(false), - label = getString(Res.string.game_master__alteration__edit_label), - valueFlow = labelFlow, - placeHolder = null, - onValueChange = { labelFlow.value = it }, - ), - description = LwaTextFieldUio( - enable = true, - isError = MutableStateFlow(false), - label = getString(Res.string.game_master__alteration__edit_description), - valueFlow = descriptionFlow, - placeHolder = null, - onValueChange = { descriptionFlow.value = it }, - ), - tags = tagFlow, - fields = fieldsFlow, + id = form.idFlow.createLwaTextField(enable = originId == null), + label = form.labelFlow.createLwaTextField(), + description = form.descriptionFlow.createLwaTextField(), + tags = form.tagFlow, + fields = form.fieldsFlow, ) } suspend fun createField( alteration: Alteration.Field?, ): GMAlterationEditPageUio.SkillUio { - val idFlow = MutableStateFlow(alteration?.fieldId ?: "") - val expressionFlow = MutableStateFlow(alteration?.expression?.toString() ?: "") - + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__alteration__edit_field_id), + value = alteration?.fieldId ?: "", + ) + val expressionFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__alteration__edit_field_expression), + value = alteration?.expression?.toString() ?: "", + ) return GMAlterationEditPageUio.SkillUio( key = "${UUID.randomUUID()}-${System.currentTimeMillis()}", - id = LwaTextFieldUio( - enable = true, - isError = MutableStateFlow(false), - label = getString(Res.string.game_master__alteration__edit_field_id), - valueFlow = idFlow, - placeHolder = null, - onValueChange = { idFlow.value = it }, - ), - expression = LwaTextFieldUio( - enable = true, - isError = MutableStateFlow(false), - label = getString(Res.string.game_master__alteration__edit_field_expression), - valueFlow = expressionFlow, - placeHolder = null, - onValueChange = { expressionFlow.value = it }, - ) + id = idFlow.createLwaTextField(), + expression = expressionFlow.createLwaTextField() ) } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditPage.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditPage.kt index 153775b..239744c 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditPage.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditPage.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -44,6 +46,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -51,6 +54,7 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -144,16 +148,30 @@ fun GMAlterationEditScreen( @Composable private fun GMAlterationEditContent( modifier: Modifier = Modifier, + paddings: PaddingValues = GMAlterationEditPageDefault.paddings, scope: CoroutineScope = rememberCoroutineScope(), tagsState: LazyListState = rememberLazyListState(), form: State, - paddings: PaddingValues = GMAlterationEditPageDefault.paddings, onBack: () -> Unit, addField: () -> Unit, removeField: (index: Int) -> Unit, onSave: () -> Unit, onTag: (GMTagUio) -> Unit, ) { + val layoutDirection = LocalLayoutDirection.current + val verticalPadding = remember(paddings) { + PaddingValues( + top = paddings.calculateTopPadding(), + bottom = paddings.calculateBottomPadding(), + ) + } + val horizontalPadding = remember(paddings, layoutDirection) { + PaddingValues( + start = paddings.calculateStartPadding(layoutDirection = layoutDirection), + end = paddings.calculateEndPadding(layoutDirection = layoutDirection), + ) + } + Scaffold( modifier = modifier, topBar = { @@ -214,45 +232,40 @@ private fun GMAlterationEditContent( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = paddings, + contentPadding = verticalPadding, verticalArrangement = Arrangement.spacedBy(space = 8.dp), ) { - item( - key = "Id", - ) { + item(key = "Id") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.id, singleLine = true, ) } - item( - key = "Name", - ) { + item(key = "Name") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.label, singleLine = true, ) } - item( - key = "Description", - ) { + item(key = "Description") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.description, singleLine = false, ) } - item( - key = "Tags", - ) { + item(key = "Tags") { LazyRow( modifier = Modifier.draggable( orientation = Orientation.Horizontal, @@ -263,6 +276,7 @@ private fun GMAlterationEditContent( }, ), state = tagsState, + contentPadding = horizontalPadding, horizontalArrangement = Arrangement.spacedBy(space = 8.dp), ) { items( @@ -281,7 +295,10 @@ private fun GMAlterationEditContent( key = { _, item -> item.key }, ) { index, item -> Row( - modifier = Modifier.animateItem(), + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), horizontalArrangement = Arrangement.spacedBy(space = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -312,13 +329,12 @@ private fun GMAlterationEditContent( } } } - item( - key = "Actions", - ) { + item(key = "Actions") { Column( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), horizontalAlignment = Alignment.End ) { Button( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditViewModel.kt index bf7eb44..0fd3cb3 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/edit/GMAlterationEditViewModel.kt @@ -7,13 +7,17 @@ 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.composable.textfield.LwaTextFieldFlow 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.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -25,8 +29,18 @@ class GMAlterationEditViewModel( ) : ViewModel() { private val argument = GMAlterationEditDestination.Argument(savedStateHandle) - private val _form = MutableStateFlow(null) - val form: StateFlow get() = _form + private val _form = MutableStateFlow(null) + val form: StateFlow = _form.map { form -> + if (form == null) return@map null + factory.createForm( + originId = argument.id, + form = form, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = null, + ) private val _error = MutableSharedFlow() val error: SharedFlow get() = _error @@ -34,7 +48,6 @@ class GMAlterationEditViewModel( init { viewModelScope.launch { _form.value = factory.createForm( - originId = argument.id, alteration = alterationRepository.alteration(alterationId = argument.id), tags = tagRepository.alterationsTags(), ) @@ -51,8 +64,8 @@ class GMAlterationEditViewModel( ) return true } catch (exception: LwaNetworkException) { - _form.value?.id?.isError?.value = exception.code == ErrorCode.AlterationId - _form.value?.label?.isError?.value = exception.code == ErrorCode.AlterationName + _form.value?.idFlow?.errorFlow?.value = exception.code == ErrorCode.AlterationId + _form.value?.labelFlow?.errorFlow?.value = exception.code == ErrorCode.AlterationName val message = ErrorSnackUio.from(exception = exception) _error.emit(message) @@ -81,7 +94,7 @@ class GMAlterationEditViewModel( } fun addTag(tag: GMTagUio) { - _form.value?.tags?.update { tags -> + form.value?.tags?.update { tags -> tags.toMutableList().also { val index = it.indexOf(tag) if (index > -1) { @@ -90,4 +103,12 @@ class GMAlterationEditViewModel( } } } + + data class GMAlterationEditForm( + val idFlow: LwaTextFieldFlow, + val labelFlow: LwaTextFieldFlow, + val descriptionFlow: LwaTextFieldFlow, + val tagFlow: MutableStateFlow>, + val fieldsFlow: MutableStateFlow>, + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/list/GMAlterationViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/list/GMAlterationViewModel.kt index 27b0b79..8ad5b5c 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/list/GMAlterationViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/alteration/list/GMAlterationViewModel.kt @@ -5,7 +5,8 @@ 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.error.ErrorSnackUio -import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio import com.pixelized.desktop.lwa.utils.extention.unAccent @@ -31,19 +32,14 @@ class GMAlterationViewModel( ) : ViewModel() { private val selectedTagId = MutableStateFlow(null) - private val filterValue = MutableStateFlow("") private val _error = MutableSharedFlow() val error: SharedFlow get() = _error - val filter = LwaTextFieldUio( - enable = true, + private val _filter = createLwaTextFieldFlow( label = runBlocking { getString(Res.string.game_master__character__filter) }, - valueFlow = filterValue, - isError = MutableStateFlow(false), - placeHolder = null, - onValueChange = { filterValue.value = it }, ) + val filter = _filter.createLwaTextField() val tags: StateFlow> = combine( tagRepository.alterationsTagFlow(), diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditFactory.kt new file mode 100644 index 0000000..aaaa196 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditFactory.kt @@ -0,0 +1,733 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit + + +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item.GMActionFieldUio +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item.GMSkillFieldUio +import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory +import com.pixelized.desktop.lwa.utils.extention.unpack +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.ACROBATICS_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.AID_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.ATHLETICS_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.BARGAIN_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.COMBAT_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.DISCRETION_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.DODGE_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.EMPATHY_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.GRAB_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.INTIMIDATION_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.PERCEPTION_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.PERSUASION_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.SEARCH_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.SLEIGHT_OF_HAND_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.SPIEL_ID +import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet.CommonSkillId.THROW_ID +import com.pixelized.shared.lwa.model.tag.Tag +import com.pixelized.shared.lwa.usecase.SkillUseCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__critical_action_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__default_action_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__description_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__id_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__name_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__spacial_action_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__cha +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__con +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__dex +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__hei +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__int +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__level +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__portrait +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__pow +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__str +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__characteristics__thumbnail +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__external_link +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__id_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__job +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__name_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__acrobatics_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__acrobatics_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__acrobatics_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__aid_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__aid_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__aid_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__athletics_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__athletics_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__athletics_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__bargain_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__bargain_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__bargain_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__base_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__bonus_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__combat_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__combat_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__combat_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__description_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__discretion_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__discretion_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__discretion_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__dodge_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__dodge_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__dodge_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__empathy_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__empathy_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__empathy_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__grab_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__grab_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__grab_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__id_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__intimidation_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__intimidation_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__intimidation_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__level_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__name_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__perception_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__perception_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__perception_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__persuasion_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__persuasion_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__persuasion_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__search_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__search_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__search_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__sleight_of_hand_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__sleight_of_hand_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__sleight_of_hand_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__spiel_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__spiel_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__spiel_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__throw_base +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__throw_description +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__skills__throw_label +import org.jetbrains.compose.resources.getString +import java.util.UUID + +class GMCharacterEditFactory( + private val tagFactory: GMTagFactory, + private val skillUseCase: SkillUseCase, +) { + suspend fun convertIntoModel( + sheet: CharacterSheet?, + form: GMCharacterEditPageUio, + ): CharacterSheet { + return CharacterSheet( + id = form.characterSheetId.unpack() + ?: error("Missing characterSheetId"), + name = form.name.unpack() + ?: error("Missing character name"), + job = form.job.unpack() ?: "", + portrait = form.portrait.unpack(), + thumbnail = form.thumbnail.unpack(), + externalLink = form.externalLink.unpack(), + level = form.level.unpack() + ?: error("Missing character level"), + shouldLevelUp = form.levelUp.checked.value, + strength = form.characteristics.strength.unpack() + ?: error("Missing character strength"), + dexterity = form.characteristics.dexterity.unpack() + ?: error("Missing character dexterity"), + constitution = form.characteristics.constitution.unpack() + ?: error("Missing character constitution"), + height = form.characteristics.height.unpack() + ?: error("Missing character height"), + intelligence = form.characteristics.intelligence.unpack() + ?: error("Missing character intelligence"), + power = form.characteristics.power.unpack() + ?: error("Missing character power"), + charisma = form.characteristics.charisma.unpack() + ?: error("Missing character charisma"), + alterations = sheet?.alterations ?: emptyList(), + damage = sheet?.damage ?: 0, + fatigue = sheet?.fatigue ?: 0, + diminished = sheet?.diminished ?: 0, + commonSkills = form.commonSkills.map { editedSkill -> + val skillId: String = editedSkill.id.unpack() + ?: error("Missing skill id") + val currentSkill = sheet?.commonSkills?.firstOrNull { + it.id == skillId + } + CharacterSheet.Skill( + id = skillId, + label = editedSkill.label.unpack() + ?: error("Missing label for skill: $skillId"), + base = commonSkillBase(skillId = skillId) + ?: error("Missing base for skill: $skillId"), + description = commonSkillDescription(skillId = skillId) + ?: error("Missing description for skill: $skillId"), + bonus = editedSkill.bonus.unpack(), + level = editedSkill.level.unpack() ?: 1, + occupation = editedSkill.occupation.checked.value, + used = currentSkill?.used ?: false, + ) + }, + specialSkills = form.specialSkills.map { editedSkill -> + val skillId: String = editedSkill.id.unpack() + ?: error("Missing skill id") + val currentSkill = sheet?.commonSkills?.firstOrNull { + it.id == skillId + } + CharacterSheet.Skill( + id = skillId, + label = editedSkill.label.unpack() + ?: error("Missing label for skill: $skillId"), + base = editedSkill.base.unpack() ?: "0", + description = editedSkill.description?.unpack(), + bonus = editedSkill.bonus.unpack(), + level = editedSkill.level.unpack() ?: 1, + occupation = editedSkill.occupation.checked.value, + used = currentSkill?.used ?: false, + ) + }, + magicSkills = form.magicSkills.map { editedSkill -> + val skillId: String = editedSkill.id.unpack() + ?: error("Missing skill id") + val currentSkill = sheet?.commonSkills?.firstOrNull { + it.id == skillId + } + CharacterSheet.Skill( + id = skillId, + label = editedSkill.label.unpack() + ?: error("Missing label for skill: $skillId"), + base = editedSkill.base.unpack() ?: "0", + description = editedSkill.description?.unpack(), + bonus = editedSkill.bonus.unpack(), + level = editedSkill.level.unpack() ?: 1, + occupation = editedSkill.occupation.checked.value, + used = currentSkill?.used ?: false, + ) + }, + actions = form.actions.map { + val actionId: String = it.id.unpack() + ?: error("Missing actions id") + CharacterSheet.Roll( + id = actionId, + label = it.label.unpack() + ?: error("Missing label for action: $actionId"), + description = it.description.unpack(), + canBeCritical = it.canBeCritical.checked.value, + default = it.default.unpack() + ?: error("Missing default roll for action: $actionId"), + special = it.special?.unpack() + ?: if (it.canBeCritical.checked.value) error("Missing special roll for action: $actionId") else null, + critical = it.critical?.unpack() + ?: if (it.canBeCritical.checked.value) error("Missing critical roll for action: $actionId") else null, + ) + }, + tags = form.tags.value.mapNotNull { + if (it.highlight) it.id else null + }, + ) + } + + suspend fun createForm( + scope: CoroutineScope, + sheet: CharacterSheet?, + tags: Collection, + ): GMCharacterEditPageUio { + val tagFlow = MutableStateFlow( + tagFactory.convertToGMTagItemUio( + tags = tags, + selectedTagIds = sheet?.tags ?: emptyList(), + ) + ) + val characteristics = GMCharacterEditPageUio.CharacteristicsUio( + strength = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__str), + value = sheet?.strength?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + dexterity = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__dex), + value = sheet?.dexterity?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + constitution = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__con), + value = sheet?.constitution?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + height = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__hei), + value = sheet?.height?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + intelligence = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__int), + value = sheet?.intelligence?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + power = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__pow), + value = sheet?.power?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + charisma = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__cha), + value = sheet?.charisma?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + ) + return GMCharacterEditPageUio( + characterSheetId = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__id_label), + value = sheet?.id ?: "", + ).createLwaTextField( + enable = sheet == null, + checkForError = { it.isBlank() }, + ), + name = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__name_label), + value = sheet?.name ?: "", + ).createLwaTextField( + checkForError = { it.isBlank() }, + ), + job = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__job), + value = sheet?.job ?: "", + ).createLwaTextField(), + portrait = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__portrait), + value = sheet?.portrait ?: "", + ).createLwaTextField(), + thumbnail = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__thumbnail), + value = sheet?.thumbnail ?: "", + ).createLwaTextField(), + externalLink = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__external_link), + value = sheet?.externalLink ?: "", + ).createLwaTextField(), + level = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__characteristics__level), + value = sheet?.level?.toString() ?: "", + ).createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + levelUp = MutableStateFlow(sheet?.shouldLevelUp ?: false).let { flow -> + LwaCheckBoxUio( + enable = false, + checked = flow, + onCheckedChange = { flow.value = it }, + ) + }, + characteristics = characteristics, + tags = tagFlow, + commonSkills = (sheet?.commonSkills ?: commonSkills()).map { + createCommonSkill( + scope = scope, + skill = it, + characteristics = characteristics + ) + }, + specialSkills = sheet?.specialSkills?.map { createSkill(skill = it) } ?: emptyList(), + magicSkills = sheet?.magicSkills?.map { createSkill(skill = it) } ?: emptyList(), + actions = sheet?.actions?.map { createAction(action = it) } ?: emptyList()) + } + + private suspend fun createCommonSkill( + scope: CoroutineScope, + skill: CharacterSheet.Skill, + characteristics: GMCharacterEditPageUio.CharacteristicsUio, + ): GMSkillFieldUio { + val baseLabel = getString(Res.string.character_sheet_edit__skills__base_label) + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__id_label), + value = skill.id, + ) + val labelFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__name_label), + value = commonSkillLabel(skillId = skill.id) ?: "", + ) + val baseFlow = createLwaTextFieldFlow( + labelFlow = MutableStateFlow( + when (skill.occupation) { + true -> "$baseLabel *" + else -> baseLabel + } + ), + valueFlow = commonSkillBaseFlow( + scope = scope, + characteristics = characteristics, + skillId = skill.id, + ), + ) + val bonusFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__bonus_label), + value = skill.bonus ?: "", + ) + val levelFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__level_label), + value = skill.level.toString(), + ) + return GMSkillFieldUio( + key = "${UUID.randomUUID()}-${System.currentTimeMillis()}", + id = idFlow.createLwaTextField( + enable = false, + ), + label = labelFlow.createLwaTextField( + enable = false, + ), + base = baseFlow.createLwaTextField( + enable = false, + ), + description = null, + bonus = bonusFlow.createLwaTextField( + checkForError = { it.isNotBlank() && it.toIntOrNull() == null }, + ), + level = levelFlow.createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + occupation = MutableStateFlow(skill.occupation).let { flow -> + LwaCheckBoxUio( + enable = true, + checked = flow, + onCheckedChange = { + baseFlow.labelFlow.value = when (it) { + true -> "$baseLabel *" + else -> baseLabel + } + flow.value = it + }, + ) + }, + ) + } + + suspend fun createSkill( + skill: CharacterSheet.Skill?, + ): GMSkillFieldUio { + val baseLabel = getString(Res.string.character_sheet_edit__skills__base_label) + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__id_label), + value = skill?.id ?: "", + ) + val labelFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__name_label), + value = skill?.label ?: "", + ) + val baseFlow = createLwaTextFieldFlow( + label = when (skill?.occupation) { + true -> "$baseLabel *" + else -> baseLabel + }, + value = skill?.base ?: "", + ) + val descriptionFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__description_label), + value = skill?.description ?: "", + ) + val bonusFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__bonus_label), + value = skill?.bonus ?: "", + ) + val levelFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__skills__level_label), + value = skill?.level.toString(), + ) + val occupationFlow = MutableStateFlow(skill?.occupation ?: false) + return GMSkillFieldUio( + key = "${UUID.randomUUID()}-${System.currentTimeMillis()}", + id = idFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + label = labelFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + base = baseFlow.createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + description = descriptionFlow.createLwaTextField(), + bonus = bonusFlow.createLwaTextField( + checkForError = { it.isNotBlank() && it.toIntOrNull() == null }, + ), + level = levelFlow.createLwaTextField( + checkForError = { it.toIntOrNull() == null }, + ), + occupation = LwaCheckBoxUio( + enable = true, + checked = occupationFlow, + onCheckedChange = { + baseFlow.labelFlow.value = when (it) { + true -> "$baseLabel *" + else -> baseLabel + } + occupationFlow.value = it + }, + ), + ) + } + + suspend fun createAction( + action: CharacterSheet.Roll?, + ): GMActionFieldUio { + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__actions__id_label), + value = action?.id ?: "", + ) + val labelFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__actions__name_label), + value = action?.label ?: "", + ) + val descriptionFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__actions__description_label), + value = action?.description ?: "", + ) + val defaultFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__actions__default_action_label), + value = action?.default ?: "", + ) + val specialFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__actions__spacial_action_label), + value = action?.special ?: "", + ) + val criticalFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__actions__critical_action_label), + value = action?.critical ?: "", + ) + val canBeCriticalFlow = MutableStateFlow(action?.canBeCritical ?: false) + return GMActionFieldUio( + id = idFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + label = labelFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + description = descriptionFlow.createLwaTextField(), + canBeCritical = LwaCheckBoxUio( + enable = true, + checked = canBeCriticalFlow, + onCheckedChange = { canBeCriticalFlow.value = it }, + ), + default = defaultFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + special = specialFlow.createLwaTextField( + checkForError = { canBeCriticalFlow.value && it.isBlank() }, + ), + critical = criticalFlow.createLwaTextField( + checkForError = { canBeCriticalFlow.value && it.isBlank() }, + ), + ) + } + + private suspend fun commonSkills(): List { + return listOf( + COMBAT_ID, + DODGE_ID, + GRAB_ID, + THROW_ID, + ATHLETICS_ID, + ACROBATICS_ID, + PERCEPTION_ID, + SEARCH_ID, + EMPATHY_ID, + PERSUASION_ID, + INTIMIDATION_ID, + SPIEL_ID, + BARGAIN_ID, + DISCRETION_ID, + SLEIGHT_OF_HAND_ID, + AID_ID, + ).map { id -> + CharacterSheet.Skill( + id = id, + label = commonSkillLabel(id)!!, + description = commonSkillDescription(id)!!, + base = commonSkillBase(id)!!, + bonus = null, + level = 1, + occupation = false, + used = false, + ) + } + } + + private suspend fun commonSkillLabel( + skillId: String, + ): String? { + return when (skillId) { + COMBAT_ID -> Res.string.character_sheet_edit__skills__combat_label + DODGE_ID -> Res.string.character_sheet_edit__skills__dodge_label + GRAB_ID -> Res.string.character_sheet_edit__skills__grab_label + THROW_ID -> Res.string.character_sheet_edit__skills__throw_label + ATHLETICS_ID -> Res.string.character_sheet_edit__skills__athletics_label + ACROBATICS_ID -> Res.string.character_sheet_edit__skills__acrobatics_label + PERCEPTION_ID -> Res.string.character_sheet_edit__skills__perception_label + SEARCH_ID -> Res.string.character_sheet_edit__skills__search_label + EMPATHY_ID -> Res.string.character_sheet_edit__skills__empathy_label + PERSUASION_ID -> Res.string.character_sheet_edit__skills__persuasion_label + INTIMIDATION_ID -> Res.string.character_sheet_edit__skills__intimidation_label + SPIEL_ID -> Res.string.character_sheet_edit__skills__spiel_label + BARGAIN_ID -> Res.string.character_sheet_edit__skills__bargain_label + DISCRETION_ID -> Res.string.character_sheet_edit__skills__discretion_label + SLEIGHT_OF_HAND_ID -> Res.string.character_sheet_edit__skills__sleight_of_hand_label + AID_ID -> Res.string.character_sheet_edit__skills__aid_label + else -> null + }?.let { + getString(it) + } + } + + private suspend fun commonSkillDescription( + skillId: String, + ): String? { + return when (skillId) { + COMBAT_ID -> Res.string.character_sheet_edit__skills__combat_description + DODGE_ID -> Res.string.character_sheet_edit__skills__dodge_description + GRAB_ID -> Res.string.character_sheet_edit__skills__grab_description + THROW_ID -> Res.string.character_sheet_edit__skills__throw_description + ATHLETICS_ID -> Res.string.character_sheet_edit__skills__athletics_description + ACROBATICS_ID -> Res.string.character_sheet_edit__skills__acrobatics_description + PERCEPTION_ID -> Res.string.character_sheet_edit__skills__perception_description + SEARCH_ID -> Res.string.character_sheet_edit__skills__search_description + EMPATHY_ID -> Res.string.character_sheet_edit__skills__empathy_description + PERSUASION_ID -> Res.string.character_sheet_edit__skills__persuasion_description + INTIMIDATION_ID -> Res.string.character_sheet_edit__skills__intimidation_description + SPIEL_ID -> Res.string.character_sheet_edit__skills__spiel_description + BARGAIN_ID -> Res.string.character_sheet_edit__skills__bargain_description + DISCRETION_ID -> Res.string.character_sheet_edit__skills__discretion_description + SLEIGHT_OF_HAND_ID -> Res.string.character_sheet_edit__skills__sleight_of_hand_description + AID_ID -> Res.string.character_sheet_edit__skills__aid_description + else -> null + }?.let { + getString(it) + } + } + + private suspend fun commonSkillBase( + skillId: String, + ): String? { + return when (skillId) { + COMBAT_ID -> Res.string.character_sheet_edit__skills__combat_base + DODGE_ID -> Res.string.character_sheet_edit__skills__dodge_base + GRAB_ID -> Res.string.character_sheet_edit__skills__grab_base + THROW_ID -> Res.string.character_sheet_edit__skills__throw_base + ATHLETICS_ID -> Res.string.character_sheet_edit__skills__athletics_base + ACROBATICS_ID -> Res.string.character_sheet_edit__skills__acrobatics_base + PERCEPTION_ID -> Res.string.character_sheet_edit__skills__perception_base + SEARCH_ID -> Res.string.character_sheet_edit__skills__search_base + EMPATHY_ID -> Res.string.character_sheet_edit__skills__empathy_base + PERSUASION_ID -> Res.string.character_sheet_edit__skills__persuasion_base + INTIMIDATION_ID -> Res.string.character_sheet_edit__skills__intimidation_base + SPIEL_ID -> Res.string.character_sheet_edit__skills__spiel_base + BARGAIN_ID -> Res.string.character_sheet_edit__skills__bargain_base + DISCRETION_ID -> Res.string.character_sheet_edit__skills__discretion_base + SLEIGHT_OF_HAND_ID -> Res.string.character_sheet_edit__skills__sleight_of_hand_base + AID_ID -> Res.string.character_sheet_edit__skills__aid_base + else -> null + }?.let { + getString(it) + } + } + + private fun commonSkillBaseFlow( + scope: CoroutineScope, + characteristics: GMCharacterEditPageUio.CharacteristicsUio, + skillId: String, + ): MutableStateFlow { + val flow = combine( + characteristics.strength.valueFlow.map { it.toIntOrNull() ?: 0 }, + characteristics.dexterity.valueFlow.map { it.toIntOrNull() ?: 0 }, + characteristics.constitution.valueFlow.map { it.toIntOrNull() ?: 0 }, + characteristics.height.valueFlow.map { it.toIntOrNull() ?: 0 }, + characteristics.intelligence.valueFlow.map { it.toIntOrNull() ?: 0 }, + characteristics.power.valueFlow.map { it.toIntOrNull() ?: 0 }, + characteristics.charisma.valueFlow.map { it.toIntOrNull() ?: 0 }, + ) { (strength, dexterity, constitution, height, intelligence, power, charisma) -> + when (skillId) { + COMBAT_ID -> skillUseCase.combat( + dexterity = dexterity, + ) + + DODGE_ID -> skillUseCase.dodge( + dexterity = dexterity, + ) + + GRAB_ID -> skillUseCase.grab( + strength = strength, height = height, + ) + + THROW_ID -> skillUseCase.shoot( + strength = strength, dexterity = dexterity, + ) + + ATHLETICS_ID -> skillUseCase.athletics( + strength = strength, constitution = constitution, + ) + + ACROBATICS_ID -> skillUseCase.acrobatics( + dexterity = dexterity, constitution = constitution, + ) + + PERCEPTION_ID -> skillUseCase.perception( + intelligence = intelligence, + ) + + SEARCH_ID -> skillUseCase.search( + intelligence = intelligence, + ) + + EMPATHY_ID -> skillUseCase.empathy( + charisma = charisma, intelligence = intelligence, + ) + + PERSUASION_ID -> skillUseCase.persuasion( + charisma = charisma, + ) + + INTIMIDATION_ID -> skillUseCase.intimidation( + charisma = charisma, height = height, power = power, + ) + + SPIEL_ID -> skillUseCase.spiel( + charisma = charisma, intelligence = intelligence, + ) + + BARGAIN_ID -> skillUseCase.bargain( + charisma = charisma, + ) + + DISCRETION_ID -> skillUseCase.discretion( + charisma = charisma, dexterity = dexterity, height = height, + ) + + SLEIGHT_OF_HAND_ID -> skillUseCase.sleightOfHand( + dexterity = dexterity, + ) + + AID_ID -> skillUseCase.aid( + intelligence = intelligence, dexterity = dexterity, + ) + + else -> null + }?.toString() + } + + val state = MutableStateFlow("") + flow.onEach { value -> state.update { value ?: "" } } + .launchIn(scope = scope) + + return state + } + + private operator fun Array.component6(): T = this[5] + + private operator fun Array.component7(): T = this[6] +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditPage.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditPage.kt new file mode 100644 index 0000000..fb60e33 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditPage.kt @@ -0,0 +1,661 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +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.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +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.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.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.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBox +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio +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.GMCharacterEditDestination +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog.GMCharacterSheetCopyDialog +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog.GMCharacterSheetCopyDialogViewModel +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item.GMActionField +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item.GMActionFieldUio +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item.GMSkillField +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item.GMSkillFieldUio +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.lwa +import com.pixelized.desktop.lwa.utils.extention.unpack +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__action__save +import lwacharactersheet.composeapp.generated.resources.game_master__action__save_as +import lwacharactersheet.composeapp.generated.resources.game_master__character_edit__title +import lwacharactersheet.composeapp.generated.resources.ic_save_24dp +import lwacharactersheet.composeapp.generated.resources.ic_save_as_24dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +@Stable +data class GMCharacterEditPageUio( + val characterSheetId: LwaTextFieldUio, + val name: LwaTextFieldUio, + val job: LwaTextFieldUio, + val portrait: LwaTextFieldUio, + val thumbnail: LwaTextFieldUio, + val externalLink: LwaTextFieldUio, + val level: LwaTextFieldUio, + val levelUp: LwaCheckBoxUio, + val characteristics: CharacteristicsUio, + val tags: MutableStateFlow>, + val commonSkills: List, + val specialSkills: List, + val magicSkills: List, + val actions: List, +) { + @Stable + data class CharacteristicsUio( + val strength: LwaTextFieldUio, + val dexterity: LwaTextFieldUio, + val constitution: LwaTextFieldUio, + val height: LwaTextFieldUio, + val intelligence: LwaTextFieldUio, + val power: LwaTextFieldUio, + val charisma: LwaTextFieldUio, + ) { + val values = + listOf(strength, dexterity, constitution, height, intelligence, power, charisma) + } +} + +@Stable +object GMCharacterEditPageDefault { + @Stable + val paddings = PaddingValues(all = 8.dp) + + @Stable + val spacing: DpSize = DpSize(8.dp, 8.dp) +} + +@Composable +fun GMCharacterEditPage( + viewModel: GMCharacterEditViewModel = koinViewModel(), + copyViewModel: GMCharacterSheetCopyDialogViewModel = koinViewModel(), +) { + val focus = LocalFocusManager.current // focus cause a lot of issues with animateItem + val screen = LocalScreenController.current + val scope = rememberCoroutineScope() + val formState = rememberLazyListState() + val tagsState = rememberLazyListState() + + val form = viewModel.form.collectAsState() + + GMCharacterEditContent( + modifier = Modifier.fillMaxSize(), + formState = formState, + tagsState = tagsState, + page = form, + onBack = { + screen.navigateBack() + }, + onSave = { + scope.launch { + if (viewModel.save()) { + screen.navigateBack() + } + } + }, + onSaveAs = { + scope.launch { + copyViewModel.showCharacterSheetCopyDialog( + characterSheetId = form.value?.characterSheetId?.valueFlow?.value, + characterName = form.value?.name?.valueFlow?.value, + ) + } + }, + onTag = { tag -> + viewModel.addTag(tag = tag) + }, + onSpecialSkillAdd = { + focus.clearFocus(force = true) + scope.launch { viewModel.addSpecialSkill() } + }, + onSpecialSkillDelete = { index -> + focus.clearFocus(force = true) + scope.launch { viewModel.deleteSpecialSkill(index = index) } + }, + onMagicSkillAdd = { + focus.clearFocus(force = true) + scope.launch { viewModel.addMagicSkill() } + }, + onMagicSkillDelete = { index -> + focus.clearFocus(force = true) + scope.launch { viewModel.deleteMagicSkill(index = index) } + }, + onActionAdd = { + focus.clearFocus(force = true) + scope.launch { viewModel.addAction() } + }, + onActionDelete = { index -> + focus.clearFocus(force = true) + scope.launch { viewModel.deleteAction(index = index) } + }, + onSkillOccupation = { skill -> + focus.clearFocus(force = true) + viewModel.toggleOccupation(skillId = skill.id.valueFlow.value) + } + ) + + GMCharacterSheetCopyDialog( + dialog = copyViewModel.dialog.collectAsState(), + onDismissRequest = { + scope.launch { copyViewModel.hideCharacterSheetCopyDialog() } + }, + onConfirm = { dialog -> + scope.launch { + if (copyViewModel.isValid()) { + if (viewModel.saveAs( + overrideCharacterSheetId = dialog.id.unpack(), + overrideCharacterName = dialog.name.unpack(), + ) + ) { + screen.navigateBack() + } + } + } + }, + ) + + CharacterEditKeyHandler( + onDismissRequest = { + screen.navigateBack() + } + ) + + ErrorSnackHandler( + error = copyViewModel.error, + ) + + ErrorSnackHandler( + error = viewModel.error, + ) +} + +@Composable +fun GMCharacterEditContent( + modifier: Modifier = Modifier, + paddings: PaddingValues = GMCharacterEditPageDefault.paddings, + spacing: DpSize = GMCharacterEditPageDefault.spacing, + scope: CoroutineScope = rememberCoroutineScope(), + formState: LazyListState = rememberLazyListState(), + tagsState: LazyListState = rememberLazyListState(), + page: State, + onBack: () -> Unit, + onSaveAs: () -> Unit, + onSave: () -> Unit, + onTag: (GMTagUio) -> Unit, + onSpecialSkillAdd: () -> Unit, + onSpecialSkillDelete: (Int) -> Unit, + onMagicSkillAdd: () -> Unit, + onMagicSkillDelete: (Int) -> Unit, + onActionAdd: () -> Unit, + onActionDelete: (Int) -> Unit, + onSkillOccupation: (GMSkillFieldUio) -> Unit, +) { + val layoutDirection = LocalLayoutDirection.current + val verticalPadding = remember(paddings) { + PaddingValues( + top = paddings.calculateTopPadding(), + bottom = paddings.calculateBottomPadding(), + ) + } + val horizontalPadding = remember(paddings, layoutDirection) { + PaddingValues( + start = paddings.calculateStartPadding(layoutDirection = layoutDirection), + end = paddings.calculateEndPadding(layoutDirection = layoutDirection), + ) + } + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.game_master__character_edit__title), + ) + }, + navigationIcon = { + IconButton( + onClick = onBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null, + ) + } + }, + actions = { + val saveAsHover = remember { MutableInteractionSource() } + TextButton( + modifier = Modifier.hoverable(interactionSource = saveAsHover), + onClick = onSaveAs, + ) { + AnimatedVisibility( + visible = saveAsHover.collectIsHoveredAsState().value, + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + ) { + Text( + modifier = Modifier.padding(end = spacing.width), + color = MaterialTheme.lwa.colorScheme.base.primary, + fontWeight = FontWeight.SemiBold, + text = stringResource(Res.string.game_master__action__save_as), + ) + } + Icon( + painter = painterResource(Res.drawable.ic_save_as_24dp), + tint = MaterialTheme.lwa.colorScheme.base.primary, + contentDescription = null, + ) + } + TextButton( + onClick = onSave, + ) { + AnimatedVisibility( + visible = saveAsHover.collectIsHoveredAsState().value.not(), + enter = fadeIn() + expandHorizontally(), + exit = fadeOut() + shrinkHorizontally(), + ) { + Text( + modifier = Modifier.padding(end = spacing.width), + color = MaterialTheme.lwa.colorScheme.base.primary, + fontWeight = FontWeight.SemiBold, + text = stringResource(Res.string.game_master__action__save), + ) + } + Icon( + painter = painterResource(Res.drawable.ic_save_24dp), + tint = MaterialTheme.lwa.colorScheme.base.primary, + contentDescription = null, + ) + } + } + ) + }, + content = { + when (val form = page.value) { + null -> Box( + modifier = Modifier.fillMaxSize(), + ) + + else -> { + val tags = form.tags.collectAsState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = verticalPadding, + state = formState, + verticalArrangement = Arrangement.spacedBy(space = spacing.height), + ) { + item(key = "MetadataTitle") { + Text( + modifier = Modifier + .animateItem() + .padding(horizontalPadding), + style = MaterialTheme.lwa.typography.base.caption, + text = "Metadata", + ) + } + item(key = "characterSheetId") { + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = form.characterSheetId, + singleLine = true, + ) + } + item(key = "name") { + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = form.name, + singleLine = true, + ) + } + item(key = "job") { + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = form.job, + singleLine = true, + ) + } + item(key = "portrait") { + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = form.portrait, + singleLine = true, + ) + } + item(key = "thumbnail") { + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = form.thumbnail, + singleLine = true, + ) + } + item(key = "external") { + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = form.externalLink, + singleLine = true, + ) + } + item(key = "Level") { + Row( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = spacing.width), + ) { + LwaTextField( + modifier = Modifier.weight(weight = 1f), + field = form.level, + singleLine = true, + ) + Row( + modifier = Modifier.weight(weight = 1f), + verticalAlignment = Alignment.CenterVertically, + ) { + LwaCheckBox( + field = form.levelUp, + ) + Text( + style = MaterialTheme.lwa.typography.base.body1, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + text = "Montée de niveau" + ) + } + } + } + item(key = "tags") { + LazyRow( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + scope.launch { + tagsState.scrollBy(-delta) + } + }, + ), + state = tagsState, + contentPadding = horizontalPadding, + ) { + items( + items = tags.value, + key = { tag -> tag.id }, + ) { tag -> + GMTagButton( + tag = tag, + onTag = { onTag(tag) }, + ) + } + } + } + item(key = "CharacteristicsTitle") { + Text( + modifier = Modifier + .animateItem() + .padding(paddingValues = horizontalPadding), + style = MaterialTheme.lwa.typography.base.caption, + text = "Characteristics", + ) + } + items( + items = form.characteristics.values, + key = { "characteristics-${it.labelFlow}" }, + ) { characteristic -> + LwaTextField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + field = characteristic, + singleLine = true, + ) + } + item(key = "CommonSkillsTitle") { + Text( + modifier = Modifier + .animateItem() + .padding(paddingValues = horizontalPadding), + style = MaterialTheme.lwa.typography.base.caption, + text = "Compétences communes", + ) + } + items( + items = form.commonSkills, + key = { item -> item.key }, + ) { item -> + GMSkillField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + skill = item, + onDelete = null, + onOccupation = onSkillOccupation, + ) + } + item(key = "SpecialSkillsTitle") { + Text( + modifier = Modifier + .animateItem() + .padding(paddingValues = horizontalPadding), + style = MaterialTheme.lwa.typography.base.caption, + text = "Compétences spéciales", + ) + } + itemsIndexed( + items = form.specialSkills, + key = { _, item -> item.key }, + ) { index, item -> + GMSkillField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + skill = item, + onDelete = { onSpecialSkillDelete(index) }, + onOccupation = onSkillOccupation, + ) + } + item(key = "SpecialSkillsAdd") { + Row( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onSpecialSkillAdd, + ) { + Text(text = "Ajouter une compétence spéciale") + } + } + } + item(key = "MagicSkillsTitle") { + Text( + modifier = Modifier + .animateItem() + .padding(paddingValues = horizontalPadding), + style = MaterialTheme.lwa.typography.base.caption, + text = "Compétences magiques", + ) + } + itemsIndexed( + items = form.magicSkills, + key = { _, item -> item.key }, + ) { index, item -> + GMSkillField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + skill = item, + onDelete = { onMagicSkillDelete(index) }, + onOccupation = onSkillOccupation, + ) + } + item(key = "MagicSkillsAdd") { + Row( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onMagicSkillAdd, + ) { + Text(text = "Ajouter une compétence magique") + } + } + } + item(key = "ActionsTitle") { + Text( + modifier = Modifier + .animateItem() + .padding(paddingValues = horizontalPadding), + style = MaterialTheme.lwa.typography.base.caption, + text = "Actions", + ) + } + itemsIndexed( + items = form.actions, + key = { _, item -> item.id }, + ) { index, item -> + GMActionField( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + action = item, + onDelete = { onActionDelete(index) } + ) + } + item(key = "ActionAdd") { + Row( + modifier = Modifier + .animateItem() + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onActionAdd, + ) { + Text(text = "Ajouter une action") + } + } + } + } + } + } + }, + ) +} + + +@Composable +private fun CharacterEditKeyHandler( + onDismissRequest: () -> Unit, +) { + KeyHandler { + when { + it.type == KeyEventType.KeyDown && it.key == Key.Escape -> { + onDismissRequest() + true + } + + else -> false + } + } +} + +private fun NavHostController.navigateBack() = popBackStack( + route = GMCharacterEditDestination.baseRoute(), + inclusive = true, +) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditViewModel.kt new file mode 100644 index 0000000..f3190f4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/GMCharacterEditViewModel.kt @@ -0,0 +1,185 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository +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.GMCharacterEditDestination +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterFactory.Companion.NPC_ID +import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterFactory.Companion.PLAYER_ID +import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio +import kotlinx.coroutines.delay +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 GMCharacterEditViewModel( + private val characterSheetRepository: CharacterSheetRepository, + private val tagRepository: TagRepository, + private val factory: GMCharacterEditFactory, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val argument = GMCharacterEditDestination.Argument(savedStateHandle) + + private val _form = MutableStateFlow(null) + val form: StateFlow get() = _form + + private val _error = MutableSharedFlow() + val error: SharedFlow get() = _error + + init { + viewModelScope.launch { + _form.value = factory.createForm( + scope = this, + sheet = characterSheetRepository.characterDetail(argument.characterSheetId), + tags = tagRepository.charactersTags().filterNot { + it.id == PLAYER_ID || it.id == NPC_ID + }, + ) + } + } + + suspend fun save(): Boolean { + try { + val characterSheet = factory.convertIntoModel( + sheet = characterSheetRepository.characterDetail(argument.characterSheetId), + form = _form.value ?: error("Form is empty") + ) + characterSheetRepository.updateCharacter( + sheet = characterSheet, + create = argument.characterSheetId == null, + ) + return true + } catch (exception: Exception) { + val message = ErrorSnackUio.from(exception) + _error.emit(message) + return false + } + } + + suspend fun saveAs( + overrideCharacterSheetId: String?, + overrideCharacterName: String?, + ): Boolean { + try { + if (overrideCharacterSheetId == null) { + error("characterSheetId should not be null") + } + if (overrideCharacterName == null) { + error("character name should not be null") + } + val characterSheet = factory.convertIntoModel( + sheet = characterSheetRepository.characterDetail(argument.characterSheetId), + form = _form.value ?: error("Form is empty") + ).copy( + id = overrideCharacterSheetId, + name = overrideCharacterName, + ) + characterSheetRepository.updateCharacter( + sheet = characterSheet, + create = true, + ) + return true + } catch (exception: Exception) { + val message = ErrorSnackUio.from(exception) + _error.emit(message) + return false + } + } + + fun addTag(tag: GMTagUio) { + _form.value?.tags?.update { tags -> + tags.toMutableList().also { + val index = it.indexOf(tag) + if (index > -1) { + it[index] = tag.copy(highlight = tag.highlight.not()) + } + } + } + } + + suspend fun deleteSpecialSkill( + index: Int, + ) { + _form.update { form -> + form?.copy( + specialSkills = form.specialSkills.toMutableList().also { + it.removeAt(index) + }, + ) + } + } + + suspend fun addSpecialSkill() { + _form.update { form -> + form?.copy( + specialSkills = form.specialSkills.toMutableList().also { + it.add(factory.createSkill(skill = null)) + }, + ) + } + } + + suspend fun deleteMagicSkill( + index: Int, + ) { + _form.update { form -> + form?.copy( + magicSkills = form.magicSkills.toMutableList().also { + it.removeAt(index) + }, + ) + } + } + + suspend fun addMagicSkill() { + _form.update { form -> + form?.copy( + magicSkills = form.magicSkills.toMutableList().also { + it.add(factory.createSkill(skill = null)) + }, + ) + } + } + + suspend fun deleteAction( + index: Int, + ) { + _form.update { form -> + form?.copy( + actions = form.actions.toMutableList().also { + it.removeAt(index) + }, + ) + } + } + + suspend fun addAction() { + delay(150) + _form.update { form -> + form?.copy( + actions = form.actions.toMutableList().also { + it.add(factory.createAction(action = null)) + }, + ) + } + } + + fun toggleOccupation( + skillId: String, + ) { + val common = _form.value?.commonSkills ?: emptyList() + val special = _form.value?.specialSkills ?: emptyList() + val magics = _form.value?.magicSkills ?: emptyList() + (common + special + magics) + .firstOrNull { it.id.valueFlow.value == skillId } + ?.let { + it.occupation.onCheckedChange(it.occupation.checked.value.not()) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialog.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialog.kt new file mode 100644 index 0000000..2c10f52 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialog.kt @@ -0,0 +1,120 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pixelized.desktop.lwa.ui.composable.character.LwaDialog +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.theme.color.component.LwaTextFieldColors +import kotlinx.coroutines.flow.StateFlow +import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.dialog__cancel_action +import lwacharactersheet.composeapp.generated.resources.dialog__confirm_action +import org.jetbrains.compose.resources.stringResource + +@Stable +data class GMCharacterSheetCopyDialogUio( + val id: LwaTextFieldUio, + val name: LwaTextFieldUio, + val enableConfirm: StateFlow, +) + +@Stable +object GMCharacterSheetCopyDialogDefault { + @Stable + val paddings = PaddingValues(top = 8.dp, start = 16.dp, end = 16.dp) + + @Stable + val spacings = 8.dp +} + +@Composable +fun GMCharacterSheetCopyDialog( + dialog: State, + paddings: PaddingValues = GMCharacterSheetCopyDialogDefault.paddings, + spacings: Dp = GMCharacterSheetCopyDialogDefault.spacings, + onDismissRequest: () -> Unit, + onConfirm: (GMCharacterSheetCopyDialogUio) -> Unit, +) { + LwaDialog( + state = dialog, + onDismissRequest = onDismissRequest, + onConfirm = { dialog.value?.let(onConfirm) }, + ) { + GMCharacterSheetCopyContent( + paddings = paddings, + spacings = spacings, + dialog = it, + onDismissRequest = onDismissRequest, + onConfirm = onConfirm, + ) + } +} + +@Composable +private fun GMCharacterSheetCopyContent( + modifier: Modifier = Modifier, + paddings: PaddingValues = GMCharacterSheetCopyDialogDefault.paddings, + spacings: Dp = GMCharacterSheetCopyDialogDefault.spacings, + dialog: GMCharacterSheetCopyDialogUio, + onDismissRequest: () -> Unit, + onConfirm: (GMCharacterSheetCopyDialogUio) -> Unit, +) { + Column( + modifier = Modifier + .padding(paddingValues = paddings) + .then(other = modifier) + ) { + LwaTextField( + modifier = Modifier.fillMaxWidth(), + colors = LwaTextFieldColors(backgroundColor = Color.Transparent), + field = dialog.id, + ) + LwaTextField( + modifier = Modifier.fillMaxWidth(), + colors = LwaTextFieldColors(backgroundColor = Color.Transparent), + field = dialog.name, + ) + Spacer( + modifier = Modifier.height(height = spacings) + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton( + onClick = onDismissRequest, + ) { + Text( + color = MaterialTheme.colors.primary.copy(alpha = .7f), + text = stringResource(Res.string.dialog__cancel_action) + ) + } + TextButton( + enabled = dialog.enableConfirm.collectAsState().value, + onClick = { onConfirm(dialog) }, + ) { + Text( + text = stringResource(Res.string.dialog__confirm_action) + ) + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialogFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialogFactory.kt new file mode 100644 index 0000000..352d306 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialogFactory.kt @@ -0,0 +1,77 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog + +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldFlow +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__id_label +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__name_label +import org.jetbrains.compose.resources.getString + +class GMCharacterSheetCopyDialogFactory { + + suspend fun createDialogForm( + scope: CoroutineScope, + characterSheetId: String?, + characterName: String?, + ): GMCharacterSheetCopyDialogUio { + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__id_label), + value = characterSheetId ?: "", + ) + val nameFlow = createLwaTextFieldFlow( + label = getString(Res.string.character_sheet_edit__name_label), + value = characterName?.let { "$it (copy)" } ?: "", + ) + return GMCharacterSheetCopyDialogUio( + id = idFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + name = nameFlow.createLwaTextField( + checkForError = { it.isBlank() }, + ), + enableConfirm = enableConfirm( + scope = scope, + idFlow = idFlow, + nameFlow = nameFlow, + ), + ) + } + + private fun enableConfirm( + scope: CoroutineScope, + idFlow: LwaTextFieldFlow, + nameFlow: LwaTextFieldFlow, + ): StateFlow { + return combine( + idFlow.valueFlow, + idFlow.errorFlow, + nameFlow.valueFlow, + nameFlow.errorFlow, + transform = ::enableConfirm + ).stateIn( + scope = scope, + started = SharingStarted.Lazily, + initialValue = enableConfirm( + id = idFlow.valueFlow.value, + idError = idFlow.errorFlow.value, + name = idFlow.valueFlow.value, + nameError = nameFlow.errorFlow.value, + ) + ) + } + + fun enableConfirm( + id: String, + idError: Boolean, + name: String, + nameError: Boolean, + ): Boolean { + return idError.not() && nameError.not() && id.isNotBlank() && name.isNotBlank() + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialogViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialogViewModel.kt new file mode 100644 index 0000000..01a0e1e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/dialog/GMCharacterSheetCopyDialogViewModel.kt @@ -0,0 +1,64 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.dialog + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository +import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio +import com.pixelized.desktop.lwa.utils.extention.unpack +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 + + +class GMCharacterSheetCopyDialogViewModel( + private val characterSheetRepository: CharacterSheetRepository, + private val factory: GMCharacterSheetCopyDialogFactory, +) : ViewModel() { + + private val _dialog = MutableStateFlow(null) + val dialog: StateFlow get() = _dialog + + private val _error = MutableSharedFlow() + val error: SharedFlow get() = _error + + suspend fun showCharacterSheetCopyDialog( + characterSheetId: String?, + characterName: String?, + ) { + _dialog.value = factory.createDialogForm( + scope = viewModelScope, + characterSheetId = characterSheetId?.takeIf { it.isNotBlank() }, + characterName = characterName?.takeIf { it.isNotBlank() }, + ) + } + + suspend fun hideCharacterSheetCopyDialog() { + _dialog.value = null + } + + suspend fun isValid(): Boolean { + try { + val idFlow = _dialog.value?.id?.errorFlow as? MutableStateFlow + val characterSheetId = _dialog.value?.id?.unpack() + + if (characterSheetId == null) { + idFlow?.update { true } + error("Missing characterSheetId") + } + val isIdValid = characterSheetRepository.checkCharacterSheetIdValidity( + characterSheetId = characterSheetId, + ) + if (isIdValid.not()) { + idFlow?.update { true } + error("characterSheetId already in usage") + } + return isIdValid + } catch (exception: Exception) { + val message = ErrorSnackUio.from(exception) + _error.emit(message) + return false + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/item/GMActionField.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/item/GMActionField.kt new file mode 100644 index 0000000..827def5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/item/GMActionField.kt @@ -0,0 +1,156 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBox +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.utils.extention.moveFocusOnTab + +@Stable +data class GMActionFieldUio( + val id: LwaTextFieldUio, + val label: LwaTextFieldUio, + val description: LwaTextFieldUio, + val canBeCritical: LwaCheckBoxUio, + val default: LwaTextFieldUio, + val special: LwaTextFieldUio?, + val critical: LwaTextFieldUio?, +) + +@Stable +object GMActionFieldDefault { + @Stable + val spacing: Dp = 4.dp +} + +@Composable +fun GMActionField( + modifier: Modifier = Modifier, + space: Dp = GMActionFieldDefault.spacing, + action: GMActionFieldUio, + onDelete: (GMActionFieldUio) -> Unit, +) { + val focus = LocalFocusManager.current + val showMenu = remember { mutableStateOf(false) } + val onDismissRequest = remember { + { + focus.clearFocus(force = true) + showMenu.value = false + } + } + val canBeCritical = action.canBeCritical.checked.collectAsState() + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(space = space), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(space = space), + verticalAlignment = Alignment.CenterVertically, + ) { + LwaTextField( + modifier = Modifier.weight(1f), + field = action.id, + ) + LwaTextField( + modifier = Modifier.weight(1f), + field = action.label, + ) + LwaCheckBox( + field = action.canBeCritical, + ) + Box { + IconButton( + onClick = { showMenu.value = showMenu.value.not() }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + tint = MaterialTheme.colors.primary, + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu.value, + offset = DpOffset(x = (-48).dp - space, y = (-48).dp), + onDismissRequest = onDismissRequest, + ) { + DropdownMenuItem( + modifier = modifier, + onClick = { + onDismissRequest() + onDelete(action) + }, + ) { + Icon( + imageVector = Icons.Default.Delete, + tint = MaterialTheme.colors.primary, + contentDescription = null, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = "Supprimer", + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth().animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(space = space), + ) { + LwaTextField( + modifier = Modifier.weight(1f), + field = action.default, + ) + if (canBeCritical.value) { + action.special?.let { field -> + LwaTextField( + modifier = Modifier.weight(1f), + field = field, + ) + } + action.critical?.let { field -> + LwaTextField( + modifier = Modifier.weight(1f), + field = field, + ) + } + } + } + LwaTextField( + modifier = Modifier + .fillMaxWidth() + .moveFocusOnTab(focusManager = LocalFocusManager.current), + field = action.description, + singleLine = false, + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/item/GMSkillFieldUio.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/item/GMSkillFieldUio.kt new file mode 100644 index 0000000..c72e155 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/edit/item/GMSkillFieldUio.kt @@ -0,0 +1,173 @@ +package com.pixelized.desktop.lwa.ui.screen.gamemaster.character.edit.item + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBox +import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.theme.color.component.LwaCheckboxColors +import com.pixelized.desktop.lwa.utils.extention.moveFocusOnTab + +@Stable +data class GMSkillFieldUio( + val key: String, + val id: LwaTextFieldUio, + val label: LwaTextFieldUio, + val description: LwaTextFieldUio?, + val base: LwaTextFieldUio, + val bonus: LwaTextFieldUio, + val level: LwaTextFieldUio, + val occupation: LwaCheckBoxUio, +) + +@Stable +object GMSkillFieldDefault { + @Stable + val spacing: DpSize = DpSize(4.dp, 4.dp) +} + +@Composable +fun GMSkillField( + modifier: Modifier = Modifier, + spacing: DpSize = GMSkillFieldDefault.spacing, + skill: GMSkillFieldUio, + onDelete: ((GMSkillFieldUio) -> Unit)?, + onOccupation: (GMSkillFieldUio) -> Unit, +) { + val focus = LocalFocusManager.current + val showMenu = remember { mutableStateOf(false) } + val onDismissRequest = remember { + { + focus.clearFocus(force = true) + showMenu.value = false + } + } + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = spacing.width), + ) { + Column( + modifier = Modifier.weight(weight = 1f), + verticalArrangement = Arrangement.spacedBy(space = spacing.height), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = spacing.width), + ) { + LwaTextField( + modifier = Modifier.weight(weight = 1f), + field = skill.id, + ) + LwaTextField( + modifier = Modifier.weight(weight = 1f), + field = skill.label, + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = spacing.width), + ) { + LwaTextField( + modifier = Modifier.weight(weight = 1f), + field = skill.base, + ) + LwaTextField( + modifier = Modifier.weight(weight = 1f), + field = skill.bonus, + ) + LwaTextField( + modifier = Modifier.weight(weight = 1f), + field = skill.level, + ) + } + skill.description?.let { description -> + LwaTextField( + modifier = Modifier + .fillMaxWidth() + .moveFocusOnTab(focusManager = LocalFocusManager.current), + field = description, + singleLine = false, + ) + } + } + Box { + IconButton( + onClick = { showMenu.value = showMenu.value.not() }, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + tint = MaterialTheme.colors.primary, + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu.value, + offset = DpOffset(x = (-48).dp - spacing.width, y = (-48).dp), + onDismissRequest = onDismissRequest, + ) { + DropdownMenuItem( + modifier = modifier, + enabled = onDelete != null, + onClick = { + onDismissRequest() + onDelete?.invoke(skill) + }, + ) { + Icon( + imageVector = Icons.Default.Delete, + tint = MaterialTheme.colors.primary, + contentDescription = null, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = "Supprimer", + ) + } + DropdownMenuItem( + modifier = modifier, + onClick = { + onDismissRequest() + onOccupation(skill) + }, + ) { + LwaCheckBox( + modifier = Modifier.size(size = 24.dp), + colors = LwaCheckboxColors(disabledColor = MaterialTheme.colors.primary), + field = skill.occupation, + enabled = false, + ) + Text( + modifier = Modifier.padding(start = 8.dp), + text = "Compétence d'occupation", + ) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacter.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacter.kt index 0f2301f..eaf143e 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacter.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacter.kt @@ -27,32 +27,40 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTag import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio import com.pixelized.desktop.lwa.ui.theme.lwa import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__external_link import lwacharactersheet.composeapp.generated.resources.game_master__character_action__add_to_group import lwacharactersheet.composeapp.generated.resources.game_master__character_action__add_to_npc +import lwacharactersheet.composeapp.generated.resources.game_master__character_action__delete import lwacharactersheet.composeapp.generated.resources.game_master__character_action__display_portrait +import lwacharactersheet.composeapp.generated.resources.game_master__character_action__external_link import lwacharactersheet.composeapp.generated.resources.game_master__character_action__remove_from_group import lwacharactersheet.composeapp.generated.resources.game_master__character_action__remove_from_npc import lwacharactersheet.composeapp.generated.resources.game_master__character_level__label +import lwacharactersheet.composeapp.generated.resources.ic_delete_forever_24dp import lwacharactersheet.composeapp.generated.resources.ic_face_24dp import lwacharactersheet.composeapp.generated.resources.ic_face_retouching_off_24dp import lwacharactersheet.composeapp.generated.resources.ic_group_24dp import lwacharactersheet.composeapp.generated.resources.ic_group_off_24dp import lwacharactersheet.composeapp.generated.resources.ic_imagesmode_24dp +import lwacharactersheet.composeapp.generated.resources.ic_link_24dp import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource +import java.net.URI @Stable data class GMCharacterItemUio( val characterSheetId: String, val name: String, + val job: String?, val level: Int, val tags: List, val actions: List, @@ -68,6 +76,14 @@ data class GMCharacterItemUio( label = Res.string.game_master__character_action__display_portrait, ) + @Stable + data class ExternalLink( + val externalLink: URI, + ): Action( + icon = Res.drawable.ic_link_24dp, + label = Res.string.game_master__character_action__external_link, + ) + @Stable data object AddToGroup : Action( icon = Res.drawable.ic_group_24dp, @@ -91,6 +107,14 @@ data class GMCharacterItemUio( icon = Res.drawable.ic_face_retouching_off_24dp, label = Res.string.game_master__character_action__remove_from_npc, ) + + @Stable + data class DeleteCharacter( + val characterSheetId: String, + ) : Action( + icon = Res.drawable.ic_delete_forever_24dp, + label = Res.string.game_master__character_action__delete, + ) } } @@ -137,6 +161,14 @@ fun GMCharacter( style = MaterialTheme.lwa.typography.base.body1, text = character.name, ) + character.job?.let { + Text( + modifier = Modifier.alignByBaseline(), + style = MaterialTheme.lwa.typography.base.body1, + fontStyle = FontStyle.Italic, + text = "($it)", + ) + } Text( modifier = Modifier.alignByBaseline(), style = MaterialTheme.lwa.typography.base.caption, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterFactory.kt index 0d4696a..53b7add 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterFactory.kt @@ -5,10 +5,15 @@ import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio import com.pixelized.desktop.lwa.utils.extention.unAccent import com.pixelized.shared.lwa.model.campaign.Campaign import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetPreview +import java.awt.Desktop +import java.net.URI import java.text.Collator class GMCharacterFactory { + private val isBrowserAvailable = Desktop.isDesktopSupported() + && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE) + companion object { const val PLAYER_ID = "PLAYER" const val NPC_ID = "NPC" @@ -25,20 +30,24 @@ class GMCharacterFactory { other = unAccentFilter, ignoreCase = true, ) + val matchJob = it.job.unAccent().contains( + other = unAccentFilter, + ignoreCase = true, + ) val matchTag = when (selectedTagId) { null -> true PLAYER_ID -> campaign.characters.contains(it.characterSheetId) NPC_ID -> campaign.npcs.contains(it.characterSheetId) - else -> false + else -> it.tags.contains(selectedTagId) } - matchName && matchTag + (matchName || matchJob) && matchTag } } suspend fun convertToGMCharacterPreviewUio( campaign: Campaign, characters: List, - tagIdMap: List, + tagIdMap: Map, ): List { return characters.map { convertToGMCharacterPreviewUio( @@ -52,23 +61,36 @@ class GMCharacterFactory { private suspend fun convertToGMCharacterPreviewUio( campaign: Campaign, character: CharacterSheetPreview, - tagIdMap: List, + tagIdMap: Map, ): GMCharacterItemUio { val isPlayer = campaign.characters.contains(character.characterSheetId) val isNpc = campaign.npcs.contains(character.characterSheetId) + val externalLink = if (isBrowserAvailable) { + character.externalLink?.let { URI.create(it) } + } else { + null + } + // Build the call tag list. - val tags = tagIdMap.filter { - when (it.id) { - PLAYER_ID -> isPlayer - NPC_ID -> isNpc - else -> false - } + val tags = if (isPlayer) { + listOfNotNull(tagIdMap[PLAYER_ID]) + } else { + emptyList() + } + if (isNpc) { + listOfNotNull(tagIdMap[NPC_ID]) + } else { + emptyList() + } + character.tags.mapNotNull { + tagIdMap[it] } // build the cell action list val actions = buildList { add(Action.DisplayPortrait) + if (externalLink != null) { + add(Action.ExternalLink(externalLink = externalLink)) + } when { isPlayer -> add(Action.RemoveFromGroup) isNpc -> add(Action.RemoveFromNpc) @@ -77,12 +99,14 @@ class GMCharacterFactory { add(Action.AddToNpc) } } + add(Action.DeleteCharacter(characterSheetId = character.characterSheetId)) } // return the cell UIO. return GMCharacterItemUio( characterSheetId = character.characterSheetId, name = character.name, level = character.level, + job = character.job.takeIf { it.isNotBlank() }, tags = tags, actions = actions, ) diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterPage.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterPage.kt index e20a0bf..cb1bdb9 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterPage.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterPage.kt @@ -33,7 +33,6 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.pixelized.desktop.lwa.LocalBlurController -import com.pixelized.desktop.lwa.LocalWindowController import com.pixelized.desktop.lwa.ui.composable.character.alterteration.CharacterSheetAlterationDialog import com.pixelized.desktop.lwa.ui.composable.character.alterteration.CharacterSheetAlterationDialogViewModel import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialog @@ -43,8 +42,8 @@ import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterShe import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler 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.navigateToGameMasterCharacterEditPage import com.pixelized.desktop.lwa.ui.navigation.screen.destination.navigateToLevelScreen -import com.pixelized.desktop.lwa.ui.navigation.window.destination.navigateToCharacterSheetEdit import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanel import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.CharacterDetailPanelViewModel import com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.DetailPanelUio @@ -54,9 +53,7 @@ import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors import kotlinx.coroutines.launch import lwacharactersheet.composeapp.generated.resources.Res -import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__create__title import lwacharactersheet.composeapp.generated.resources.game_master__create_character_sheet -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel @@ -68,7 +65,6 @@ fun GMCharacterPage( dismissedViewModel: CharacterSheetDiminishedDialogViewModel = koinViewModel(), alterationViewModel: CharacterSheetAlterationDialogViewModel = koinViewModel(), ) { - val windows = LocalWindowController.current val screens = LocalScreenController.current val blurController = LocalBlurController.current val scope = rememberCoroutineScope() @@ -95,17 +91,14 @@ fun GMCharacterPage( scope.launch { characterDetailViewModel.editCharacter( characterSheetId = characterSheetId, - windows = windows, + screens = screens, ) } }, onCharacterSheetCreate = { - scope.launch { - windows.navigateToCharacterSheetEdit( - characterId = null, - title = getString(Res.string.character_sheet_edit__create__title), - ) - } + screens.navigateToGameMasterCharacterEditPage( + characterSheetId = null + ) }, ) @@ -239,10 +232,10 @@ fun GMCharacterContent( .animateItem(), character = character, onClick = { - onCharacterSheetDetail(character.characterSheetId) + onCharacterSheetEdit(character.characterSheetId) }, onSecondary = { - onCharacterSheetEdit(character.characterSheetId) + onCharacterSheetDetail(character.characterSheetId) }, onAction = { action -> onCharacterAction(character.characterSheetId, action) diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterViewModel.kt index 6e1436a..5f0e35d 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/character/list/GMCharacterViewModel.kt @@ -6,7 +6,8 @@ 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.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterItemUio.Action import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory import com.pixelized.desktop.lwa.utils.extention.unAccent @@ -22,27 +23,23 @@ import kotlinx.coroutines.runBlocking import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.game_master__character__filter import org.jetbrains.compose.resources.getString +import java.awt.Desktop class GMCharacterViewModel( private val networkRepository: NetworkRepository, private val campaignRepository: CampaignRepository, - characterSheetRepository: CharacterSheetRepository, + private val characterSheetRepository: CharacterSheetRepository, tagRepository: TagRepository, private val factory: GMCharacterFactory, private val tagFactory: GMTagFactory, ) : ViewModel() { private val selectedTagId = MutableStateFlow(null) - private val filterValue = MutableStateFlow("") - val filter = LwaTextFieldUio( - enable = true, + private val _filter = createLwaTextFieldFlow( label = runBlocking { getString(Res.string.game_master__character__filter) }, - valueFlow = filterValue, - isError = MutableStateFlow(false), - placeHolder = null, - onValueChange = { filterValue.value = it }, ) + val filter = _filter.createLwaTextField() val tags = combine( tagRepository.charactersTagFlow(), @@ -62,7 +59,7 @@ class GMCharacterViewModel( campaignRepository.campaignFlow(), characterSheetRepository.characterSheetPreviewFlow(), filter.valueFlow.map { it.unAccent() }, - tags, + tags.map { tags -> tags.associateBy { it.id } }, selectedTagId, ) { campaign, characters, unAccentFilter, tagIdMap, selectedTagId -> factory.convertToGMCharacterPreviewUio( @@ -88,31 +85,44 @@ class GMCharacterViewModel( viewModelScope.launch { try { when (action) { - Action.DisplayPortrait -> networkRepository.share( + is Action.DisplayPortrait -> networkRepository.share( GameMasterEvent.DisplayPortrait( timestamp = System.currentTimeMillis(), characterSheetId = characterSheetId, ) ) - Action.AddToGroup -> campaignRepository.addCharacter( + is Action.ExternalLink -> { + if (Desktop.isDesktopSupported()) { + val desktop = Desktop.getDesktop() + if (desktop.isSupported(Desktop.Action.BROWSE)) { + desktop.browse(action.externalLink) + } + } + } + + is Action.AddToGroup -> campaignRepository.addCharacter( characterSheetId = characterSheetId, ) - Action.AddToNpc -> campaignRepository.addNpc( + is Action.AddToNpc -> campaignRepository.addNpc( characterSheetId = characterSheetId, ) - Action.RemoveFromGroup -> campaignRepository.removeCharacter( + is Action.RemoveFromGroup -> campaignRepository.removeCharacter( characterSheetId = characterSheetId, ) - Action.RemoveFromNpc -> campaignRepository.removeNpc( + is Action.RemoveFromNpc -> campaignRepository.removeNpc( characterSheetId = characterSheetId, ) + + is Action.DeleteCharacter -> characterSheetRepository.deleteCharacter( + characterSheetId = action.characterSheetId, + ) } } catch (exception: Exception) { - // TODO + // TODO proper exception handling } } } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditFactory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditFactory.kt index 9c8d117..cb08b20 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditFactory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditFactory.kt @@ -1,7 +1,8 @@ package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio -import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory import com.pixelized.shared.lwa.model.item.Item import com.pixelized.shared.lwa.model.tag.Tag @@ -17,66 +18,82 @@ import org.jetbrains.compose.resources.getString class GMItemEditFactory( private val tagFactory: GMTagFactory, ) { - suspend fun createForm( - originId: String?, item: Item?, tags: Collection, - ): GMItemEditPageUio { - val idFlow = createFlows(initialValue = item?.id ?: "") - val labelFlow = createFlows(initialValue = item?.metadata?.label ?: "") - val descriptionFlow = createFlows(initialValue = item?.metadata?.description ?: "") - val imageFlow = createFlows(initialValue = item?.metadata?.image ?: "") - val thumbnailFlow = createFlows(initialValue = item?.metadata?.thumbnail ?: "") + ): GMItemEditViewModel.GMItemEditForm { + val idFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__item__edit_id), + value = item?.id ?: "", + ) + val labelFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__item__edit_label), + value = item?.metadata?.label ?: "", + ) + val descriptionFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__item__edit_description), + value = item?.metadata?.description ?: "", + ) + val imageFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__item__edit_image), + value = item?.metadata?.image ?: "", + ) + val thumbnailFlow = createLwaTextFieldFlow( + label = getString(Res.string.game_master__item__edit_thumbnail), + value = item?.metadata?.thumbnail ?: "", + ) val stackableFlow = MutableStateFlow(value = item?.options?.stackable ?: false) val equipableFlow = MutableStateFlow(value = item?.options?.equipable ?: false) val consumableFlow = MutableStateFlow(value = item?.options?.consumable ?: false) - val tagFlow = MutableStateFlow( tagFactory.convertToGMTagItemUio( tags = tags, selectedTagIds = item?.tags ?: emptyList(), ) ) - - return GMItemEditPageUio( - id = idFlow.createLwaTextField( - enable = originId == null, - label = getString(Res.string.game_master__item__edit_id), - ), - label = labelFlow.createLwaTextField( - label = getString(Res.string.game_master__item__edit_label), - ), - description = descriptionFlow.createLwaTextField( - label = getString(Res.string.game_master__item__edit_description), - ), - image = imageFlow.createLwaTextField( - label = getString(Res.string.game_master__item__edit_image), - ), - thumbnail = thumbnailFlow.createLwaTextField( - label = getString(Res.string.game_master__item__edit_thumbnail), - ), - equipable = LwaCheckBoxUio( - checked = equipableFlow, - onCheckedChange = { equipableFlow.value = it }, - ), - stackable = LwaCheckBoxUio( - checked = stackableFlow, - onCheckedChange = { stackableFlow.value = it }, - ), - consumable = LwaCheckBoxUio( - checked = consumableFlow, - onCheckedChange = { consumableFlow.value = it }, - ), - tags = tagFlow, + return GMItemEditViewModel.GMItemEditForm( + idFlow = idFlow, + labelFlow = labelFlow, + descriptionFlow = descriptionFlow, + imageFlow = imageFlow, + thumbnailFlow = thumbnailFlow, + stackableFlow = stackableFlow, + equipableFlow = equipableFlow, + consumableFlow = consumableFlow, + tagFlow = tagFlow, ) } - suspend fun createItem( + fun createForm( + form: GMItemEditViewModel.GMItemEditForm, + originId: String?, + ): GMItemEditPageUio { + return GMItemEditPageUio( + id = form.idFlow.createLwaTextField(enable = originId == null), + label = form.labelFlow.createLwaTextField(), + description = form.descriptionFlow.createLwaTextField(), + image = form.imageFlow.createLwaTextField(), + thumbnail = form.thumbnailFlow.createLwaTextField(), + equipable = LwaCheckBoxUio( + checked = form.equipableFlow, + onCheckedChange = { form.equipableFlow.value = it }, + ), + stackable = LwaCheckBoxUio( + checked = form.stackableFlow, + onCheckedChange = { form.stackableFlow.value = it }, + ), + consumable = LwaCheckBoxUio( + checked = form.consumableFlow, + onCheckedChange = { form.consumableFlow.value = it }, + ), + tags = form.tagFlow, + ) + } + + fun createItem( form: GMItemEditPageUio?, ): Item? { if (form == null) return null - return Item( id = form.id.valueFlow.value, metadata = Item.MetaData( @@ -96,25 +113,4 @@ class GMItemEditFactory( alterations = emptyList(), // TODO, ) } - - private fun createFlows( - initialValue: String = "", - initialError: Boolean = false, - ): Pair, MutableStateFlow> { - return MutableStateFlow(value = initialValue) to MutableStateFlow(value = initialError) - } - - private fun Pair, MutableStateFlow>.createLwaTextField( - enable: Boolean = true, - label: String, - ): LwaTextFieldUio { - return LwaTextFieldUio( - enable = enable, - isError = second, - label = label, - valueFlow = first, - placeHolder = null, - onValueChange = { first.value = it }, - ) - } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditPage.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditPage.kt index 96fbd80..fe6478e 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditPage.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditPage.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -39,6 +41,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -46,6 +49,7 @@ 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.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController @@ -142,6 +146,20 @@ private fun GMItemEditContent( onSave: () -> Unit, onTag: (GMTagUio) -> Unit, ) { + val layoutDirection = LocalLayoutDirection.current + val verticalPadding = remember(paddings) { + PaddingValues( + top = paddings.calculateTopPadding(), + bottom = paddings.calculateBottomPadding(), + ) + } + val horizontalPadding = remember(paddings, layoutDirection) { + PaddingValues( + start = paddings.calculateStartPadding(layoutDirection = layoutDirection), + end = paddings.calculateEndPadding(layoutDirection = layoutDirection), + ) + } + Scaffold( modifier = modifier, topBar = { @@ -201,71 +219,66 @@ private fun GMItemEditContent( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = paddings, + contentPadding = verticalPadding, verticalArrangement = Arrangement.spacedBy(space = 8.dp), ) { - item( - key = "Id", - ) { + item(key = "Id") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.id, singleLine = true, ) } - item( - key = "Name", - ) { + item(key = "Name") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.label, singleLine = true, ) } - item( - key = "Description", - ) { + item(key = "Description") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.description, singleLine = false, ) } - item( - key = "Image", - ) { + item(key = "Image") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.image, singleLine = true, ) } - item( - key = "Thumbnail", - ) { + item(key = "Thumbnail") { LwaTextField( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), field = it.thumbnail, singleLine = true, ) } - item( - key = "Stackable", - ) { + item(key = "Stackable") { Row( modifier = Modifier .animateItem() - .fillMaxWidth().padding(start = 16.dp), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding) + .padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -277,13 +290,13 @@ private fun GMItemEditContent( ) } } - item( - key = "Equipable", - ) { + item(key = "Equipable") { Row( modifier = Modifier .animateItem() - .fillMaxWidth().padding(start = 16.dp), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding) + .padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -295,13 +308,13 @@ private fun GMItemEditContent( ) } } - item( - key = "Consumable", - ) { + item(key = "Consumable") { Row( modifier = Modifier .animateItem() - .fillMaxWidth().padding(start = 16.dp), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding) + .padding(start = 16.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -313,9 +326,7 @@ private fun GMItemEditContent( ) } } - item( - key = "Tags", - ) { + item(key = "Tags") { LazyRow( modifier = Modifier.draggable( orientation = Orientation.Horizontal, @@ -325,6 +336,7 @@ private fun GMItemEditContent( } }, ), + contentPadding = horizontalPadding, state = tagsState, horizontalArrangement = Arrangement.spacedBy(space = 8.dp), ) { @@ -339,13 +351,12 @@ private fun GMItemEditContent( } } } - item( - key = "Actions", - ) { + item(key = "Actions") { Column( modifier = Modifier .animateItem() - .fillMaxWidth(), + .fillMaxWidth() + .padding(paddingValues = horizontalPadding), horizontalAlignment = Alignment.End ) { Button( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditViewModel.kt index 05b3368..e2ba1a3 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/edit/GMItemEditViewModel.kt @@ -7,13 +7,17 @@ import com.pixelized.desktop.lwa.network.LwaNetworkException import com.pixelized.desktop.lwa.repository.item.ItemRepository import com.pixelized.desktop.lwa.repository.tag.TagRepository import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldFlow import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMItemEditDestination 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.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -28,13 +32,22 @@ class GMItemEditViewModel( private val _error = MutableSharedFlow() val error: SharedFlow get() = _error - private val _form = MutableStateFlow(null) - val form: StateFlow get() = _form + private val _form = MutableStateFlow(null) + val form: StateFlow = _form.map { + if (it == null) return@map null + factory.createForm( + form = it, + originId = argument.id, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = null, + ) init { viewModelScope.launch { _form.value = factory.createForm( - originId = argument.id, item = itemRepository.item(itemId = argument.id), tags = tagRepository.itemsTags(), ) @@ -51,8 +64,8 @@ class GMItemEditViewModel( ) return true } catch (exception: LwaNetworkException) { - _form.value?.id?.isError?.value = exception.code == ErrorCode.ItemId - _form.value?.label?.isError?.value = exception.code == ErrorCode.ItemName + _form.value?.idFlow?.errorFlow?.value = exception.code == ErrorCode.ItemId + _form.value?.labelFlow?.errorFlow?.value = exception.code == ErrorCode.ItemName val message = ErrorSnackUio.from(exception = exception) _error.emit(message) @@ -65,7 +78,7 @@ class GMItemEditViewModel( } fun addTag(tag: GMTagUio) { - _form.value?.tags?.update { tags -> + _form.value?.tagFlow?.update { tags -> tags.toMutableList().also { val index = it.indexOf(tag) if (index > -1) { @@ -74,4 +87,16 @@ class GMItemEditViewModel( } } } + + data class GMItemEditForm( + val idFlow: LwaTextFieldFlow, + val labelFlow: LwaTextFieldFlow, + val descriptionFlow: LwaTextFieldFlow, + val imageFlow: LwaTextFieldFlow, + val thumbnailFlow: LwaTextFieldFlow, + val stackableFlow: MutableStateFlow, + val equipableFlow: MutableStateFlow, + val consumableFlow: MutableStateFlow, + val tagFlow: MutableStateFlow>, + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemPage.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemPage.kt index 47f9b30..35473f2 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemPage.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemPage.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler @@ -106,7 +107,7 @@ private fun GMItemContent( modifier = Modifier.fillMaxWidth().weight(1f), ) { LazyColumn( - modifier = Modifier.matchParentSize(), + modifier = Modifier.matchParentSize().clipToBounds(), contentPadding = remember { PaddingValues( start = padding, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemViewModel.kt index a236887..7204d21 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/gamemaster/item/list/GMItemViewModel.kt @@ -6,6 +6,8 @@ import com.pixelized.desktop.lwa.repository.item.ItemRepository import com.pixelized.desktop.lwa.repository.tag.TagRepository import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextField +import com.pixelized.desktop.lwa.ui.composable.textfield.createLwaTextFieldFlow import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio import com.pixelized.desktop.lwa.utils.extention.unAccent @@ -31,19 +33,14 @@ class GMItemViewModel( ) : ViewModel() { private val selectedTagId = MutableStateFlow(null) - private val filterValue = MutableStateFlow("") private val _error = MutableSharedFlow() val error: SharedFlow get() = _error - val filter = LwaTextFieldUio( - enable = true, + private val _filter = createLwaTextFieldFlow( label = runBlocking { getString(Res.string.game_master__character__filter) }, - valueFlow = filterValue, - isError = MutableStateFlow(false), - placeHolder = null, - onValueChange = { filterValue.value = it }, ) + val filter = _filter.createLwaTextField() val tags: StateFlow> = combine( tagRepository.itemsTagFlow(), diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/color/component/LwaCheckboxColors.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/color/component/LwaCheckboxColors.kt index 1c45066..c50e4d6 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/color/component/LwaCheckboxColors.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/color/component/LwaCheckboxColors.kt @@ -2,16 +2,24 @@ package com.pixelized.desktop.lwa.ui.theme.color.component import androidx.compose.material.CheckboxColors import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.ContentAlpha import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import com.pixelized.desktop.lwa.ui.theme.color.LwaColors -import com.pixelized.desktop.lwa.ui.theme.lwa +import androidx.compose.ui.graphics.Color @Composable @Stable fun LwaCheckboxColors( - colors: LwaColors = MaterialTheme.lwa.colorScheme, + checkedColor: Color = MaterialTheme.colors.primary, + uncheckedColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f), + checkmarkColor: Color = MaterialTheme.colors.surface, + disabledColor: Color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled), + disabledIndeterminateColor: Color = checkedColor.copy(alpha = ContentAlpha.disabled), ): CheckboxColors = CheckboxDefaults.colors( - checkedColor = colors.base.primary, + checkedColor = checkedColor, + uncheckedColor = uncheckedColor, + checkmarkColor = checkmarkColor, + disabledColor = disabledColor, + disabledIndeterminateColor = disabledIndeterminateColor, ) \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/size/LwaSize.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/size/LwaSize.kt index 31aecb0..9650cc7 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/size/LwaSize.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/theme/size/LwaSize.kt @@ -34,7 +34,7 @@ fun lwaSize( ), sheet: LwaSize.Sheet = LwaSize.Sheet( subCategory = 14.dp, - characteristic = DpSize(width = 76.dp, height = 110.dp), + characteristic = DpSize(width = 96.dp, height = 128.dp), ), ) = remember { LwaSize( diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/LwaTextFieldUioEx+unpack.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/LwaTextFieldUioEx+unpack.kt new file mode 100644 index 0000000..efe36fc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/LwaTextFieldUioEx+unpack.kt @@ -0,0 +1,14 @@ +package com.pixelized.desktop.lwa.utils.extention + +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio + +inline fun LwaTextFieldUio.unpack(): T? { + if (errorFlow.value) return null + val value = valueFlow.value.ifBlank { placeHolder } + return when (T::class) { + String::class -> value + Float::class -> value?.toFloatOrNull() + Int::class -> value?.toIntOrNull() + else -> null + } as T? +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+Ribbon.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+Ribbon.kt index 76d180d..aad591e 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+Ribbon.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+Ribbon.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -23,7 +24,7 @@ fun Modifier.ribbon( size = Size( width = width.toPx(), height = size.height, - ) + ), ) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+isElementVisible.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+isElementVisible.kt new file mode 100644 index 0000000..46a2c7e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+isElementVisible.kt @@ -0,0 +1,25 @@ +package com.pixelized.desktop.lwa.utils.extention + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned + + +/** + * is an extension function for Modifier. + * It allows you to attach visibility detection logic to any composable element + */ +fun Modifier.isElementVisible(onVisibilityChanged: (Boolean) -> Unit): Modifier { + var isVisible = false + return this.onGloballyPositioned { layoutCoordinates -> + val localIsVisible = layoutCoordinates.parentLayoutCoordinates?.let { + val parentBounds = it.boundsInWindow() + val childBounds = layoutCoordinates.boundsInWindow() + parentBounds.overlaps(childBounds) + } ?: false + if (isVisible != localIsVisible) { + isVisible = localIsVisible + onVisibilityChanged(localIsVisible) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+moveFocusOnTab.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+moveFocusOnTab.kt new file mode 100644 index 0000000..f75d9b5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+moveFocusOnTab.kt @@ -0,0 +1,27 @@ +package com.pixelized.desktop.lwa.utils.extention + +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type + +fun Modifier.moveFocusOnTab( + focusManager: FocusManager, +) = composed { + onPreviewKeyEvent { + if (it.type == KeyEventType.KeyDown && it.key == Key.Tab) { + focusManager.moveFocus( + if (it.isShiftPressed) FocusDirection.Previous else FocusDirection.Next + ) + true + } else { + false + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+thenIf.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+thenIf.kt new file mode 100644 index 0000000..f95b857 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/utils/extention/ModifierEx+thenIf.kt @@ -0,0 +1,10 @@ +package com.pixelized.desktop.lwa.utils.extention + +import androidx.compose.ui.Modifier + +fun Modifier.thenIf(condition: Boolean, block: Modifier.() -> Modifier): Modifier { + return when (condition) { + true -> block(this) + else -> this + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/model/character/CharacterSheetService.kt b/server/src/main/kotlin/com/pixelized/server/lwa/model/character/CharacterSheetService.kt index 0298cc4..5005af7 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/model/character/CharacterSheetService.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/model/character/CharacterSheetService.kt @@ -31,6 +31,10 @@ class CharacterSheetService( return sheets[characterSheetId] } + fun charactersJson(characterSheetId: String): CharacterPreviewJson? { + return sheets[characterSheetId]?.let { factory.convertToPreviewJson(sheet = it) } + } + fun charactersJson(): List { return sheets.map { factory.convertToPreviewJson(sheet = it.value) } } diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/Engine.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/Engine.kt index 3398a26..e9b2849 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/Engine.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/Engine.kt @@ -91,7 +91,7 @@ class Engine( characterSheetId = message.characterSheetId, ) - is CampaignEvent.UpdateScene -> Unit // TODO + is CampaignEvent.UpdateScene -> Unit } is ApiSynchronisation -> Unit // Nothing to do there. diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt index 5582ca7..7d0facb 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/Server.kt @@ -13,7 +13,8 @@ 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.getCharacters +import com.pixelized.server.lwa.server.rest.character.getPreviewCharacter +import com.pixelized.server.lwa.server.rest.character.getPreviewCharacters import com.pixelized.server.lwa.server.rest.character.putCharacter import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration import com.pixelized.server.lwa.server.rest.character.putCharacterDamage @@ -107,7 +108,7 @@ class LocalServer { try { send(frame) } catch (exception: Exception) { - // TODO + // TODO proper exception handling println("WebSocket exception: ${exception.localizedMessage}") } } @@ -125,7 +126,7 @@ class LocalServer { } } }.onFailure { exception -> - // TODO + // TODO proper exception handling println("WebSocket exception: ${exception.message}") }.also { job.cancel() @@ -165,10 +166,6 @@ class LocalServer { route( path = "/character", ) { - get( - path = "/all", - body = engine.getCharacters(), - ) get( path = "/detail", body = engine.getCharacter(), @@ -201,6 +198,18 @@ class LocalServer { body = engine.putCharacterAlteration(), ) } + route( + path = "/preview" + ) { + get( + path = "/all", + body = engine.getPreviewCharacters(), + ) + get( + path = "/detail", + body = engine.getPreviewCharacter(), + ) + } } route( path = "/alteration", diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_PreviewCharacter.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_PreviewCharacter.kt new file mode 100644 index 0000000..90e3132 --- /dev/null +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_PreviewCharacter.kt @@ -0,0 +1,30 @@ +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 io.ktor.server.response.respond +import io.ktor.server.routing.RoutingContext + +fun Engine.getPreviewCharacter(): suspend RoutingContext.() -> Unit { + return { + try { + // get the query parameter + val characterSheetId = call.queryParameters.characterSheetId + // fetch the character preview + val json = characterService.charactersJson(characterSheetId = characterSheetId) + ?: error("CharacterSheet preview with id:$characterSheetId not found.") + // send it back to the user. + call.respond( + message = APIResponse.success( + data = json, + ), + ) + } catch (exception: Exception) { + call.exception( + exception = exception + ) + } + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_Characters.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_PreviewCharacters.kt similarity index 89% rename from server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_Characters.kt rename to server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_PreviewCharacters.kt index 4f62a70..bdba701 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_Characters.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/GET_PreviewCharacters.kt @@ -6,7 +6,7 @@ import com.pixelized.shared.lwa.protocol.rest.APIResponse import io.ktor.server.response.respond import io.ktor.server.routing.RoutingContext -fun Engine.getCharacters(): suspend RoutingContext.() -> Unit { +fun Engine.getPreviewCharacters(): suspend RoutingContext.() -> Unit { return { try { call.respond( diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/Module.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/Module.kt index c1b3f00..44ef629 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/Module.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/Module.kt @@ -19,6 +19,7 @@ import com.pixelized.shared.lwa.usecase.CharacterSheetUseCase import com.pixelized.shared.lwa.usecase.ExpressionUseCase import com.pixelized.shared.lwa.usecase.RollUseCase import com.pixelized.shared.lwa.usecase.SkillStepUseCase +import com.pixelized.shared.lwa.usecase.SkillUseCase import kotlinx.serialization.json.Json import org.koin.core.module.dsl.factoryOf import org.koin.dsl.module @@ -68,6 +69,7 @@ val parserDependencies val useCaseDependencies get() = module { factoryOf(::CharacterSheetUseCase) + factoryOf(::SkillUseCase) factoryOf(::SkillStepUseCase) factoryOf(::RollUseCase) factoryOf(::ExpressionUseCase) diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheet.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheet.kt index 55b8705..d389595 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheet.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheet.kt @@ -3,8 +3,10 @@ package com.pixelized.shared.lwa.model.characterSheet data class CharacterSheet( val id: String, val name: String, + val job: String, val portrait: String?, val thumbnail: String?, + val externalLink: String?, val level: Int, val shouldLevelUp: Boolean, // characteristics @@ -26,6 +28,8 @@ data class CharacterSheet( val magicSkills: List, // actions val actions: List, + // tags + val tags: List, ) { fun skill(id: String?): Skill? = commonSkills.firstOrNull { it.id == id } ?: specialSkills.firstOrNull { it.id == id } diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetJsonV1.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetJsonV1.kt index ec47459..580599d 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetJsonV1.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetJsonV1.kt @@ -6,8 +6,10 @@ import kotlinx.serialization.Serializable data class CharacterSheetJsonV1( override val id: String, override val name: String, + val job: String?, val portrait: String?, val thumbnail: String?, + val externalLink: String?, val level: Int, val shouldLevelUp: Boolean?, // characteristics @@ -29,6 +31,8 @@ data class CharacterSheetJsonV1( val magics: List, // actions val rolls: List, + // tags + val tag: List?, ) : CharacterSheetJson { @Serializable diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetPreview.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetPreview.kt index 205ab64..3c2c27c 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetPreview.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/CharacterSheetPreview.kt @@ -3,5 +3,8 @@ package com.pixelized.shared.lwa.model.characterSheet data class CharacterSheetPreview( val characterSheetId: String, val name: String, + val job: String, val level: Int, + val externalLink: String?, + val tags: List, ) diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonFactory.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonFactory.kt index 71ad69a..ae2bb8e 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonFactory.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonFactory.kt @@ -26,8 +26,10 @@ class CharacterSheetJsonFactory( val json = CharacterSheetJsonV1( id = sheet.id, name = sheet.name, + job = sheet.job, portrait = sheet.portrait?.takeIf { it.isNotBlank() }, thumbnail = sheet.thumbnail?.takeIf { it.isNotBlank() }, + externalLink = sheet.externalLink, level = sheet.level, shouldLevelUp = sheet.shouldLevelUp, strength = sheet.strength, @@ -88,17 +90,34 @@ class CharacterSheetJsonFactory( critical = it.critical ) }, + tag = sheet.tags, ) return json } - suspend fun convertFromJson( + fun convertToPreview( + sheet: CharacterSheet, + ): CharacterSheetPreview { + return CharacterSheetPreview( + characterSheetId = sheet.id, + name = sheet.name, + job = sheet.job, + level = sheet.level, + externalLink = sheet.externalLink, + tags = sheet.tags, + ) + } + + fun convertFromJson( json: CharacterPreviewJson, ): CharacterSheetPreview { return CharacterSheetPreview( characterSheetId = json.id, name = json.name, + job = json.job ?: "", level = json.level, + externalLink = json.externalLink, + tags = json.tags, ) } @@ -110,7 +129,10 @@ class CharacterSheetJsonFactory( portrait = sheet.portrait, thumbnail = sheet.thumbnail, name = sheet.name, + job = sheet.job, level = sheet.level, + externalLink = sheet.externalLink, + tags = sheet.tags, ) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonV1Factory.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonV1Factory.kt index 456da20..aa02201 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonV1Factory.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/characterSheet/factory/CharacterSheetJsonV1Factory.kt @@ -13,8 +13,10 @@ class CharacterSheetJsonV1Factory( CharacterSheet( id = json.id, name = json.name, + job = json.job ?: "", portrait = json.portrait, thumbnail = json.thumbnail, + externalLink = json.externalLink, level = json.level, shouldLevelUp = json.shouldLevelUp ?: false, strength = json.strength, @@ -75,6 +77,7 @@ class CharacterSheetJsonV1Factory( critical = it.critical, ) }, + tags = json.tag ?: emptyList(), ) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/CharacterPreviewJson.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/CharacterPreviewJson.kt index 76e14a2..6fd9265 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/CharacterPreviewJson.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/CharacterPreviewJson.kt @@ -8,5 +8,8 @@ class CharacterPreviewJson( val portrait: String?, val thumbnail: String?, val name: String, + val job: String?, val level: Int, + val externalLink: String?, + val tags: List, ) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/ExpressionUseCase.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/ExpressionUseCase.kt index 7baf5f4..57e9059 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/ExpressionUseCase.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/ExpressionUseCase.kt @@ -7,6 +7,7 @@ import com.pixelized.shared.lwa.model.characterSheet.CharacterSheet import com.pixelized.shared.lwa.parser.expression.Expression import com.pixelized.shared.lwa.parser.expression.ExpressionParser import com.pixelized.shared.lwa.parser.word.Word +import com.pixelized.shared.lwa.utils.floor5 import kotlin.math.max import kotlin.math.min @@ -108,7 +109,7 @@ class ExpressionUseCase( } is Expression.Floor5 -> { - evaluate(expression.expression).let { it - it % 5 } + evaluate(expression.expression).floor5() } is Expression.Flat -> { diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/SkillUseCase.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/SkillUseCase.kt new file mode 100644 index 0000000..6f62159 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/usecase/SkillUseCase.kt @@ -0,0 +1,71 @@ +package com.pixelized.shared.lwa.usecase + +import com.pixelized.shared.lwa.utils.floor5 +import kotlin.math.max + +class SkillUseCase { + + fun combat(dexterity: Int): Int { + return (dexterity * 2).floor5() + } + + fun dodge(dexterity: Int): Int { + return (dexterity * 2).floor5() + } + + fun grab(strength: Int, height: Int): Int { + return (strength + height).floor5() + } + + fun shoot(strength: Int, dexterity: Int): Int { + return (strength + dexterity).floor5() + } + + fun athletics(strength: Int, constitution: Int): Int { + return (strength + constitution * 2).floor5() + } + + fun acrobatics(dexterity: Int, constitution: Int): Int { + return (dexterity + constitution * 2).floor5() + } + + fun perception(intelligence: Int): Int { + return (10 + intelligence * 2).floor5() + } + + fun search(intelligence: Int): Int { + return (10 + intelligence * 2).floor5() + } + + fun empathy(charisma: Int, intelligence: Int): Int { + return (charisma + intelligence).floor5() + } + + fun persuasion(charisma: Int): Int { + return (charisma * 3).floor5() + } + + fun intimidation(charisma: Int, height: Int, power: Int): Int { + return (charisma + max(height, power) * 2).floor5() + } + + fun spiel(charisma: Int, intelligence: Int): Int { + return (charisma * 2 + intelligence).floor5() + } + + fun bargain(charisma: Int): Int { + return (charisma * 2).floor5() + } + + fun discretion(charisma: Int, dexterity: Int, height: Int): Int { + return (charisma + dexterity * 2 - height).floor5() + } + + fun sleightOfHand(dexterity: Int): Int { + return (dexterity * 2).floor5() + } + + fun aid(intelligence: Int, dexterity: Int): Int { + return (intelligence + dexterity).floor5() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/utils/IntEx+floor5.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/utils/IntEx+floor5.kt new file mode 100644 index 0000000..fea977f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/utils/IntEx+floor5.kt @@ -0,0 +1,3 @@ +package com.pixelized.shared.lwa.utils + +fun Int.floor5(): Int = this - this % 5 \ No newline at end of file