From 48074f3d13c468554158cde95d96af36fbac5321 Mon Sep 17 00:00:00 2001 From: Thomas Andres Gomez Date: Fri, 18 Apr 2025 16:00:35 +0200 Subject: [PATCH] Add AddItem dialog to the inventory screen. --- .../composeResources/values/strings.xml | 3 + .../lwa/repository/item/ItemRepository.kt | 4 +- .../character/inventory/InventoryDialog.kt | 302 +++++++++++++++++- .../inventory/InventoryDialogFactory.kt | 77 +++++ .../inventory/InventoryDialogViewModel.kt | 47 ++- .../character/item/ItemDetailDialog.kt | 70 +++- .../character/item/ItemDetailDialogFactory.kt | 11 +- .../item/ItemDetailDialogViewModel.kt | 26 +- .../ui/composable/tooltip/TooltipLayout2.kt | 43 +++ .../inventory/CharacterDetailInventory.kt | 36 ++- .../CharacterDetailInventoryFactory.kt | 9 +- .../detail/inventory/item/InventoryItem.kt | 146 ++++++--- .../gamemaster/item/list/GMItemViewModel.kt | 2 +- .../factory/CharacterSheetJsonFactory.kt | 4 +- .../shared/lwa/model/inventory/Inventory.kt | 2 +- .../lwa/model/inventory/InventoryJsonV1.kt | 2 +- .../model/item/factory/ItemJsonFactoryV1.kt | 4 +- 17 files changed, 704 insertions(+), 84 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/TooltipLayout2.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c6004bd..c830cd1 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -188,6 +188,9 @@ Filtrer l'inventaire Ajouter à la bourse Retirer de la bourse + Filtrer les objets + Ajouter à l'inventaire + 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. La Force représente essentiellement la puissance musculaire du personnage. Elle ne décrit pas nécessairement la masse musculaire brute, mais l’efficacité avec laquelle le personnage exerce ses muscles pour accomplir des actions physiques pénibles.\n\n- Bonus aux dégats\n- Réflexe\n- Athlétisme\n- Lancer\n- Saisie diff --git a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/item/ItemRepository.kt b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/item/ItemRepository.kt index cf6f214..b33f9df 100644 --- a/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/item/ItemRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/repository/item/ItemRepository.kt @@ -5,7 +5,7 @@ import com.pixelized.shared.lwa.model.item.Item class ItemRepository( private val itemStore: ItemStore, ) { - val itemFlow get() = itemStore.items + fun itemFlow() = itemStore.items suspend fun updateItemFlow() { itemStore.updateItemFlow() @@ -14,7 +14,7 @@ class ItemRepository( fun item( itemId: String?, ): Item? { - return itemFlow.value[itemId] + return itemStore.items.value[itemId] } @Throws 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 d2dfc6a..278d741 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 @@ -1,8 +1,308 @@ package com.pixelized.desktop.lwa.ui.composable.character.inventory +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +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 +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 +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.minimumInteractiveComponentSize 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 +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +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.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.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 +import lwacharactersheet.composeapp.generated.resources.ic_cancel_24dp +import lwacharactersheet.composeapp.generated.resources.ic_close_24dp +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Stable +data class InventoryDialogUio( + val characterSheetId: String, + val filter: LwaTextFieldUio, + val items: List, +) { + @Stable + data class Item( + val itemId: String, + val label: String, + val tooltips: ToolTip?, + ) { + @Stable + data class ToolTip( + val label: String, + val description: String, + val image: String?, + ) + } +} + +@Stable +object InventoryDialogDefault { + @Stable + val paddings = PaddingValues(all = 16.dp) + + @Stable + val spacings = 8.dp +} + +@Stable +object InventoryDialogItemDefault { + @Stable + val paddings = PaddingValues(horizontal = 16.dp) + + @Stable + val spacings = 8.dp +} @Composable -fun InventoryDialog() { +fun InventoryDialog( + dialog: State, + paddings: PaddingValues = InventoryDialogDefault.paddings, + onDismissRequest: () -> Unit, + onItem: (String) -> Unit, +) { + dialog.value?.let { + Dialog( + onDismissRequest = onDismissRequest, + content = { + InventoryDialogContent( + dialog = it, + paddings = paddings, + onDismissRequest = onDismissRequest, + onItem = onItem, + ) + } + ) + } +} +@Composable +private fun InventoryDialogContent( + dialog: InventoryDialogUio, + paddings: PaddingValues = InventoryDialogDefault.paddings, + spacing: Dp = InventoryDialogDefault.spacings, + onDismissRequest: () -> Unit, + onItem: (String) -> Unit, +) { + val layoutDirection = LocalLayoutDirection.current + val start = remember(layoutDirection) { paddings.calculateStartPadding(layoutDirection) } + val end = remember(layoutDirection) { paddings.calculateEndPadding(layoutDirection) } + 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, + ) { + DecoratedBox { + Surface { + Column { + 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("") }, + ) { + 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, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun InventoryDialogItem( + modifier: Modifier = Modifier, + paddings: PaddingValues = InventoryDialogItemDefault.paddings, + spacings: Dp = InventoryDialogItemDefault.spacings, + item: InventoryDialogUio.Item, + onItem: (String) -> Unit, +) { + TooltipLayout2( + modifier = modifier, + delayMillis = 500, + tips = item.tooltips, + tooltip = { tooltips -> + DecoratedBox { + Surface { + Box( + modifier = Modifier.padding(all = 16.dp) + ) { + takeIf { tooltips.image?.isNotEmpty() == true }?.let { + DesaturatedAsyncImage( + modifier = Modifier + .size(96.dp) + .align(alignment = Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + colorFilter = rememberSaturationFilter(), + model = tooltips.image, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + filterQuality = FilterQuality.High, + contentDescription = null, + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + Text( + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Bold, + text = tooltips.label, + ) + Text( + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Light, + text = tooltips.description, + ) + } + } + } + } + }, + content = { + Row( + modifier = Modifier + .clip(shape = MaterialTheme.lwa.shapes.item) + .clickable(onClick = { onItem(item.itemId) }) + .background(color = MaterialTheme.lwa.colorScheme.elevated.base2dp) + .minimumInteractiveComponentSize() + .padding(paddingValues = paddings) + .fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(space = spacings), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.alignByBaseline(), + style = MaterialTheme.lwa.typography.base.body1, + fontWeight = FontWeight.Bold, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + text = item.label, + ) + } + }, + ) } \ No newline at end of file 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 37d375e..a4856cf 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 @@ -1,5 +1,82 @@ package com.pixelized.desktop.lwa.ui.composable.character.inventory +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 { + fun filterItems( + items: Collection, + filter: String, + setting: Settings, + ): Collection { + return if (setting.isGameMaster == true) { + items.filter { + it.metadata.label.unAccent().contains(other = filter, ignoreCase = true) + } + } else { + items.filter { + it.tags.contains(ADDABLE_TAG_ID) + }.filter { + it.metadata.label.unAccent().contains(other = filter, ignoreCase = true) + } + } + } + + suspend fun convertToDialogUio( + items: Collection, + filterFlow: Pair, MutableStateFlow>, + 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), + ), + items = items.map { item -> + InventoryDialogUio.Item( + itemId = item.id, + label = item.metadata.label, + tooltips = takeIf { item.metadata.description.isNotEmpty() }?.let { + InventoryDialogUio.Item.ToolTip( + label = item.metadata.label, + description = item.metadata.description, + image = item.metadata.image, + ) + }, + ) + } + ) + } + + 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 { + private const val ADDABLE_TAG_ID = "META:ADDABLE" + } } \ No newline at end of file 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 e567ccd..47e0605 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 @@ -1,7 +1,52 @@ package com.pixelized.desktop.lwa.ui.composable.character.inventory 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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update -class InventoryDialogViewModel: ViewModel() { +class InventoryDialogViewModel( + itemRepository: ItemRepository, + settingRepository: SettingsRepository, + factory: InventoryDialogFactory, +) : ViewModel() { + private val selectedCharacterSheetId = MutableStateFlow(null) + private val filterFlow = factory.createTextFieldFlow() + + val inventoryDialog = combine( + itemRepository.itemFlow().map { it.values }, + settingRepository.settingsFlow(), + filterFlow.first, + selectedCharacterSheetId, + ) { items, settings, filter, characterSheetId -> + factory.convertToDialogUio( + items = factory.filterItems( + items = items, + filter = filter, + setting = settings, + ), + filterFlow = filterFlow, + characterSheetId = characterSheetId, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Lazily, + initialValue = null + ) + + fun showInventoryDialog(characterSheetId: String?) { + filterFlow.first.update { "" } + selectedCharacterSheetId.update { characterSheetId } + } + + fun hideInventoryDialog() { + selectedCharacterSheetId.update { null } + } } \ No newline at end of file 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 5782e5a..b70de01 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 @@ -6,9 +6,15 @@ 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.calculateEndPadding import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +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 @@ -20,16 +26,23 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.FilterQuality 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.window.Dialog import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox import com.pixelized.desktop.lwa.ui.composable.image.DesaturatedAsyncImage +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.ic_close_24dp +import org.jetbrains.compose.resources.painterResource @Stable data class ItemDetailDialogUio( + val inventoryId: String?, val itemId: String, val label: String, val description: String, @@ -76,6 +89,10 @@ private fun ItemDetailDialogContent( 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( @@ -96,26 +113,47 @@ private fun ItemDetailDialogContent( Box( modifier = Modifier.padding(paddingValues = paddings) ) { - 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, - ) + 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) ) { - Text( - style = MaterialTheme.typography.h5, - fontWeight = FontWeight.Bold, - text = dialog.label, - ) + 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 = dialog.label, + ) + 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, + ) + } + } 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 f92dd65..f790d59 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,19 +1,26 @@ package com.pixelized.desktop.lwa.ui.composable.character.item import com.pixelized.shared.lwa.model.item.Item +import lwacharactersheet.composeapp.generated.resources.Res +import lwacharactersheet.composeapp.generated.resources.character__inventory__description_empty__label +import org.jetbrains.compose.resources.getString class ItemDetailDialogFactory { - fun convertToDialogUio( + suspend fun convertToDialogUio( items: Map, + inventoryId: String?, itemId: String?, ): ItemDetailDialogUio? { val item = itemId.let(items::get) ?: return null return ItemDetailDialogUio( + inventoryId = inventoryId, itemId = item.id, label = item.metadata.label, - description = item.metadata.description, + description = item.metadata.description.ifBlank { + getString(Res.string.character__inventory__description_empty__label) + }, image = item.metadata.image, option = ItemDetailDialogUio.OptionUio( equipable = item.options.equipable, 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 543d282..39c1357 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 @@ -14,21 +14,37 @@ class ItemDetailDialogViewModel( factory: ItemDetailDialogFactory, ) : ViewModel() { - private val selectedItemId = MutableStateFlow(null) + private val selectedItemId = MutableStateFlow(null) val itemDialog = combine( - itemRepository.itemFlow, selectedItemId, - transform = { items, itemId -> factory.convertToDialogUio(items, itemId) } + itemRepository.itemFlow(), selectedItemId, + transform = { items, ids -> + factory.convertToDialogUio( + items = items, + inventoryId = ids?.inventoryId, + itemId = ids?.itemId, + ) + } ).stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = null ) - fun showItemDialog(itemId: String?) { - selectedItemId.update { itemId } + fun showItemDialog(inventoryId: String?, itemId: String?) { + selectedItemId.update { + InventoryItemId( + inventoryId = inventoryId, + itemId = itemId, + ) + } } fun hideItemDialog() { selectedItemId.update { null } } + + private data class InventoryItemId( + val inventoryId: String?, + val itemId: String?, + ) } \ No newline at end of file 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 new file mode 100644 index 0000000..e1c4af4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/pixelized/desktop/lwa/ui/composable/tooltip/TooltipLayout2.kt @@ -0,0 +1,43 @@ +package com.pixelized.desktop.lwa.ui.composable.tooltip + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea +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.Modifier +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp + + +@Composable +@OptIn(ExperimentalFoundationApi::class) +fun TooltipLayout2( + modifier: Modifier = Modifier, + delayMillis: Int = 1000, + tooltipPlacement: TooltipPlacement = TooltipPlacement.CursorPoint(DpOffset(0.dp, 16.dp)), + tips: T? = null, + tooltip: (@Composable (tips: T) -> Unit)? = null, + content: @Composable () -> Unit, +) { + if (tips != null && tooltip != null) { + TooltipArea( + modifier = modifier, + tooltip = { + Box( + modifier = Modifier.width(width = 448.dp), + content = { tooltip(tips) }, + ) + }, + content = content, + delayMillis = delayMillis, + tooltipPlacement = tooltipPlacement, + ) + } else { + Box( + modifier = modifier, + content = { content() }, + ) + } +} 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 aeea9ad..6dda488 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 @@ -30,6 +30,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp 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 import com.pixelized.desktop.lwa.ui.composable.character.item.ItemDetailDialog import com.pixelized.desktop.lwa.ui.composable.character.item.ItemDetailDialogViewModel import com.pixelized.desktop.lwa.ui.composable.character.purse.PurseDialog @@ -81,6 +83,7 @@ fun CharacterDetailInventory( paddings: PaddingValues = CharacterDetailInventoryDefault.padding, spacing: Dp = CharacterDetailInventoryDefault.spacing, purseViewModel: PurseDialogViewModel = koinViewModel(), + inventoryDialogViewModel: InventoryDialogViewModel = koinViewModel(), itemDetailDialogViewModel: ItemDetailDialogViewModel = koinViewModel(), inventory: State, ) { @@ -97,11 +100,22 @@ fun CharacterDetailInventory( inventory = unWrap, onPurse = { blur.show() - purseViewModel.showPurseDialog(characterSheetId = it) + purseViewModel.showPurseDialog( + characterSheetId = it, + ) }, onItem = { blur.show() - itemDetailDialogViewModel.showItemDialog(itemId = it.itemId) + itemDetailDialogViewModel.showItemDialog( + inventoryId = it.inventoryId, + itemId = it.itemId, + ) + }, + onAddItem = { + blur.show() + inventoryDialogViewModel.showInventoryDialog( + characterSheetId = it, + ) } ) } @@ -125,6 +139,21 @@ fun CharacterDetailInventory( } ) + InventoryDialog( + dialog = inventoryDialogViewModel.inventoryDialog.collectAsState(), + onDismissRequest = { + blur.hide() + inventoryDialogViewModel.hideInventoryDialog() + }, + onItem = { itemId -> + blur.show() + itemDetailDialogViewModel.showItemDialog( + inventoryId = null, + itemId = itemId, + ) + }, + ) + ItemDetailDialog( dialog = itemDetailDialogViewModel.itemDialog.collectAsState(), onDismissRequest = { @@ -146,6 +175,7 @@ private fun CharacterDetailInventoryContent( inventory: CharacterDetailInventoryUio, onPurse: (String) -> Unit, onItem: (InventoryItemUio) -> Unit, + onAddItem: (String) -> Unit, ) { Box( modifier = modifier, @@ -221,7 +251,7 @@ private fun CharacterDetailInventoryContent( colors = LwaButtonColors(), elevation = ButtonDefaults.elevation(4.dp), shape = CircleShape, - onClick = { }, + onClick = { onAddItem(inventory.characterSheetId) }, ) { Text( modifier = Modifier.padding(end = 4.dp), 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 b7940c6..fd9b6e7 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 @@ -42,7 +42,7 @@ class CharacterDetailInventoryFactory( ) return combine( inventoryRepository.inventoryFlow(characterSheetId = characterSheetId), - itemRepository.itemFlow, + itemRepository.itemFlow(), filterFlow.map { it.unAccent() }, ) { inventory, items, filter -> convertToCharacterInventoryUio( @@ -85,6 +85,13 @@ class CharacterDetailInventoryFactory( label = item.metadata.label, count = it.count, equipped = it.equipped, + tooltips = takeIf { item.metadata.description.isNotEmpty() }?.let { + InventoryItemUio.Tooltips( + label = item.metadata.label, + description = item.metadata.description, + image = item.metadata.image, + ) + } ) } ?.sortedWith(compareBy(Collator.getInstance()) { it.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 7b5709b..3234591 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 @@ -1,20 +1,22 @@ package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory.item import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.minimumInteractiveComponentSize import androidx.compose.runtime.Composable @@ -23,21 +25,35 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.layout.ContentScale 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 com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox +import com.pixelized.desktop.lwa.ui.composable.image.DesaturatedAsyncImage +import com.pixelized.desktop.lwa.ui.composable.tooltip.TooltipLayout2 import com.pixelized.desktop.lwa.ui.theme.lwa import com.pixelized.desktop.lwa.utils.extention.ribbon +import com.pixelized.desktop.lwa.utils.rememberSaturationFilter @Stable data class InventoryItemUio( val inventoryId: String, val itemId: String, val label: String, - val count: Int, + val count: Float, val equipped: Boolean, -) + val tooltips: Tooltips?, +) { + @Stable + data class Tooltips( + val label: String, + val description: String, + val image: String?, + ) +} @Stable object GMCharacterPreviewDefault { @@ -48,6 +64,7 @@ object GMCharacterPreviewDefault { val spacing: Dp = 4.dp } +@OptIn(ExperimentalFoundationApi::class) @Composable fun InventoryItem( modifier: Modifier = Modifier, @@ -56,53 +73,90 @@ fun InventoryItem( item: InventoryItemUio, onClick: () -> Unit, ) { - Row( - modifier = Modifier - .clip(shape = MaterialTheme.lwa.shapes.item) - .clickable(onClick = onClick) - .background(color = MaterialTheme.lwa.colorScheme.elevated.base2dp) - .ribbon( - color = when (item.equipped) { - true -> MaterialTheme.lwa.colorScheme.base.primary - else -> Color.Transparent - } - ) - .minimumInteractiveComponentSize() - .padding(paddingValues = padding) - .then(other = modifier), - horizontalArrangement = Arrangement.spacedBy(space = spacing), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.alignByBaseline(), - style = MaterialTheme.lwa.typography.base.body1, - fontWeight = FontWeight.Bold, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - text = item.label, - ) - AnimatedVisibility( - visible = item.count > 1, - enter = fadeIn(), - exit = fadeOut(), - ) { - AnimatedContent( - targetState = item.count, - transitionSpec = { - val prod = if (initialState < targetState) 1 else -1 - val enter = fadeIn() + slideInVertically { -8 * prod } - val exit = fadeOut() + slideOutVertically { 8 * prod } - enter togetherWith exit using SizeTransform(clip = false) + TooltipLayout2( + delayMillis = 500, + tips = item.tooltips, + tooltip = { tooltips -> + DecoratedBox { + Surface { + Box( + modifier = Modifier.padding(all = 16.dp) + ) { + takeIf { tooltips.image?.isNotEmpty() == true }?.let { + DesaturatedAsyncImage( + modifier = Modifier + .size(96.dp) + .align(alignment = Alignment.TopEnd) + .offset(x = 8.dp, y = (-8).dp), + colorFilter = rememberSaturationFilter(), + model = tooltips.image, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + filterQuality = FilterQuality.High, + contentDescription = null, + ) + } + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(space = 8.dp) + ) { + Text( + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Bold, + text = tooltips.label, + ) + Text( + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Light, + text = tooltips.description, + ) + } + } } + } + }, + content = { + Row( + modifier = Modifier + .clip(shape = MaterialTheme.lwa.shapes.item) + .clickable(onClick = onClick) + .background(color = MaterialTheme.lwa.colorScheme.elevated.base2dp) + .ribbon( + color = when (item.equipped) { + true -> MaterialTheme.lwa.colorScheme.base.primary + else -> Color.Transparent + } + ) + .minimumInteractiveComponentSize() + .padding(paddingValues = padding) + .then(other = modifier), + horizontalArrangement = Arrangement.spacedBy(space = spacing), + verticalAlignment = Alignment.CenterVertically, ) { Text( modifier = Modifier.alignByBaseline(), style = MaterialTheme.lwa.typography.base.body1, + fontWeight = FontWeight.Bold, overflow = TextOverflow.Ellipsis, maxLines = 1, - text = "x${it}", + text = item.label, ) + AnimatedContent( + modifier = Modifier.alignByBaseline(), + targetState = item.count, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + ) { + when (it) { + 0f, 1f -> Unit + else -> Text( + style = MaterialTheme.lwa.typography.base.caption, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + text = "x${it}", + ) + } + } } - } - } + }, + ) } \ No newline at end of file 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 0b6f250..a236887 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 @@ -60,7 +60,7 @@ class GMItemViewModel( ) val items: StateFlow> = combine( - itemRepository.itemFlow, + itemRepository.itemFlow(), tagRepository.itemsTagFlow(), filter.valueFlow.map { it.unAccent() }, selectedTagId, 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 5b8edb9..71ad69a 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,8 @@ class CharacterSheetJsonFactory( val json = CharacterSheetJsonV1( id = sheet.id, name = sheet.name, - portrait = sheet.portrait, - thumbnail = sheet.thumbnail, + portrait = sheet.portrait?.takeIf { it.isNotBlank() }, + thumbnail = sheet.thumbnail?.takeIf { it.isNotBlank() }, level = sheet.level, shouldLevelUp = sheet.shouldLevelUp, strength = sheet.strength, diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/Inventory.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/Inventory.kt index 1fbfb71..6efec1a 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/Inventory.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/Inventory.kt @@ -14,7 +14,7 @@ data class Inventory( data class Item( val inventoryId: String, val itemId: String, - val count: Int, + val count: Float, val equipped: Boolean, ) diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/InventoryJsonV1.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/InventoryJsonV1.kt index 10fa214..303a0f0 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/InventoryJsonV1.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/inventory/InventoryJsonV1.kt @@ -20,7 +20,7 @@ data class InventoryJsonV1( data class ItemJson( val inventoryId: String, val itemId: String, - val count: Int, + val count: Float, val equipped: Boolean?, ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/item/factory/ItemJsonFactoryV1.kt b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/item/factory/ItemJsonFactoryV1.kt index 0577ace..64f9ea2 100644 --- a/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/item/factory/ItemJsonFactoryV1.kt +++ b/shared/src/commonMain/kotlin/com/pixelized/shared/lwa/model/item/factory/ItemJsonFactoryV1.kt @@ -30,8 +30,8 @@ class ItemJsonFactoryV1 { metadata = ItemJsonV1.ItemMetadataJsonV1( name = item.metadata.label, description = item.metadata.description, - image = item.metadata.image, - thumbnail = item.metadata.thumbnail, + image = item.metadata.image?.takeIf { it.isNotBlank() }, + thumbnail = item.metadata.thumbnail?.takeIf { it.isNotBlank() }, ), options = ItemJsonV1.ItemOptionJsonV1( stackable = item.options.stackable,