Add AddItem dialog to the inventory screen.

This commit is contained in:
Thomas Andres Gomez 2025-04-18 16:00:35 +02:00
parent c94c820efb
commit 48074f3d13
17 changed files with 704 additions and 84 deletions

View file

@ -188,6 +188,9 @@
<string name="character__inventory__filter_inventory__label">Filtrer l'inventaire</string> <string name="character__inventory__filter_inventory__label">Filtrer l'inventaire</string>
<string name="character__inventory__add_to_purse__title">Ajouter à la bourse</string> <string name="character__inventory__add_to_purse__title">Ajouter à la bourse</string>
<string name="character__inventory__remove_from_purse__title">Retirer de la bourse</string> <string name="character__inventory__remove_from_purse__title">Retirer de la bourse</string>
<string name="character__inventory__filter_item_inventory__label">Filtrer les objets</string>
<string name="character__inventory__inventory__dialog__title">Ajouter à l'inventaire</string>
<string name="character__inventory__description_empty__label">Cet objet n'a pas de description.</string>
<string name="tooltip__characteristics__characteristics">Les caractéristiques constituent les aptitudes innées dun 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.</string> <string name="tooltip__characteristics__characteristics">Les caractéristiques constituent les aptitudes innées dun 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.</string>
<string name="tooltip__characteristics__strength">La Force représente essentiellement la puissance musculaire du personnage. Elle ne décrit pas nécessairement la masse musculaire brute, mais lefficacité 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</string> <string name="tooltip__characteristics__strength">La Force représente essentiellement la puissance musculaire du personnage. Elle ne décrit pas nécessairement la masse musculaire brute, mais lefficacité 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</string>

View file

@ -5,7 +5,7 @@ import com.pixelized.shared.lwa.model.item.Item
class ItemRepository( class ItemRepository(
private val itemStore: ItemStore, private val itemStore: ItemStore,
) { ) {
val itemFlow get() = itemStore.items fun itemFlow() = itemStore.items
suspend fun updateItemFlow() { suspend fun updateItemFlow() {
itemStore.updateItemFlow() itemStore.updateItemFlow()
@ -14,7 +14,7 @@ class ItemRepository(
fun item( fun item(
itemId: String?, itemId: String?,
): Item? { ): Item? {
return itemFlow.value[itemId] return itemStore.items.value[itemId]
} }
@Throws @Throws

View file

@ -1,8 +1,308 @@
package com.pixelized.desktop.lwa.ui.composable.character.inventory 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.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<Item>,
) {
@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 @Composable
fun InventoryDialog() { fun InventoryDialog(
dialog: State<InventoryDialogUio?>,
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,
)
}
},
)
} }

View file

@ -1,5 +1,82 @@
package com.pixelized.desktop.lwa.ui.composable.character.inventory 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 { class InventoryDialogFactory {
fun filterItems(
items: Collection<Item>,
filter: String,
setting: Settings,
): Collection<Item> {
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<Item>,
filterFlow: Pair<MutableStateFlow<String>, MutableStateFlow<Boolean>>,
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<String>, MutableStateFlow<Boolean>> {
return MutableStateFlow(value) to MutableStateFlow(error)
}
private fun Pair<MutableStateFlow<String>, MutableStateFlow<Boolean>>.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"
}
} }

View file

@ -1,7 +1,52 @@
package com.pixelized.desktop.lwa.ui.composable.character.inventory package com.pixelized.desktop.lwa.ui.composable.character.inventory
import androidx.lifecycle.ViewModel 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<String?>(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 }
}
} }

View file

@ -6,9 +6,15 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.MaterialTheme
import androidx.compose.material.Surface import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
@ -20,16 +26,23 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.window.Dialog
import com.pixelized.desktop.lwa.ui.composable.decoratedBox.DecoratedBox 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.image.DesaturatedAsyncImage
import com.pixelized.desktop.lwa.ui.theme.lwa
import com.pixelized.desktop.lwa.utils.extention.onPreviewEscape import com.pixelized.desktop.lwa.utils.extention.onPreviewEscape
import com.pixelized.desktop.lwa.utils.rememberSaturationFilter 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 @Stable
data class ItemDetailDialogUio( data class ItemDetailDialogUio(
val inventoryId: String?,
val itemId: String, val itemId: String,
val label: String, val label: String,
val description: String, val description: String,
@ -76,6 +89,10 @@ private fun ItemDetailDialogContent(
dialog: ItemDetailDialogUio, dialog: ItemDetailDialogUio,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val layoutDirection = LocalLayoutDirection.current
val end = remember(layoutDirection, paddings) { paddings.calculateEndPadding(layoutDirection) }
val top = remember(paddings) { paddings.calculateTopPadding() }
Box( Box(
modifier = Modifier modifier = Modifier
.clickable( .clickable(
@ -96,6 +113,7 @@ private fun ItemDetailDialogContent(
Box( Box(
modifier = Modifier.padding(paddingValues = paddings) modifier = Modifier.padding(paddingValues = paddings)
) { ) {
takeIf { dialog.image?.isNotEmpty() == true }?.let {
DesaturatedAsyncImage( DesaturatedAsyncImage(
modifier = Modifier modifier = Modifier
.size(64.dp * 2) .size(64.dp * 2)
@ -107,15 +125,35 @@ private fun ItemDetailDialogContent(
filterQuality = FilterQuality.High, filterQuality = FilterQuality.High,
contentDescription = null, contentDescription = null,
) )
}
Column( Column(
modifier = modifier, modifier = modifier,
verticalArrangement = Arrangement.spacedBy(space = 8.dp) verticalArrangement = Arrangement.spacedBy(space = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Text(
modifier = Modifier.weight(weight = 1f),
style = MaterialTheme.typography.h5, style = MaterialTheme.typography.h5,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = dialog.label, 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( Text(
style = MaterialTheme.typography.body1, style = MaterialTheme.typography.body1,
text = dialog.description, text = dialog.description,

View file

@ -1,19 +1,26 @@
package com.pixelized.desktop.lwa.ui.composable.character.item package com.pixelized.desktop.lwa.ui.composable.character.item
import com.pixelized.shared.lwa.model.item.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 { class ItemDetailDialogFactory {
fun convertToDialogUio( suspend fun convertToDialogUio(
items: Map<String, Item>, items: Map<String, Item>,
inventoryId: String?,
itemId: String?, itemId: String?,
): ItemDetailDialogUio? { ): ItemDetailDialogUio? {
val item = itemId.let(items::get) ?: return null val item = itemId.let(items::get) ?: return null
return ItemDetailDialogUio( return ItemDetailDialogUio(
inventoryId = inventoryId,
itemId = item.id, itemId = item.id,
label = item.metadata.label, 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, image = item.metadata.image,
option = ItemDetailDialogUio.OptionUio( option = ItemDetailDialogUio.OptionUio(
equipable = item.options.equipable, equipable = item.options.equipable,

View file

@ -14,21 +14,37 @@ class ItemDetailDialogViewModel(
factory: ItemDetailDialogFactory, factory: ItemDetailDialogFactory,
) : ViewModel() { ) : ViewModel() {
private val selectedItemId = MutableStateFlow<String?>(null) private val selectedItemId = MutableStateFlow<InventoryItemId?>(null)
val itemDialog = combine( val itemDialog = combine(
itemRepository.itemFlow, selectedItemId, itemRepository.itemFlow(), selectedItemId,
transform = { items, itemId -> factory.convertToDialogUio(items, itemId) } transform = { items, ids ->
factory.convertToDialogUio(
items = items,
inventoryId = ids?.inventoryId,
itemId = ids?.itemId,
)
}
).stateIn( ).stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.Lazily, started = SharingStarted.Lazily,
initialValue = null initialValue = null
) )
fun showItemDialog(itemId: String?) { fun showItemDialog(inventoryId: String?, itemId: String?) {
selectedItemId.update { itemId } selectedItemId.update {
InventoryItemId(
inventoryId = inventoryId,
itemId = itemId,
)
}
} }
fun hideItemDialog() { fun hideItemDialog() {
selectedItemId.update { null } selectedItemId.update { null }
} }
private data class InventoryItemId(
val inventoryId: String?,
val itemId: String?,
)
} }

View file

@ -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 <T> 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() },
)
}
}

View file

@ -30,6 +30,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.LocalBlurController 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.ItemDetailDialog
import com.pixelized.desktop.lwa.ui.composable.character.item.ItemDetailDialogViewModel import com.pixelized.desktop.lwa.ui.composable.character.item.ItemDetailDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.purse.PurseDialog import com.pixelized.desktop.lwa.ui.composable.character.purse.PurseDialog
@ -81,6 +83,7 @@ fun CharacterDetailInventory(
paddings: PaddingValues = CharacterDetailInventoryDefault.padding, paddings: PaddingValues = CharacterDetailInventoryDefault.padding,
spacing: Dp = CharacterDetailInventoryDefault.spacing, spacing: Dp = CharacterDetailInventoryDefault.spacing,
purseViewModel: PurseDialogViewModel = koinViewModel(), purseViewModel: PurseDialogViewModel = koinViewModel(),
inventoryDialogViewModel: InventoryDialogViewModel = koinViewModel(),
itemDetailDialogViewModel: ItemDetailDialogViewModel = koinViewModel(), itemDetailDialogViewModel: ItemDetailDialogViewModel = koinViewModel(),
inventory: State<CharacterDetailInventoryUio?>, inventory: State<CharacterDetailInventoryUio?>,
) { ) {
@ -97,11 +100,22 @@ fun CharacterDetailInventory(
inventory = unWrap, inventory = unWrap,
onPurse = { onPurse = {
blur.show() blur.show()
purseViewModel.showPurseDialog(characterSheetId = it) purseViewModel.showPurseDialog(
characterSheetId = it,
)
}, },
onItem = { onItem = {
blur.show() 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( ItemDetailDialog(
dialog = itemDetailDialogViewModel.itemDialog.collectAsState(), dialog = itemDetailDialogViewModel.itemDialog.collectAsState(),
onDismissRequest = { onDismissRequest = {
@ -146,6 +175,7 @@ private fun CharacterDetailInventoryContent(
inventory: CharacterDetailInventoryUio, inventory: CharacterDetailInventoryUio,
onPurse: (String) -> Unit, onPurse: (String) -> Unit,
onItem: (InventoryItemUio) -> Unit, onItem: (InventoryItemUio) -> Unit,
onAddItem: (String) -> Unit,
) { ) {
Box( Box(
modifier = modifier, modifier = modifier,
@ -221,7 +251,7 @@ private fun CharacterDetailInventoryContent(
colors = LwaButtonColors(), colors = LwaButtonColors(),
elevation = ButtonDefaults.elevation(4.dp), elevation = ButtonDefaults.elevation(4.dp),
shape = CircleShape, shape = CircleShape,
onClick = { }, onClick = { onAddItem(inventory.characterSheetId) },
) { ) {
Text( Text(
modifier = Modifier.padding(end = 4.dp), modifier = Modifier.padding(end = 4.dp),

View file

@ -42,7 +42,7 @@ class CharacterDetailInventoryFactory(
) )
return combine( return combine(
inventoryRepository.inventoryFlow(characterSheetId = characterSheetId), inventoryRepository.inventoryFlow(characterSheetId = characterSheetId),
itemRepository.itemFlow, itemRepository.itemFlow(),
filterFlow.map { it.unAccent() }, filterFlow.map { it.unAccent() },
) { inventory, items, filter -> ) { inventory, items, filter ->
convertToCharacterInventoryUio( convertToCharacterInventoryUio(
@ -85,6 +85,13 @@ class CharacterDetailInventoryFactory(
label = item.metadata.label, label = item.metadata.label,
count = it.count, count = it.count,
equipped = it.equipped, 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 }) ?.sortedWith(compareBy(Collator.getInstance()) { it.label })

View file

@ -1,20 +1,22 @@
package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory.item package com.pixelized.desktop.lwa.ui.screen.campaign.player.detail.inventory.item
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.minimumInteractiveComponentSize import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -23,21 +25,35 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.ui.theme.lwa
import com.pixelized.desktop.lwa.utils.extention.ribbon import com.pixelized.desktop.lwa.utils.extention.ribbon
import com.pixelized.desktop.lwa.utils.rememberSaturationFilter
@Stable @Stable
data class InventoryItemUio( data class InventoryItemUio(
val inventoryId: String, val inventoryId: String,
val itemId: String, val itemId: String,
val label: String, val label: String,
val count: Int, val count: Float,
val equipped: Boolean, val equipped: Boolean,
) val tooltips: Tooltips?,
) {
@Stable
data class Tooltips(
val label: String,
val description: String,
val image: String?,
)
}
@Stable @Stable
object GMCharacterPreviewDefault { object GMCharacterPreviewDefault {
@ -48,6 +64,7 @@ object GMCharacterPreviewDefault {
val spacing: Dp = 4.dp val spacing: Dp = 4.dp
} }
@OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun InventoryItem( fun InventoryItem(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -56,6 +73,49 @@ fun InventoryItem(
item: InventoryItemUio, item: InventoryItemUio,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
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( Row(
modifier = Modifier modifier = Modifier
.clip(shape = MaterialTheme.lwa.shapes.item) .clip(shape = MaterialTheme.lwa.shapes.item)
@ -81,23 +141,15 @@ fun InventoryItem(
maxLines = 1, maxLines = 1,
text = item.label, text = item.label,
) )
AnimatedVisibility(
visible = item.count > 1,
enter = fadeIn(),
exit = fadeOut(),
) {
AnimatedContent( 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)
}
) {
Text(
modifier = Modifier.alignByBaseline(), modifier = Modifier.alignByBaseline(),
style = MaterialTheme.lwa.typography.base.body1, targetState = item.count,
transitionSpec = { fadeIn() togetherWith fadeOut() },
) {
when (it) {
0f, 1f -> Unit
else -> Text(
style = MaterialTheme.lwa.typography.base.caption,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
maxLines = 1, maxLines = 1,
text = "x${it}", text = "x${it}",
@ -105,4 +157,6 @@ fun InventoryItem(
} }
} }
} }
},
)
} }

View file

@ -60,7 +60,7 @@ class GMItemViewModel(
) )
val items: StateFlow<List<GMItemUio>> = combine( val items: StateFlow<List<GMItemUio>> = combine(
itemRepository.itemFlow, itemRepository.itemFlow(),
tagRepository.itemsTagFlow(), tagRepository.itemsTagFlow(),
filter.valueFlow.map { it.unAccent() }, filter.valueFlow.map { it.unAccent() },
selectedTagId, selectedTagId,

View file

@ -26,8 +26,8 @@ class CharacterSheetJsonFactory(
val json = CharacterSheetJsonV1( val json = CharacterSheetJsonV1(
id = sheet.id, id = sheet.id,
name = sheet.name, name = sheet.name,
portrait = sheet.portrait, portrait = sheet.portrait?.takeIf { it.isNotBlank() },
thumbnail = sheet.thumbnail, thumbnail = sheet.thumbnail?.takeIf { it.isNotBlank() },
level = sheet.level, level = sheet.level,
shouldLevelUp = sheet.shouldLevelUp, shouldLevelUp = sheet.shouldLevelUp,
strength = sheet.strength, strength = sheet.strength,

View file

@ -14,7 +14,7 @@ data class Inventory(
data class Item( data class Item(
val inventoryId: String, val inventoryId: String,
val itemId: String, val itemId: String,
val count: Int, val count: Float,
val equipped: Boolean, val equipped: Boolean,
) )

View file

@ -20,7 +20,7 @@ data class InventoryJsonV1(
data class ItemJson( data class ItemJson(
val inventoryId: String, val inventoryId: String,
val itemId: String, val itemId: String,
val count: Int, val count: Float,
val equipped: Boolean?, val equipped: Boolean?,
) )
} }

View file

@ -30,8 +30,8 @@ class ItemJsonFactoryV1 {
metadata = ItemJsonV1.ItemMetadataJsonV1( metadata = ItemJsonV1.ItemMetadataJsonV1(
name = item.metadata.label, name = item.metadata.label,
description = item.metadata.description, description = item.metadata.description,
image = item.metadata.image, image = item.metadata.image?.takeIf { it.isNotBlank() },
thumbnail = item.metadata.thumbnail, thumbnail = item.metadata.thumbnail?.takeIf { it.isNotBlank() },
), ),
options = ItemJsonV1.ItemOptionJsonV1( options = ItemJsonV1.ItemOptionJsonV1(
stackable = item.options.stackable, stackable = item.options.stackable,