From 9fce3f1cb848364802e9b025a0b551e9927994b2 Mon Sep 17 00:00:00 2001 From: Thomas Andres Gomez Date: Sat, 19 Apr 2025 19:37:55 +0200 Subject: [PATCH] Add Addable InventoryItem feature & quantity change. --- .../composeResources/values/strings.xml | 3 + .../desktop/lwa/network/LwaClient.kt | 7 +- .../desktop/lwa/network/LwaClientImpl.kt | 8 + .../inventory/InventoryRepository.kt | 32 ++- .../repository/inventory/InventoryStore.kt | 17 +- .../lwa/ui/composable/character/LwaDialog.kt | 78 ++++++ .../character/inventory/InventoryDialog.kt | 185 +++++++------- .../character/item/ItemDetailDialog.kt | 225 ++++++++++-------- .../character/item/ItemDetailDialogFactory.kt | 51 +++- .../item/ItemDetailDialogViewModel.kt | 101 +++++++- .../composable/character/purse/PurseDialog.kt | 178 +++++++------- .../character/purse/PurseDialogViewModel.kt | 4 +- .../ui/composable/tooltip/TooltipLayout2.kt | 2 + .../inventory/CharacterDetailInventory.kt | 34 ++- .../CharacterDetailInventoryFactory.kt | 1 + .../detail/inventory/item/InventoryItem.kt | 6 +- .../lwa/model/inventory/InventoryService.kt | 11 + .../lwa/model/inventory/InventoryStore.kt | 43 ++++ .../com/pixelized/server/lwa/server/Server.kt | 5 + .../exception/MissingParameterException.kt | 4 +- .../rest/character/PUT_Character_Damage.kt | 8 +- .../character/PUT_Character_Diminished.kt | 8 +- .../rest/character/PUT_Character_Fatigue.kt | 7 +- .../server/rest/inventory/CREATE_Inventory.kt | 41 ++++ .../server/rest/inventory/DELETE_Inventory.kt | 2 +- .../lwa/utils/extentions/ParametersExt.kt | 60 +++-- .../extentions/RoutingCallExt+exception.kt | 2 +- .../shared/lwa/protocol/rest/APIResponse.kt | 5 + 28 files changed, 785 insertions(+), 343 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/LwaDialog.kt create mode 100644 server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/CREATE_Inventory.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c830cd1..b44213f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -190,6 +190,9 @@ Retirer de la bourse Filtrer les objets Ajouter à l'inventaire + Ajouter à l'inventaire + Quantité + Modifier Cet objet n'a pas de description. Les caractéristiques constituent les aptitudes innées d’un personnage comme son intelligence, sa force, son charisme, etc. Elles ne sont pas acquises, mais peuvent être parfois augmentées par un entraînement ou une utilisation réussie. Les caractéristiques des humains normaux varient de 2 (niveau extrêmement bas) à 20 (maximum du potentiel humain), avec une moyenne de 10 ou 11. Plus une caractéristique est élevée plus le personnage est puissant dans cette aptitude.\nÀ la création de votre personnage, répartissez les valeurs suivantes dans les différentes caractéristiques : 15, 15, 13, 11, 10, 9 et 7. 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 6d70e5d..254ddf2 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 @@ -111,10 +111,15 @@ interface LwaClient { ): APIResponse suspend fun putInventory( - json: InventoryJson, + inventory: InventoryJson, create: Boolean, ): APIResponse + suspend fun createInventoryItem( + characterSheetId: String, + itemId: String, + ): APIResponse + suspend fun deleteInventory( characterSheetId: String, ): APIResponse 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 0e878bf..48af180 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 @@ -192,6 +192,14 @@ class LwaClientImpl( } .body>() + @Throws + override suspend fun createInventoryItem( + characterSheetId: String, + itemId: String, + ): APIResponse = client + .put("$root/inventory/create?characterSheetId=$characterSheetId&itemId=$itemId") + .body>() + @Throws override suspend fun deleteInventory(characterSheetId: String): APIResponse = client .delete("$root/inventory/delete?characterSheetId=$characterSheetId") diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryRepository.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryRepository.kt index d6f0db2..277d3d1 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryRepository.kt @@ -2,27 +2,32 @@ package com.pixelized.desktop.lwa.repository.inventory import com.pixelized.shared.lwa.model.inventory.Inventory import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map class InventoryRepository( private val inventoryStore: InventoryStore, ) { - val inventoryFlow get() = inventoryStore.inventories - suspend fun updateInventoryFlow(characterSheetId: String) { inventoryStore.updateInventoryFlow(characterSheetId = characterSheetId) } + fun inventoryFlow(): StateFlow> { + return inventoryStore.inventories + } + fun inventory( - characterSheetId: String?, - ): Inventory? { - return inventoryFlow.value[characterSheetId] + characterSheetId: String, + ): Inventory { + return inventoryStore.inventories.value[characterSheetId] + ?: Inventory.empty(characterSheetId) } fun inventoryFlow( - characterSheetId: String?, - ): Flow { - return inventoryFlow.map { it[characterSheetId] } + characterSheetId: String, + ): Flow { + return inventoryStore.inventories + .map { it[characterSheetId] ?: Inventory.empty(characterSheetId) } } @Throws @@ -36,6 +41,17 @@ class InventoryRepository( ) } + @Throws + suspend fun createInventoryItem( + characterSheetId: String, + itemId: String, + ): String { + return inventoryStore.createInventoryItem( + characterSheetId = characterSheetId, + itemId = itemId, + ) + } + @Throws suspend fun deleteItem( characterSheetId: String, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryStore.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryStore.kt index 763c243..5a86be5 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryStore.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/inventory/InventoryStore.kt @@ -65,7 +65,7 @@ class InventoryStore( create: Boolean, ) { val request = client.putInventory( - json = factory.convertToJson(inventory = inventory), + inventory = factory.convertToJson(inventory = inventory), create = create, ) if (request.success.not()) { @@ -73,6 +73,21 @@ class InventoryStore( } } + @Throws + suspend fun createInventoryItem( + characterSheetId: String, + itemId: String, + ): String { + val request = client.createInventoryItem( + characterSheetId = characterSheetId, + itemId = itemId, + ) + if (request.success.not()) { + LwaClient.error(error = request) + } + return request.data!! + } + @Throws suspend fun deleteInventory( characterSheetId: String, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/LwaDialog.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/LwaDialog.kt new file mode 100644 index 0000000..856450d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/LwaDialog.kt @@ -0,0 +1,78 @@ +package com.pixelized.desktop.lwa.ui.composable.character + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox +import com.pixelized.desktop.lwa.utils.extention.onPreviewEscape + +@Stable +object LwaDialogDefault { + val paddings = PaddingValues(vertical = 32.dp) +} + +@Composable +fun LwaDialog( + modifier: Modifier = Modifier, + paddings: PaddingValues = LwaDialogDefault.paddings, + color: Color = MaterialTheme.colors.surface, + state: State, + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, + content: @Composable BoxScope.(T) -> Unit, +) { + state.value?.let { dialog -> + Dialog( + onDismissRequest = onDismissRequest, + content = { + Box( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismissRequest, + ) + .onPreviewEscape( + escape = onDismissRequest, + enter = onConfirm, + ) + .fillMaxSize() + .padding(paddingValues = paddings) + .then(other = modifier), + contentAlignment = Alignment.Center, + ) { + DecoratedBox( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { }, + ) + ) { + Surface( + color = color, + ) { + Box { + content(dialog) + } + } + } + } + } + ) + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialog.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialog.kt index 278d741..2b0f820 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/inventory/InventoryDialog.kt @@ -6,7 +6,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +13,6 @@ 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.offset import androidx.compose.foundation.layout.padding @@ -43,7 +41,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog +import com.pixelized.desktop.lwa.ui.composable.character.LwaDialog import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox import com.pixelized.desktop.lwa.ui.composable.image.DesaturatedAsyncImage import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField @@ -51,7 +49,6 @@ import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio import com.pixelized.desktop.lwa.ui.composable.tooltip.TooltipLayout2 import com.pixelized.desktop.lwa.ui.theme.color.component.LwaTextFieldColors import com.pixelized.desktop.lwa.ui.theme.lwa -import com.pixelized.desktop.lwa.utils.extention.onPreviewEscape import com.pixelized.desktop.lwa.utils.rememberSaturationFilter import lwacharactersheet.composeapp.generated.resources.Res import lwacharactersheet.composeapp.generated.resources.character__inventory__inventory__dialog__title @@ -103,31 +100,33 @@ object InventoryDialogItemDefault { fun InventoryDialog( dialog: State, paddings: PaddingValues = InventoryDialogDefault.paddings, + spacing: Dp = InventoryDialogDefault.spacings, onDismissRequest: () -> Unit, - onItem: (String) -> Unit, + onItem: (InventoryDialogUio, String) -> Unit, ) { - dialog.value?.let { - Dialog( + LwaDialog( + state = dialog, + onDismissRequest = onDismissRequest, + onConfirm = onDismissRequest, + ) { + InventoryDialogContent( + dialog = it, + paddings = paddings, + spacing = spacing, onDismissRequest = onDismissRequest, - content = { - InventoryDialogContent( - dialog = it, - paddings = paddings, - onDismissRequest = onDismissRequest, - onItem = onItem, - ) - } + onItem = onItem, ) } } @Composable private fun InventoryDialogContent( + modifier: Modifier = Modifier, dialog: InventoryDialogUio, paddings: PaddingValues = InventoryDialogDefault.paddings, spacing: Dp = InventoryDialogDefault.spacings, onDismissRequest: () -> Unit, - onItem: (String) -> Unit, + onItem: (InventoryDialogUio, String) -> Unit, ) { val layoutDirection = LocalLayoutDirection.current val start = remember(layoutDirection) { paddings.calculateStartPadding(layoutDirection) } @@ -135,98 +134,80 @@ private fun InventoryDialogContent( val top = remember(layoutDirection) { paddings.calculateTopPadding() } val bottom = remember(layoutDirection) { paddings.calculateBottomPadding() } - Box( - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismissRequest, - ) - .onPreviewEscape( - escape = onDismissRequest, - enter = onDismissRequest, - ) - .fillMaxSize() - .padding(all = 32.dp), - contentAlignment = Alignment.Center, + Column( + modifier = modifier, ) { - DecoratedBox { - Surface { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = top, start = start, bottom = spacing, end = end), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = top, start = start, bottom = spacing, end = end), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier.weight(weight = 1f), + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + text = stringResource(Res.string.character__inventory__inventory__dialog__title), + ) + IconButton( + modifier = Modifier.offset(x = end, y = -top), + onClick = onDismissRequest, + ) { + Icon( + painter = painterResource(Res.drawable.ic_close_24dp), + tint = MaterialTheme.lwa.colorScheme.base.primary, + contentDescription = null, + ) + } + } + LwaTextField( + colors = LwaTextFieldColors(backgroundColor = Color.Transparent), + modifier = Modifier.fillMaxWidth(), + field = dialog.filter, + trailingIcon = { + val value = dialog.filter.valueFlow.collectAsState() + AnimatedVisibility( + visible = value.value.isNotBlank(), + enter = fadeIn(), + exit = fadeOut(), + ) { + IconButton( + onClick = { dialog.filter.onValueChange.invoke("") }, ) { - Text( - modifier = Modifier.weight(weight = 1f), - style = MaterialTheme.typography.h5, - fontWeight = FontWeight.Bold, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - text = stringResource(Res.string.character__inventory__inventory__dialog__title), + Icon( + painter = painterResource(Res.drawable.ic_cancel_24dp), + tint = MaterialTheme.lwa.colorScheme.base.primary, + contentDescription = null, ) - IconButton( - modifier = Modifier.offset(x = end, y = -top), - onClick = onDismissRequest, - ) { - Icon( - painter = painterResource(Res.drawable.ic_close_24dp), - tint = MaterialTheme.lwa.colorScheme.base.primary, - contentDescription = null, - ) - } - } - LwaTextField( - colors = LwaTextFieldColors(backgroundColor = Color.Transparent), - modifier = Modifier.fillMaxWidth(), - field = dialog.filter, - trailingIcon = { - val value = dialog.filter.valueFlow.collectAsState() - AnimatedVisibility( - visible = value.value.isNotBlank(), - enter = fadeIn(), - exit = fadeOut(), - ) { - IconButton( - onClick = { dialog.filter.onValueChange.invoke("") }, - ) { - Icon( - painter = painterResource(Res.drawable.ic_cancel_24dp), - tint = MaterialTheme.lwa.colorScheme.base.primary, - contentDescription = null, - ) - } - } - } - ) - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(weight = 1f), - contentPadding = PaddingValues( - start = start, - top = spacing, - end = end, - bottom = bottom - ), - verticalArrangement = Arrangement.spacedBy(space = spacing), - ) { - items( - items = dialog.items, - key = { it.itemId }, - ) { item -> - InventoryDialogItem( - modifier = Modifier.animateItem(), - item = item, - onItem = onItem, - ) - } } } } + ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(weight = 1f), + contentPadding = PaddingValues( + start = start, + top = spacing, + end = end, + bottom = bottom + ), + verticalArrangement = Arrangement.spacedBy(space = spacing), + ) { + items( + items = dialog.items, + key = { it.itemId }, + ) { item -> + InventoryDialogItem( + modifier = Modifier.animateItem(), + item = item, + onItem = { onItem(dialog, it) }, + ) + } } } } 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 b70de01..5cf0f31 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 @@ -1,26 +1,28 @@ package com.pixelized.desktop.lwa.ui.composable.character.item -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface 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.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,135 +31,164 @@ import androidx.compose.ui.layout.ContentScale 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.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox +import com.pixelized.desktop.lwa.ui.composable.character.LwaDialog import com.pixelized.desktop.lwa.ui.composable.image.DesaturatedAsyncImage +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 com.pixelized.desktop.lwa.ui.theme.lwa -import com.pixelized.desktop.lwa.utils.extention.onPreviewEscape import com.pixelized.desktop.lwa.utils.rememberSaturationFilter import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.character__inventory__inventory__dialog__action +import lwacharactersheet.composeapp.generated.resources.character__inventory__inventory__dialog__count_action import lwacharactersheet.composeapp.generated.resources.ic_close_24dp import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Stable data class ItemDetailDialogUio( + val characterSheetId: String, val inventoryId: String?, val itemId: String, + // meta val label: String, val description: String, val image: String?, - val option: OptionUio, -) { - @Stable - data class OptionUio( - val equipable: Boolean, - val consumable: Boolean, - ) -} + val count: Float, + // options + val countable: LwaTextFieldUio?, +) @Stable object ItemDetailDialogDefault { @Stable val paddings = PaddingValues(all = 16.dp) + + @Stable + val spacings = 8.dp } @Composable fun ItemDetailDialog( dialog: State, paddings: PaddingValues = ItemDetailDialogDefault.paddings, + spacings: Dp = ItemDetailDialogDefault.spacings, onDismissRequest: () -> Unit, + onConfirm: (ItemDetailDialogUio) -> Unit, + onAddItem: (ItemDetailDialogUio) -> Unit, ) { - dialog.value?.let { - Dialog( - onDismissRequest = onDismissRequest, - content = { - ItemDetailDialogContent( - dialog = it, - paddings = paddings, - onDismissRequest = onDismissRequest, + LwaDialog( + state = dialog, + onDismissRequest = onDismissRequest, + onConfirm = { dialog.value?.let(onConfirm) ?: onDismissRequest }, + ) { state -> + val layoutDirection = LocalLayoutDirection.current + val end = remember(layoutDirection, paddings) { paddings.calculateEndPadding(layoutDirection) } + val top = remember(paddings) { paddings.calculateTopPadding() } + + takeIf { state.image?.isNotEmpty() == true }?.let { + DesaturatedAsyncImage( + modifier = Modifier + .size(64.dp * 2) + .align(alignment = Alignment.TopEnd), + colorFilter = rememberSaturationFilter(), + model = state.image, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + filterQuality = FilterQuality.High, + contentDescription = null, + ) + } + + Column( + modifier = Modifier.padding(paddingValues = paddings), + verticalArrangement = Arrangement.spacedBy(space = spacings) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + modifier = Modifier.weight(weight = 1f), + style = MaterialTheme.typography.h5, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + text = state.label, ) - } - ) - } -} - -@Composable -private fun ItemDetailDialogContent( - modifier: Modifier = Modifier, - paddings: PaddingValues, - dialog: ItemDetailDialogUio, - onDismissRequest: () -> Unit, -) { - val layoutDirection = LocalLayoutDirection.current - val end = remember(layoutDirection, paddings) { paddings.calculateEndPadding(layoutDirection) } - val top = remember(paddings) { paddings.calculateTopPadding() } - - Box( - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismissRequest, - ) - .onPreviewEscape( - escape = onDismissRequest, - enter = onDismissRequest, - ) - .fillMaxSize() - .padding(all = 32.dp), - contentAlignment = Alignment.Center, - ) { - DecoratedBox { - Surface { - Box( - modifier = Modifier.padding(paddingValues = paddings) + IconButton( + modifier = Modifier.offset(x = end, y = -top), + onClick = onDismissRequest, ) { - takeIf { dialog.image?.isNotEmpty() == true }?.let { - DesaturatedAsyncImage( - modifier = Modifier - .size(64.dp * 2) - .align(alignment = Alignment.TopEnd), - colorFilter = rememberSaturationFilter(), - model = dialog.image, - contentScale = ContentScale.Crop, - alignment = Alignment.TopCenter, - filterQuality = FilterQuality.High, - contentDescription = null, - ) - } - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(space = 8.dp) + Icon( + painter = painterResource(Res.drawable.ic_close_24dp), + tint = MaterialTheme.lwa.colorScheme.base.primary, + contentDescription = null, + ) + } + } + + Text( + style = MaterialTheme.typography.body1, + text = state.description, + ) + + Spacer(modifier = Modifier) + + AnimatedContent( + targetState = state.inventoryId, + ) { + when (it) { + null -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + TextButton( + onClick = { onAddItem(state) }, ) { Text( - modifier = Modifier.weight(weight = 1f), - style = MaterialTheme.typography.h5, - fontWeight = FontWeight.Bold, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - text = dialog.label, + text = stringResource(Res.string.character__inventory__inventory__dialog__action) ) - IconButton( - modifier = Modifier.offset(x = end, y = -top), - onClick = onDismissRequest, - ) { - Icon( - painter = painterResource(Res.drawable.ic_close_24dp), - tint = MaterialTheme.lwa.colorScheme.base.primary, - contentDescription = null, + } + } + + else -> Column( + verticalArrangement = Arrangement.spacedBy(space = spacings) + ) { + if (state.countable != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = spacings, + Alignment.End, ) + ) { + LwaTextField( + modifier = Modifier.width(width = 128.dp), + colors = LwaTextFieldColors( + backgroundColor = MaterialTheme.lwa.colorScheme.elevated.base2dp, + ), + field = state.countable, + ) + TextButton( + modifier = Modifier + .height(height = 56.dp) + .background( + color = MaterialTheme.lwa.colorScheme.elevated.base2dp, + shape = MaterialTheme.shapes.small, + ), + enabled = state.countable.isError.collectAsState().value.not(), + onClick = { onConfirm(state) } + ) { + Text( + text = stringResource(Res.string.character__inventory__inventory__dialog__count_action), + ) + } } } - Text( - style = MaterialTheme.typography.body1, - text = dialog.description, - ) } } } 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 f790d59..f0382f1 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,20 +1,31 @@ package com.pixelized.desktop.lwa.ui.composable.character.item +import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio 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__description_empty__label +import lwacharactersheet.composeapp.generated.resources.character__inventory__inventory__dialog__count import org.jetbrains.compose.resources.getString +import java.text.DecimalFormat class ItemDetailDialogFactory { + private val floatChecker = Regex("""^\d*[.,]?\d*${'$'}""") + private val format = DecimalFormat("#.##") + suspend fun convertToDialogUio( + characterSheetId: String?, items: Map, + count: Float, inventoryId: String?, itemId: String?, ): ItemDetailDialogUio? { + if (characterSheetId == null) return null val item = itemId.let(items::get) ?: return null return ItemDetailDialogUio( + characterSheetId = characterSheetId, inventoryId = inventoryId, itemId = item.id, label = item.metadata.label, @@ -22,10 +33,42 @@ class ItemDetailDialogFactory { getString(Res.string.character__inventory__description_empty__label) }, image = item.metadata.image, - option = ItemDetailDialogUio.OptionUio( - equipable = item.options.equipable, - consumable = item.options.consumable, - ), + count = count, + countable = takeIf { item.options.stackable } + ?.let { createFieldFlow(value = format.format(count)) } + ?.createTextField(label = getString(Res.string.character__inventory__inventory__dialog__count)), ) } + + private fun createFieldFlow( + value: String = "", + error: Boolean = false, + ): Pair, MutableStateFlow> { + return MutableStateFlow(value) to MutableStateFlow(error) + } + + fun parse( + quantity: String, + ): Float? = try { + format.parse(quantity).toFloat() + } catch (_: Exception) { + null + } + + 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 39c1357..dc2ab62 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 @@ -2,24 +2,56 @@ package com.pixelized.desktop.lwa.ui.composable.character.item import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pixelized.desktop.lwa.repository.inventory.InventoryRepository import com.pixelized.desktop.lwa.repository.item.ItemRepository +import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio +import com.pixelized.shared.lwa.model.inventory.Inventory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update class ItemDetailDialogViewModel( + private val inventoryRepository: InventoryRepository, itemRepository: ItemRepository, - factory: ItemDetailDialogFactory, + private val factory: ItemDetailDialogFactory, ) : ViewModel() { + private val _error = MutableSharedFlow() + val error: SharedFlow = _error + private val selectedItemId = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + private val selectedInventoryItem: Flow = selectedItemId + .flatMapLatest { selectedIds -> + when (selectedIds?.inventoryId) { + null -> flowOf(null) + else -> inventoryRepository + .inventoryFlow(characterSheetId = selectedIds.characterSheetId) + .mapNotNull { inventory -> + inventory.items.firstOrNull { it.inventoryId == selectedIds.inventoryId } + } + } + } + val itemDialog = combine( - itemRepository.itemFlow(), selectedItemId, - transform = { items, ids -> + itemRepository.itemFlow(), + selectedInventoryItem, + selectedItemId, + transform = { items, selectedInventoryItem, ids -> factory.convertToDialogUio( + characterSheetId = ids?.characterSheetId, items = items, + count = selectedInventoryItem?.count ?: 0f, inventoryId = ids?.inventoryId, itemId = ids?.itemId, ) @@ -30,9 +62,14 @@ class ItemDetailDialogViewModel( initialValue = null ) - fun showItemDialog(inventoryId: String?, itemId: String?) { + fun showItemDialog( + characterSheetId: String, + inventoryId: String?, + itemId: String?, + ) { selectedItemId.update { InventoryItemId( + characterSheetId = characterSheetId, inventoryId = inventoryId, itemId = itemId, ) @@ -43,7 +80,63 @@ class ItemDetailDialogViewModel( selectedItemId.update { null } } + suspend fun onAddInventoryItem( + characterSheetId: String, + itemId: String, + ) { + try { + // create the inventory item on the server, get the newly create id from that. + val inventoryId = inventoryRepository.createInventoryItem( + characterSheetId = characterSheetId, + itemId = itemId, + ) + // update the dialog with the id only if this dialog still correspond to this item. (should always be the case but hey). + if (selectedItemId.value?.let { it.itemId == itemId && it.characterSheetId == characterSheetId } == true) { + selectedItemId.update { + it?.copy(inventoryId = inventoryId) + } + } + } catch (exception: Exception) { + val message = ErrorSnackUio.from(exception) + _error.emit(message) + } + } + + suspend fun changeInventoryItemQuantity( + dialog: ItemDetailDialogUio + ) : Boolean { + if (dialog.countable?.isError?.value == true) return false + + val characterSheetId = dialog.characterSheetId + val inventoryId = dialog.inventoryId ?: return false + val quantity = dialog.countable?.valueFlow?.value ?: return false + + val count = factory.parse(quantity = quantity) ?: return false + + val inventory = inventoryRepository.inventory(characterSheetId = characterSheetId) + + try { + inventoryRepository.updateInventory( + inventory = inventory.copy( + items = inventory.items.toMutableList().also { items -> + val index = items.indexOfFirst { item -> item.inventoryId == inventoryId } + items[index] = items[index].copy( + count = count, + ) + } + ), + create = false, + ) + return true + } catch (exception: Exception) { + val message = ErrorSnackUio.from(exception) + _error.emit(message) + return false + } + } + private data class InventoryItemId( + val characterSheetId: String, val inventoryId: String?, val itemId: String?, ) diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialog.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialog.kt index a897da3..63f4573 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -39,8 +40,11 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import com.pixelized.desktop.lwa.ui.composable.character.LwaDialog import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField @@ -66,27 +70,103 @@ data class PurseDialogUio( val enableConfirm: StateFlow, ) +@Stable +object PurseDialogDefault { + @Stable + val paddings = PaddingValues(top = 16.dp, start = 8.dp, end = 8.dp, bottom = 8.dp) + + @Stable + val spacings = DpSize(width = 4.dp, height = 8.dp) +} + @Composable fun PurseDialog( dialog: State, + paddings: PaddingValues = PurseDialogDefault.paddings, + spacings: DpSize = PurseDialogDefault.spacings, onConfirm: (PurseDialogUio) -> Unit, onSwapSign: (PurseDialogUio) -> Unit, onDismissRequest: () -> Unit, ) { - dialog.value?.let { - Dialog( - onDismissRequest = onDismissRequest, - content = { - PurseContent( - dialog = it, - onConfirm = onConfirm, - onSwapSign = onSwapSign, - onDismissRequest = onDismissRequest, - ) - PurseDialogKeyHandler( - onSwap = { onSwapSign(it) }, + LwaDialog( + state = dialog, + onDismissRequest = onDismissRequest, + onConfirm = { dialog.value?.let(onConfirm) } + ) { state -> + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + Column( + modifier = Modifier.padding(paddingValues = paddings), + verticalArrangement = Arrangement.spacedBy(space = spacings.height), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContent( + modifier = Modifier, + targetState = state.label.collectAsState().value, + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { + Text( + style = MaterialTheme.typography.caption, + text = it, ) } + Row( + horizontalArrangement = Arrangement.spacedBy(space = spacings.width), + verticalAlignment = Alignment.Bottom, + ) { + SignButton( + modifier = Modifier + .size(size = 56.dp) + .background( + color = MaterialTheme.lwa.colorScheme.elevated.base1dp, + shape = MaterialTheme.shapes.small, + ), + add = state.add, + onClick = { onSwapSign(state) }, + ) + LwaTextField( + modifier = Modifier.focusRequester(focusRequester = focusRequester) + .width(100.dp), + field = state.gold, + ) + LwaTextField( + modifier = Modifier.width(100.dp), + field = state.silver, + ) + LwaTextField( + modifier = Modifier.width(100.dp), + field = state.copper, + ) + } + Row( + modifier = Modifier.align(alignment = Alignment.End), + horizontalArrangement = Arrangement.spacedBy( + space = spacings.width, + alignment = Alignment.End + ) + ) { + TextButton( + onClick = onDismissRequest, + ) { + Text( + color = MaterialTheme.colors.primary.copy(alpha = .7f), + text = stringResource(Res.string.dialog__cancel_action) + ) + } + TextButton( + enabled = state.enableConfirm.collectAsState().value, + onClick = { onConfirm(state) }, + ) { + Text( + text = stringResource(Res.string.dialog__confirm_action) + ) + } + } + } + + PurseDialogKeyHandler( + onSwap = { onSwapSign(state) }, ) } } @@ -99,8 +179,7 @@ private fun PurseContent( onSwapSign: (PurseDialogUio) -> Unit, onDismissRequest: () -> Unit, ) { - val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.requestFocus() } + Box( modifier = Modifier @@ -119,76 +198,7 @@ private fun PurseContent( ) { DecoratedBox { Surface { - Column( - modifier = modifier.padding(horizontal = 8.dp), - verticalArrangement = Arrangement.spacedBy(space = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AnimatedContent( - modifier = Modifier.padding(top = 16.dp, bottom = 8.dp), - targetState = dialog.label.collectAsState().value, - transitionSpec = { fadeIn() togetherWith fadeOut() } - ) { - Text( - style = MaterialTheme.typography.caption, - text = it, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(space = 4.dp), - verticalAlignment = Alignment.Bottom, - ) { - SignButton( - modifier = Modifier - .size(size = 56.dp) - .background( - color = MaterialTheme.lwa.colorScheme.elevated.base1dp, - shape = MaterialTheme.shapes.small, - ), - add = dialog.add, - onClick = { onSwapSign(dialog) }, - ) - LwaTextField( - modifier = Modifier.focusRequester(focusRequester = focusRequester) - .width(100.dp), - field = dialog.gold, - ) - LwaTextField( - modifier = Modifier.width(100.dp), - field = dialog.silver, - ) - LwaTextField( - modifier = Modifier.width(100.dp), - field = dialog.copper, - ) - } - Row( - modifier = Modifier - .padding(bottom = 4.dp) - .align(alignment = Alignment.End), - horizontalArrangement = Arrangement.spacedBy( - space = 4.dp, - alignment = Alignment.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) - ) - } - } - } + } } } diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogViewModel.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogViewModel.kt index 55360c2..2bfb767 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/character/purse/PurseDialogViewModel.kt @@ -56,9 +56,7 @@ class PurseDialogViewModel( return false } // Get the player inventory - val inventory = inventoryRepository - .inventory(characterSheetId = dialog.characterSheetId) - ?: return false + val inventory = inventoryRepository.inventory(characterSheetId = dialog.characterSheetId) // compute the new purse val sign = if (dialog.add.value) 1 else -1 val goldValue = dialog.gold.valueFlow.value.toIntOrNull() ?: 0 diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/TooltipLayout2.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/TooltipLayout2.kt index e1c4af4..7796364 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/TooltipLayout2.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/TooltipLayout2.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.TooltipPlacement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -27,6 +28,7 @@ fun TooltipLayout2( tooltip = { Box( modifier = Modifier.width(width = 448.dp), + contentAlignment = Alignment.TopEnd, content = { tooltip(tips) }, ) }, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventory.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventory.kt index 6dda488..a6e28fa 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventory.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/CharacterDetailInventory.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil3.compose.rememberDrawScopeSizeResolver import com.pixelized.desktop.lwa.LocalBlurController import com.pixelized.desktop.lwa.ui.composable.character.inventory.InventoryDialog import com.pixelized.desktop.lwa.ui.composable.character.inventory.InventoryDialogViewModel @@ -104,11 +105,12 @@ fun CharacterDetailInventory( characterSheetId = it, ) }, - onItem = { + onItem = { item -> blur.show() itemDetailDialogViewModel.showItemDialog( - inventoryId = it.inventoryId, - itemId = it.itemId, + characterSheetId = item.characterSheetId, + inventoryId = item.inventoryId, + itemId = item.itemId, ) }, onAddItem = { @@ -145,9 +147,10 @@ fun CharacterDetailInventory( blur.hide() inventoryDialogViewModel.hideInventoryDialog() }, - onItem = { itemId -> + onItem = { dialog, itemId -> blur.show() itemDetailDialogViewModel.showItemDialog( + characterSheetId = dialog.characterSheetId, inventoryId = null, itemId = itemId, ) @@ -159,6 +162,25 @@ fun CharacterDetailInventory( onDismissRequest = { blur.hide() itemDetailDialogViewModel.hideItemDialog() + }, + onAddItem = { dialog -> + scope.launch { + itemDetailDialogViewModel.onAddInventoryItem( + characterSheetId = dialog.characterSheetId, + itemId = dialog.itemId, + ) + } + }, + onConfirm = { dialog -> + scope.launch { + val result = itemDetailDialogViewModel.changeInventoryItemQuantity( + dialog = dialog, + ) + if (result) { + blur.hide() + itemDetailDialogViewModel.hideItemDialog() + } + } } ) @@ -232,9 +254,7 @@ private fun CharacterDetailInventoryContent( key = { it.inventoryId }, ) { InventoryItem( - modifier = Modifier - .animateItem() - .fillMaxWidth(), + modifier = Modifier.animateItem(), item = it, onClick = { onItem(it) }, ) 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 fd9b6e7..dff60d6 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 @@ -80,6 +80,7 @@ class CharacterDetailInventoryFactory( ?.mapNotNull { val item = items[it.itemId] ?: return@mapNotNull null InventoryItemUio( + characterSheetId = characterSheetId, inventoryId = it.inventoryId, itemId = it.itemId, label = item.metadata.label, diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/item/InventoryItem.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/item/InventoryItem.kt index 3234591..828302a 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/item/InventoryItem.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/screen/campaign/player/detail/inventory/item/InventoryItem.kt @@ -12,6 +12,7 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -40,6 +41,7 @@ import com.pixelized.desktop.lwa.utils.rememberSaturationFilter @Stable data class InventoryItemUio( + val characterSheetId: String, val inventoryId: String, val itemId: String, val label: String, @@ -74,6 +76,7 @@ fun InventoryItem( onClick: () -> Unit, ) { TooltipLayout2( + modifier = modifier, delayMillis = 500, tips = item.tooltips, tooltip = { tooltips -> @@ -97,7 +100,7 @@ fun InventoryItem( ) } Column( - modifier = modifier, + modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(space = 8.dp) ) { Text( @@ -128,6 +131,7 @@ fun InventoryItem( } ) .minimumInteractiveComponentSize() + .fillMaxWidth() .padding(paddingValues = padding) .then(other = modifier), horizontalArrangement = Arrangement.spacedBy(space = spacing), diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryService.kt b/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryService.kt index c2ad828..789bf8b 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryService.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryService.kt @@ -49,4 +49,15 @@ class InventoryService( fun delete(characterSheetId: String) { inventoryStore.delete(characterSheetId = characterSheetId) } + + @Throws + fun createItem( + characterSheetId: String, + itemId: String, + ) : String { + return inventoryStore.createItem( + characterSheetId = characterSheetId, + itemId = itemId, + ) + } } diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryStore.kt b/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryStore.kt index 3352a67..6378c18 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryStore.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/model/inventory/InventoryStore.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import java.io.File +import java.util.UUID class InventoryStore( private val pathProvider: PathProvider, @@ -152,7 +153,49 @@ class InventoryStore( } } + @Throws + fun createItem( + characterSheetId: String, + itemId: String, + ): String { + val (updatedInventory, inventoryId) = inventoryFlow.value.toMutableMap().let { characters -> + // get the inventory of the character, if none create one. + val inventory = characters[characterSheetId] + ?: Inventory.empty(characterSheetId = characterSheetId) + // create an inventoryId. + val inventoryId = inventory.items.createInventoryId() + // create an inventory Item. + val item = Inventory.Item( + inventoryId = inventoryId, + itemId = itemId, + count = 1f, + equipped = false, + ) + // update the inventory with the updated item. + inventory.copy( + items = inventory.items.toMutableList().also { + it.add(item) + } + ) to inventoryId + } + // save the inventory + save( + inventory = updatedInventory, + create = false, + ) + // return the inventory ID. + return inventoryId + } + private fun inventoryFile(id: String): File { return File("${pathProvider.inventoryPath()}${id}.json") } + + private fun List.createInventoryId(): String { + var inventoryId: String + do { + inventoryId = UUID.randomUUID().toString() + } while (any { inventoryId == it.inventoryId }) + return inventoryId + } } 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 e6a4bc2..8fb3a98 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 @@ -19,6 +19,7 @@ import com.pixelized.server.lwa.server.rest.character.putCharacterAlteration import com.pixelized.server.lwa.server.rest.character.putCharacterDamage import com.pixelized.server.lwa.server.rest.character.putCharacterDiminished import com.pixelized.server.lwa.server.rest.character.putCharacterFatigue +import com.pixelized.server.lwa.server.rest.inventory.createInventory import com.pixelized.server.lwa.server.rest.inventory.deleteInventory import com.pixelized.server.lwa.server.rest.inventory.getInventory import com.pixelized.server.lwa.server.rest.inventory.putInventory @@ -258,6 +259,10 @@ class LocalServer { path = "/update", body = engine.putInventory() ) + put( + path = "/create", + body = engine.createInventory() + ) delete( path = "/delete", body = engine.deleteInventory() diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/exception/MissingParameterException.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/exception/MissingParameterException.kt index 9ae5498..1b0cda2 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/exception/MissingParameterException.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/exception/MissingParameterException.kt @@ -1,4 +1,6 @@ package com.pixelized.server.lwa.server.exception -class MissingParameterException(name: String) : +import com.pixelized.shared.lwa.protocol.rest.APIResponse.ErrorCode + +class MissingParameterException(name: String, val code: ErrorCode) : ServerException(root = Exception("Missing '$name' parameter.")) \ No newline at end of file diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Damage.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Damage.kt index 3687b79..5c8094d 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Damage.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Damage.kt @@ -1,9 +1,9 @@ package com.pixelized.server.lwa.server.rest.character import com.pixelized.server.lwa.server.Engine -import com.pixelized.server.lwa.server.exception.MissingParameterException import com.pixelized.server.lwa.utils.extentions.characterSheetId import com.pixelized.server.lwa.utils.extentions.exception +import com.pixelized.server.lwa.utils.extentions.param import com.pixelized.shared.lwa.protocol.rest.APIResponse import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent import io.ktor.server.response.respond @@ -14,8 +14,10 @@ fun Engine.putCharacterDamage(): suspend RoutingContext.() -> Unit { try { // get the query parameter val characterSheetId = call.queryParameters.characterSheetId - val damage = call.queryParameters["damage"]?.toIntOrNull() - ?: throw MissingParameterException(name = "damage") + val damage: Int = call.queryParameters.param( + name = "damage", + code = APIResponse.ErrorCode.Damage, + ) // fetch the character sheet val characterSheet = characterService.character(characterSheetId) ?: error("CharacterSheet with id:$characterSheetId not found.") diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Diminished.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Diminished.kt index 666eb99..5ad9a4b 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Diminished.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Diminished.kt @@ -1,9 +1,9 @@ package com.pixelized.server.lwa.server.rest.character import com.pixelized.server.lwa.server.Engine -import com.pixelized.server.lwa.server.exception.MissingParameterException import com.pixelized.server.lwa.utils.extentions.characterSheetId import com.pixelized.server.lwa.utils.extentions.exception +import com.pixelized.server.lwa.utils.extentions.param import com.pixelized.shared.lwa.protocol.rest.APIResponse import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent import io.ktor.server.response.respond @@ -14,8 +14,10 @@ fun Engine.putCharacterDiminished(): suspend RoutingContext.() -> Unit { try { // get the query parameter val characterSheetId = call.queryParameters.characterSheetId - val diminished = call.queryParameters["diminished"]?.toIntOrNull() - ?: throw MissingParameterException(name = "diminished") + val diminished: Int = call.queryParameters.param( + name = "diminished", + code = APIResponse.ErrorCode.Diminished, + ) // Update the character damage characterService.updateDiminished( characterSheetId = characterSheetId, diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Fatigue.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Fatigue.kt index 1709485..08b93cd 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Fatigue.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/character/PUT_Character_Fatigue.kt @@ -4,6 +4,7 @@ import com.pixelized.server.lwa.server.Engine import com.pixelized.server.lwa.server.exception.MissingParameterException import com.pixelized.server.lwa.utils.extentions.characterSheetId import com.pixelized.server.lwa.utils.extentions.exception +import com.pixelized.server.lwa.utils.extentions.param import com.pixelized.shared.lwa.protocol.rest.APIResponse import com.pixelized.shared.lwa.protocol.websocket.CharacterSheetEvent import io.ktor.server.response.respond @@ -14,8 +15,10 @@ fun Engine.putCharacterFatigue(): suspend RoutingContext.() -> Unit { try { // get the query parameter val characterSheetId = call.queryParameters.characterSheetId - val fatigue = call.queryParameters["fatigue"]?.toIntOrNull() - ?: throw MissingParameterException(name = "fatigue") + val fatigue: Int = call.queryParameters.param( + name = "fatigue", + code = APIResponse.ErrorCode.Fatigue, + ) // fetch the character sheet val characterSheet = characterService.character(characterSheetId) ?: error("CharacterSheet with id:$characterSheetId not found.") diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/CREATE_Inventory.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/CREATE_Inventory.kt new file mode 100644 index 0000000..186be00 --- /dev/null +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/CREATE_Inventory.kt @@ -0,0 +1,41 @@ +package com.pixelized.server.lwa.server.rest.inventory + +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.server.lwa.utils.extentions.itemId +import com.pixelized.shared.lwa.protocol.rest.APIResponse +import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation +import io.ktor.server.response.respond +import io.ktor.server.routing.RoutingContext + +fun Engine.createInventory(): suspend RoutingContext.() -> Unit { + return { + try { + // get the query parameter + val characterSheetId = call.queryParameters.characterSheetId + val itemId = call.queryParameters.itemId + // add the item to the inventory. + val inventoryId = inventoryService.createItem( + characterSheetId = characterSheetId, + itemId = itemId, + ) + // API & WebSocket responses. + call.respond( + message = APIResponse.success( + data = inventoryId, + ), + ) + webSocket.emit( + value = ApiSynchronisation.InventoryUpdate( + timestamp = System.currentTimeMillis(), + characterSheetId = characterSheetId, + ), + ) + } catch (exception: Exception) { + call.exception( + exception = exception, + ) + } + } +} diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/DELETE_Inventory.kt b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/DELETE_Inventory.kt index fd3b8bd..73dea38 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/DELETE_Inventory.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/server/rest/inventory/DELETE_Inventory.kt @@ -13,7 +13,7 @@ fun Engine.deleteInventory(): suspend RoutingContext.() -> Unit { try { // get the query parameter val characterSheetId = call.queryParameters.characterSheetId - // delete the alteration. + // delete the inventory. inventoryService.delete( characterSheetId = characterSheetId, ) diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/ParametersExt.kt b/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/ParametersExt.kt index df45030..3c71759 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/ParametersExt.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/ParametersExt.kt @@ -1,29 +1,49 @@ package com.pixelized.server.lwa.utils.extentions import com.pixelized.server.lwa.server.exception.MissingParameterException +import com.pixelized.shared.lwa.protocol.rest.APIResponse import io.ktor.http.Parameters -val Parameters.characterSheetId - get() = "characterSheetId".let { param -> - this[param] ?: throw MissingParameterException(name = param) - } +inline fun Parameters.param( + name: String, + code: APIResponse.ErrorCode, +): T { + return when (T::class) { + String::class -> this[name] as? T + Boolean::class -> this[name]?.toBooleanStrictOrNull() as? T + else -> null + } ?: throw MissingParameterException( + name = name, + code = code, + ) +} -val Parameters.alterationId - get() = "alterationId".let { param -> - this[param] ?: throw MissingParameterException(name = param) - } +val Parameters.characterSheetId: String + get() = param( + name = "characterSheetId", + code = APIResponse.ErrorCode.CharacterSheetId, + ) -val Parameters.itemId - get() = "itemId".let { param -> - this[param] ?: throw MissingParameterException(name = param) - } +val Parameters.alterationId: String + get() = param( + name = "alterationId", + code = APIResponse.ErrorCode.AlterationId, + ) -val Parameters.create - get() = "create".let { param -> - this[param]?.toBooleanStrictOrNull() ?: throw MissingParameterException(name = param) - } +val Parameters.itemId: String + get() = param( + name = "itemId", + code = APIResponse.ErrorCode.ItemId, + ) -val Parameters.active - get() = "active".let { param -> - this[param]?.toBooleanStrictOrNull() ?: throw MissingParameterException(name = param) - } +val Parameters.create: Boolean + get() = param( + name = "create", + code = APIResponse.ErrorCode.Create, + ) + +val Parameters.active: Boolean + get() = param( + name = "active", + code = APIResponse.ErrorCode.Active, + ) \ No newline at end of file diff --git a/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/RoutingCallExt+exception.kt b/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/RoutingCallExt+exception.kt index 45688b6..ed471d3 100644 --- a/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/RoutingCallExt+exception.kt +++ b/server/src/main/kotlin/com/pixelized/server/lwa/utils/extentions/RoutingCallExt+exception.kt @@ -15,7 +15,7 @@ suspend inline fun RoutingCall.exception(exception: T) { message = APIResponse.error( status = APIResponse.BAD_REQUEST, message = exception.message ?: "?", - code = APIResponse.ErrorCode.AlterationName, + code = exception.code, ) ) } diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/APIResponse.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/APIResponse.kt index 2a6a627..52b62ce 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/APIResponse.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/protocol/rest/APIResponse.kt @@ -17,6 +17,11 @@ data class APIResponse( ItemId, ItemName, CharacterSheetId, + Create, + Active, + Damage, + Fatigue, + Diminished, } companion object {