Add item CRUD into the GameMaster screen

This commit is contained in:
Andres Gomez, Thomas (ITDV RL) 2025-04-04 17:05:29 +02:00
parent 4ac30fd9b5
commit 5bcb4367d6
32 changed files with 1467 additions and 40 deletions

View file

@ -232,6 +232,7 @@
<string name="game_master__title">Admin</string>
<string name="game_master__action">GameMaster</string>
<string name="game_master__action__save">Sauvegarder</string>
<string name="game_master__character__filter">Filtrer par nom :</string>
<string name="game_master__character_level__label">niv: %1$d</string>
<string name="game_master__character_tag__character">Joueur</string>
@ -253,7 +254,15 @@
<string name="game_master__alteration__edit_tags">Tags</string>
<string name="game_master__alteration__edit_field_id">Identifiant du champ</string>
<string name="game_master__alteration__edit_field_expression">Expression</string>
<string name="game_master__alteration__edit_field_save">Sauvegarder</string>
<string name="game_master__alteration__edit_field_cancel">Annuler</string>
<string name="game_master__item__create">Créer un objet</string>
<string name="game_master__item__delete">Supprimer un objet</string>
<string name="game_master__item__edit_id">Identifiant de l'altération</string>
<string name="game_master__item__edit_label">Nom</string>
<string name="game_master__item__edit_description">Description</string>
<string name="game_master__item__edit_image">Image url</string>
<string name="game_master__item__edit_thumbnail">Vignette url</string>
<string name="game_master__item__edit_stackable">Empilable</string>
<string name="game_master__item__edit_equipable">Équipable</string>
<string name="game_master__item__edit_consumable">Consommable</string>
</resources>

View file

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.onEach
class DataSyncViewModel(
private val characterRepository: CharacterSheetRepository,
private val alterationRepository: AlterationRepository,
private val itemRepository: ItemRepository,
private val campaignRepository: CampaignRepository,
private val tagRepository: TagRepository,
private val settingsRepository: SettingsRepository,
@ -39,14 +41,18 @@ class DataSyncViewModel(
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun synchronise() = coroutineScope {
networkRepository.data.onEach { println(it) }.launchIn(this)
networkRepository.status
.filter { status -> status == NetworkRepository.Status.CONNECTED }
.onEach {
tagRepository.updateAlterationTags()
alterationRepository.updateAlterations()
alterationRepository.updateAlterationFlow()
tagRepository.updateCharacterTags()
characterRepository.updateCharacterPreviews()
campaignRepository.updateCampaign()
tagRepository.updateItemTags()
itemRepository.updateItemFlow()
}
.launchIn(this)

View file

@ -8,6 +8,8 @@ import com.pixelized.desktop.lwa.repository.campaign.CampaignRepository
import com.pixelized.desktop.lwa.repository.campaign.CampaignStore
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetRepository
import com.pixelized.desktop.lwa.repository.characterSheet.CharacterSheetStore
import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.item.ItemStore
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.desktop.lwa.repository.roll_history.RollHistoryRepository
import com.pixelized.desktop.lwa.repository.settings.SettingsFactory
@ -17,8 +19,8 @@ import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.repository.tag.TagStore
import com.pixelized.desktop.lwa.ui.composable.character.alterteration.CharacterSheetAlterationDialogFactory
import com.pixelized.desktop.lwa.ui.composable.character.alterteration.CharacterSheetAlterationDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogFactory
import com.pixelized.desktop.lwa.ui.composable.character.characteristic.CharacterSheetCharacteristicDialogViewModel
import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterSheetDiminishedDialogFactory
import com.pixelized.desktop.lwa.ui.composable.character.diminished.CharacterSheetDiminishedDialogViewModel
import com.pixelized.desktop.lwa.ui.overlay.portrait.PortraitOverlayViewModel
@ -47,6 +49,10 @@ import com.pixelized.desktop.lwa.ui.screen.gamemaster.alteration.list.GMAlterati
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.character.list.GMCharacterViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit.GMItemEditFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit.GMItemEditViewModel
import com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list.GMItemFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list.GMItemViewModel
import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpFactory
import com.pixelized.desktop.lwa.ui.screen.levelup.LevelUpViewModel
import com.pixelized.desktop.lwa.ui.screen.rollhistory.RollHistoryViewModel
@ -105,6 +111,7 @@ val storeDependencies
singleOf(::AlterationStore)
singleOf(::CampaignStore)
singleOf(::TagStore)
singleOf(::ItemStore)
}
val repositoryDependencies
@ -116,6 +123,7 @@ val repositoryDependencies
singleOf(::AlterationRepository)
singleOf(::CampaignRepository)
singleOf(::TagRepository)
singleOf(::ItemRepository)
}
val factoryDependencies
@ -137,6 +145,8 @@ val factoryDependencies
factoryOf(::GMCharacterFactory)
factoryOf(::GMAlterationFactory)
factoryOf(::GMAlterationEditFactory)
factoryOf(::GMItemFactory)
factoryOf(::GMItemEditFactory)
}
val viewModelDependencies
@ -163,6 +173,8 @@ val viewModelDependencies
viewModelOf(::GMActionViewModel)
viewModelOf(::GMAlterationViewModel)
viewModelOf(::GMAlterationEditViewModel)
viewModelOf(::GMItemViewModel)
viewModelOf(::GMItemEditViewModel)
}
val useCaseDependencies

View file

@ -3,9 +3,10 @@ package com.pixelized.desktop.lwa.network
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
interface LwaClient {
@ -56,6 +57,7 @@ interface LwaClient {
suspend fun putCharacter(
sheet: CharacterSheetJson,
create: Boolean,
): APIResponse<Unit>
suspend fun putCharacterDamage(
@ -83,12 +85,31 @@ interface LwaClient {
characterSheetId: String,
): APIResponse<Unit>
// Items
suspend fun getItems(): APIResponse<List<ItemJson>>
suspend fun getItem(
itemId: String,
): APIResponse<ItemJson>
suspend fun putItem(
itemJson: ItemJson,
create: Boolean,
): APIResponse<Unit>
suspend fun deleteItem(
itemId: String,
): APIResponse<Unit>
// Tags
suspend fun getAlterationTags(): APIResponse<List<TagJson>>
suspend fun getCharacterTags(): APIResponse<List<TagJson>>
suspend fun getItemTags(): APIResponse<List<TagJson>>
companion object {
fun error(error: APIResponse<*>): Nothing = throw LwaNetworkException(error)
}

View file

@ -4,6 +4,7 @@ import com.pixelized.desktop.lwa.repository.settings.SettingsRepository
import com.pixelized.shared.lwa.model.alteration.AlterationJson
import com.pixelized.shared.lwa.model.campaign.CampaignJson
import com.pixelized.shared.lwa.model.characterSheet.CharacterSheetJson
import com.pixelized.shared.lwa.model.item.ItemJson
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import com.pixelized.shared.lwa.protocol.rest.CharacterPreviewJson
@ -98,8 +99,9 @@ class LwaClientImpl(
@Throws
override suspend fun putCharacter(
sheet: CharacterSheetJson,
create: Boolean,
) = client
.put("$root/character/update/sheet") {
.put("$root/character/update/sheet?create=$create") {
contentType(ContentType.Application.Json)
setBody(sheet)
}
@ -143,6 +145,36 @@ class LwaClientImpl(
.delete("$root/character/delete?characterSheetId=$characterSheetId")
.body<APIResponse<Unit>>()
@Throws
override suspend fun getItems(): APIResponse<List<ItemJson>> = client
.get("$root/item/all")
.body()
@Throws
override suspend fun getItem(
itemId: String,
): APIResponse<ItemJson> = client
.get("$root/item/detail?itemId=$itemId")
.body()
@Throws
override suspend fun putItem(
item: ItemJson,
create: Boolean,
): APIResponse<Unit> = client
.put("$root/item/update?create=$create") {
contentType(ContentType.Application.Json)
setBody(item)
}
.body<APIResponse<Unit>>()
@Throws
override suspend fun deleteItem(
itemId: String,
): APIResponse<Unit> = client
.delete("$root/item/delete?itemId=$itemId")
.body()
@Throws
override suspend fun getAlterationTags(): APIResponse<List<TagJson>> = client
.get("$root/tag/alteration")
@ -152,4 +184,9 @@ class LwaClientImpl(
override suspend fun getCharacterTags(): APIResponse<List<TagJson>> = client
.get("$root/tag/character")
.body()
@Throws
override suspend fun getItemTags(): APIResponse<List<TagJson>> = client
.get("$root/tag/item")
.body()
}

View file

@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.stateIn
class AlterationRepository(
private val alterationStore: AlterationStore,
campaignRepository: CampaignRepository,
characterRepository: CharacterSheetRepository,
) {
@ -64,7 +63,7 @@ class AlterationRepository(
initialValue = emptyMap(),
)
suspend fun updateAlterations() {
suspend fun updateAlterationFlow() {
alterationStore.updateAlterationsFlow()
}
@ -97,6 +96,7 @@ class AlterationRepository(
)
}
@kotlin.jvm.Throws
suspend fun deleteAlteration(
alterationId: String,
) {

View file

@ -52,8 +52,9 @@ class CharacterSheetRepository(
@Throws
suspend fun updateCharacter(
sheet: CharacterSheet,
create: Boolean,
) {
store.updateCharacterSheet(sheet = sheet)
store.updateCharacterSheet(sheet = sheet, create = create)
}
@Throws

View file

@ -1,5 +1,6 @@
package com.pixelized.desktop.lwa.repository.characterSheet
import androidx.compose.runtime.clearCompositionErrors
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.alteration.AlterationStore
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
@ -91,9 +92,10 @@ class CharacterSheetStore(
@Throws
suspend fun updateCharacterSheet(
sheet: CharacterSheet,
create: Boolean,
) {
val json = factory.convertToJson(sheet = sheet)
val request = client.putCharacter(sheet = json)
val request = client.putCharacter(sheet = json, create = create)
if (request.success.not()) {
LwaClient.error(error = request)
}

View file

@ -0,0 +1,39 @@
package com.pixelized.desktop.lwa.repository.item
import com.pixelized.shared.lwa.model.item.Item
class ItemRepository(
private val itemStore: ItemStore,
) {
val itemFlow get() = itemStore.items
suspend fun updateItemFlow() {
itemStore.updateItemFlow()
}
fun item(
itemId: String?,
): Item? {
return itemFlow.value[itemId]
}
@Throws
suspend fun updateItem(
item: Item,
create: Boolean,
) {
itemStore.putItem(
item = item,
create = create,
)
}
@Throws
suspend fun deleteItem(
itemId: String,
) {
itemStore.deleteItem(
itemId = itemId
)
}
}

View file

@ -0,0 +1,138 @@
package com.pixelized.desktop.lwa.repository.item
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.desktop.lwa.repository.network.NetworkRepository
import com.pixelized.shared.lwa.model.item.Item
import com.pixelized.shared.lwa.model.item.factory.ItemJsonFactory
import com.pixelized.shared.lwa.protocol.websocket.ApiSynchronisation
import com.pixelized.shared.lwa.protocol.websocket.SocketMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class ItemStore(
private val network: NetworkRepository,
private val factory: ItemJsonFactory,
private val client: LwaClient,
) {
private val _items = MutableStateFlow<Map<String, Item>>(emptyMap())
val items: StateFlow<Map<String, Item>> get() = _items
init {
val scope = CoroutineScope(Dispatchers.IO + Job())
// data update through WebSocket.
scope.launch {
network.data.collect(::handleMessage)
}
}
fun items(): Collection<Item> {
return items.value.values
}
fun item(itemId: String): Item? {
return items.value[itemId]
}
suspend fun updateItemFlow() {
_items.value = try {
getItem()
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
private suspend fun updateItemFlow(
itemId: String,
) {
val item = try {
getItem(itemId = itemId)
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
null
}
// guard case if getItem failed
if (item == null) return
// update the flow with the new item.
_items.update { items ->
items.toMutableMap().also {
it[itemId] = item
}
}
}
@Throws
private suspend fun getItem(): Map<String, Item> {
val request = client.getItems()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(json = it) }
?.associateBy { it.id }
?: emptyMap()
else -> LwaClient.error(error = request)
}
}
@Throws
private suspend fun getItem(
itemId: String,
): Item? {
val request = client.getItem(itemId = itemId)
return when (request.success) {
true -> request.data?.let { factory.convertFromJson(json = it) }
else -> LwaClient.error(error = request)
}
}
@Throws
suspend fun putItem(
item: Item,
create: Boolean,
) {
val request = client.putItem(
itemJson = factory.convertToJson(item = item),
create = create,
)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
@Throws
suspend fun deleteItem(
itemId: String,
) {
val request = client.deleteItem(itemId = itemId)
if (request.success.not()) {
LwaClient.error(error = request)
}
}
// region: WebSocket & data update.
private suspend fun handleMessage(message: SocketMessage) {
when (message) {
is ApiSynchronisation.ItemApiSynchronisation -> when (message) {
is ApiSynchronisation.ItemUpdate -> updateItemFlow(
itemId = message.itemId,
)
is ApiSynchronisation.ItemDelete -> _items.update { items ->
items.toMutableMap().also {
it.remove(message.itemId)
}
}
}
else -> Unit
}
}
// endregion
}

View file

@ -10,7 +10,17 @@ class TagRepository(
suspend fun updateAlterationTags() = store.updateAlterationTagsFlow()
suspend fun updateItemTags() = store.updateItemTagsFlow()
fun charactersTagFlow(): StateFlow<Map<String, Tag>> = store.charactersTagFlow()
fun charactersTags(): Collection<Tag> = charactersTagFlow().value.values
fun alterationsTagFlow(): StateFlow<Map<String, Tag>> = store.alterationsTagFlow()
fun alterationsTags(): Collection<Tag> = alterationsTagFlow().value.values
fun itemsTagFlow(): StateFlow<Map<String, Tag>> = store.itemsTagFlow()
fun itemsTags(): Collection<Tag> = itemsTagFlow().value.values
}

View file

@ -2,7 +2,9 @@ package com.pixelized.desktop.lwa.repository.tag
import com.pixelized.desktop.lwa.network.LwaClient
import com.pixelized.shared.lwa.model.tag.Tag
import com.pixelized.shared.lwa.model.tag.TagJson
import com.pixelized.shared.lwa.model.tag.TagJsonFactory
import com.pixelized.shared.lwa.protocol.rest.APIResponse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -12,6 +14,7 @@ class TagStore(
) {
private val characterTagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
private val alterationTagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
private val itemTagsFlow = MutableStateFlow<Map<String, Tag>>(emptyMap())
fun charactersTagFlow(): StateFlow<Map<String, Tag>> = characterTagsFlow
@ -33,9 +36,19 @@ class TagStore(
return alterationTagsFlow.value[tagId]
}
fun itemsTagFlow(): StateFlow<Map<String, Tag>> = itemTagsFlow
fun items(): Collection<Tag> {
return itemTagsFlow.value.values
}
fun item(tagId: String): Tag? {
return itemTagsFlow.value[tagId]
}
suspend fun updateCharacterTagsFlow() {
characterTagsFlow.value = try {
getCharacterTag()
requestTag { client.getCharacterTags() }
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
@ -44,7 +57,16 @@ class TagStore(
suspend fun updateAlterationTagsFlow() {
alterationTagsFlow.value = try {
getAlterationTag()
requestTag { client.getAlterationTags() }
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
}
}
suspend fun updateItemTagsFlow() {
itemTagsFlow.value = try {
requestTag { client.getItemTags() }
} catch (exception: Exception) {
println(exception.message) // TODO proper exception handling
emptyMap()
@ -52,21 +74,10 @@ class TagStore(
}
@Throws
private suspend fun getCharacterTag(): Map<String, Tag> {
val request = client.getCharacterTags()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(json = it) }
?.associateBy { it.id }
?: emptyMap()
else -> LwaClient.error(error = request)
}
}
@Throws
private suspend fun getAlterationTag(): Map<String, Tag> {
val request = client.getAlterationTags()
private suspend inline fun requestTag(
crossinline block: suspend () -> APIResponse<List<TagJson>>,
): Map<String, Tag> {
val request = block()
return when (request.success) {
true -> request.data
?.map { factory.convertFromJson(json = it) }

View file

@ -3,9 +3,10 @@ package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.compose.composable
import com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list.GMItemPage
object GMObjectDestination {
private const val ROUTE = "GameMasterObject"
object GMItemDestination {
private const val ROUTE = "GameMasterItem"
fun baseRoute() = ROUTE
fun navigationRoute() = ROUTE
@ -13,13 +14,13 @@ object GMObjectDestination {
fun NavGraphBuilder.composableGameMasterObjectPage() {
composable(
route = GMObjectDestination.baseRoute(),
route = GMItemDestination.baseRoute(),
) {
GMItemPage()
}
}
fun NavHostController.navigateToGameMasterObjectPage() {
val route = GMObjectDestination.navigationRoute()
val route = GMItemDestination.navigationRoute()
navigate(route = route)
}

View file

@ -0,0 +1,58 @@
package com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster
import androidx.compose.runtime.Stable
import androidx.lifecycle.SavedStateHandle
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit.GMItemEditPage
import com.pixelized.desktop.lwa.utils.extention.ARG
@Stable
object GMItemEditDestination {
private const val ROUTE = "GameMasterItem"
private const val ITEM_ID = "id"
@Stable
fun baseRoute() = "$ROUTE?${ITEM_ID.ARG}"
@Stable
fun navigationRoute(itemId: String?) = "$ROUTE?$ITEM_ID=$itemId"
@Stable
fun arguments() = listOf(
navArgument(ITEM_ID) {
nullable = true
type = NavType.StringType
},
)
@Stable
data class Argument(
val id: String?,
) {
constructor(savedStateHandle: SavedStateHandle) : this(
id = savedStateHandle.get<String>(ITEM_ID),
)
}
}
fun NavGraphBuilder.composableGameMasterItemEditPage() {
composable(
route = GMItemEditDestination.baseRoute(),
arguments = GMItemEditDestination.arguments(),
) {
GMItemEditPage()
}
}
fun NavHostController.navigateToGameMasterItemEditPage(
itemId: String?,
) {
val route = GMItemEditDestination.navigationRoute(
itemId = itemId,
)
navigate(route = route)
}

View file

@ -15,8 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.runBlocking
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__critical_action_label
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__description_label
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__default_action_label
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__description_label
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__name_label
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__actions__spacial_action_label
import lwacharactersheet.composeapp.generated.resources.character_sheet_edit__copy__label
@ -182,6 +182,7 @@ class CharacterSheetEditViewModel(
)
characterSheetRepository.updateCharacter(
sheet = updatedSheet,
create = argument.id == null,
)
}
@ -194,6 +195,7 @@ class CharacterSheetEditViewModel(
)
characterSheetRepository.updateCharacter(
sheet = updatedSheet.copy(id = characterSheetId),
create = true,
)
}

View file

@ -12,6 +12,7 @@ import androidx.navigation.compose.rememberNavController
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GameMasterDestination
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterAlterationEditPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterItemEditPage
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.composableGameMasterMainPage
val LocalGMScreenController = compositionLocalOf<NavHostController> {
@ -36,6 +37,7 @@ fun GameMasterNavHost() {
) {
composableGameMasterMainPage()
composableGameMasterAlterationEditPage()
composableGameMasterItemEditPage()
}
}
}

View file

@ -68,8 +68,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__action__save
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_add_field
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__edit_field_save
import lwacharactersheet.composeapp.generated.resources.game_master__alteration__title
import lwacharactersheet.composeapp.generated.resources.ic_save_24dp
import org.jetbrains.compose.resources.painterResource
@ -103,12 +103,12 @@ fun GMAlterationEditScreen(
) {
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
val form = viewModel.form.collectAsState()
GMAlterationEditContent(
modifier = Modifier.fillMaxSize(),
form = form,
paddings = GMAlterationEditPageDefault.paddings,
onBack = {
screen.navigateBack()
},
@ -147,7 +147,7 @@ private fun GMAlterationEditContent(
scope: CoroutineScope = rememberCoroutineScope(),
tagsState: LazyListState = rememberLazyListState(),
form: State<GMAlterationEditPageUio?>,
paddings: PaddingValues,
paddings: PaddingValues = GMAlterationEditPageDefault.paddings,
onBack: () -> Unit,
addField: () -> Unit,
removeField: (index: Int) -> Unit,
@ -181,7 +181,7 @@ private fun GMAlterationEditContent(
modifier = Modifier.padding(end = 4.dp),
color = MaterialTheme.lwa.colorScheme.base.primary,
fontWeight = FontWeight.SemiBold,
text = stringResource(Res.string.game_master__alteration__edit_field_save),
text = stringResource(Res.string.game_master__action__save),
)
Icon(
painter = painterResource(Res.drawable.ic_save_24dp),
@ -342,7 +342,7 @@ private fun GMAlterationEditContent(
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__alteration__edit_field_save),
text = stringResource(Res.string.game_master__action__save),
)
Icon(
painter = painterResource(Res.drawable.ic_save_24dp),

View file

@ -36,7 +36,7 @@ class GMAlterationEditViewModel(
_form.value = factory.createForm(
originId = argument.id,
alteration = alterationRepository.alteration(alterationId = argument.id),
tags = tagRepository.alterationsTagFlow().value.values,
tags = tagRepository.alterationsTags(),
)
}
}

View file

@ -44,6 +44,7 @@ data class GMAlterationUio(
@Stable
object GMAlterationDefault {
@Stable
val padding = PaddingValues(start = 16.dp)
}

View file

@ -26,6 +26,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterAlterationEditPage
@ -68,6 +69,10 @@ fun GMAlterationPage(
screen.navigateToGameMasterAlterationEditPage(alterationId = null)
},
)
ErrorSnackHandler(
error = viewModel.error,
)
}
}

View file

@ -4,11 +4,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.alteration.AlterationRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.utils.extention.unAccent
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@ -30,6 +33,9 @@ class GMAlterationViewModel(
private val selectedTagId = MutableStateFlow<String?>(null)
private val filterValue = MutableStateFlow("")
private val _error = MutableSharedFlow<ErrorSnackUio>()
val error: SharedFlow<ErrorSnackUio> get() = _error
val filter = LwaTextFieldUio(
enable = true,
label = runBlocking { getString(Res.string.game_master__character__filter) },
@ -84,6 +90,13 @@ class GMAlterationViewModel(
}
suspend fun deleteAlteration(alterationId: String) {
alterationRepository.deleteAlteration(alterationId)
try {
alterationRepository.deleteAlteration(
alterationId = alterationId,
)
} catch (exception: Exception) {
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
}
}
}

View file

@ -0,0 +1,123 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit
import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.shared.lwa.model.item.Item
import com.pixelized.shared.lwa.model.tag.Tag
import kotlinx.coroutines.flow.MutableStateFlow
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_description
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_id
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_image
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_label
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_thumbnail
import org.jetbrains.compose.resources.getString
class GMItemEditFactory(
private val tagFactory: GMTagFactory,
) {
suspend fun createForm(
originId: String?,
item: Item?,
tags: Collection<Tag>,
): GMItemEditPageUio {
val idFlow = MutableStateFlow(item?.id ?: "")
val labelFlow = MutableStateFlow(item?.metadata?.name ?: "")
val descriptionFlow = MutableStateFlow(item?.metadata?.description ?: "")
val imageFlow = MutableStateFlow(item?.metadata?.image ?: "")
val thumbnailFlow = MutableStateFlow(item?.metadata?.thumbnail ?: "")
val stackableFlow = MutableStateFlow(item?.options?.stackable ?: false)
val equipableFlow = MutableStateFlow(item?.options?.equipable ?: false)
val consumableFlow = MutableStateFlow(item?.options?.consumable ?: false)
val tagFlow = MutableStateFlow(
tagFactory.convertToGMTagItemUio(
tags = tags,
selectedTagIds = item?.tags ?: emptyList(),
)
)
return GMItemEditPageUio(
id = LwaTextFieldUio(
enable = originId == null,
isError = MutableStateFlow(false),
label = getString(Res.string.game_master__item__edit_id),
valueFlow = idFlow,
placeHolder = null,
onValueChange = { idFlow.value = it },
),
label = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
label = getString(Res.string.game_master__item__edit_label),
valueFlow = labelFlow,
placeHolder = null,
onValueChange = { labelFlow.value = it },
),
description = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
label = getString(Res.string.game_master__item__edit_description),
valueFlow = descriptionFlow,
placeHolder = null,
onValueChange = { descriptionFlow.value = it },
),
image = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
label = getString(Res.string.game_master__item__edit_image),
valueFlow = imageFlow,
placeHolder = null,
onValueChange = { descriptionFlow.value = it },
),
thumbnail = LwaTextFieldUio(
enable = true,
isError = MutableStateFlow(false),
label = getString(Res.string.game_master__item__edit_thumbnail),
valueFlow = thumbnailFlow,
placeHolder = null,
onValueChange = { descriptionFlow.value = it },
),
equipable = LwaCheckBoxUio(
checked = equipableFlow,
onCheckedChange = { equipableFlow.value = it },
),
stackable = LwaCheckBoxUio(
checked = stackableFlow,
onCheckedChange = { stackableFlow.value = it },
),
consumable = LwaCheckBoxUio(
checked = consumableFlow,
onCheckedChange = { consumableFlow.value = it },
),
tags = tagFlow,
)
}
suspend fun createItem(
form: GMItemEditPageUio?,
): Item? {
if (form == null) return null
return Item(
id = form.id.valueFlow.value,
metadata = Item.MetaData(
name = form.label.valueFlow.value,
description = form.description.valueFlow.value,
image = form.image.valueFlow.value,
thumbnail = form.thumbnail.valueFlow.value,
),
options = Item.Options(
stackable = form.stackable.checked.value,
equipable = form.equipable.checked.value,
consumable = form.consumable.checked.value,
),
tags = form.tags.value
.filter { it.highlight }
.map { it.id },
alterations = emptyList(), // TODO,
)
}
}

View file

@ -0,0 +1,394 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBox
import com.pixelized.desktop.lwa.ui.composable.checkbox.LwaCheckBoxUio
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler
import com.pixelized.desktop.lwa.ui.composable.key.KeyHandler
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextField
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMItemEditDestination
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagButton
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors
import com.pixelized.desktop.lwa.ui.theme.lwa
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__action__save
import lwacharactersheet.composeapp.generated.resources.game_master__item__create
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_equipable
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_stackable
import lwacharactersheet.composeapp.generated.resources.game_master__item__edit_consumable
import lwacharactersheet.composeapp.generated.resources.ic_save_24dp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@Stable
data class GMItemEditPageUio(
val id: LwaTextFieldUio,
val label: LwaTextFieldUio,
val description: LwaTextFieldUio,
val thumbnail: LwaTextFieldUio,
val image: LwaTextFieldUio,
val stackable: LwaCheckBoxUio,
val equipable: LwaCheckBoxUio,
val consumable: LwaCheckBoxUio,
val tags: MutableStateFlow<List<GMTagUio>>,
)
@Stable
object GMItemEditDefault {
val paddings = PaddingValues(all = 8.dp)
}
@Composable
fun GMItemEditPage(
viewModel: GMItemEditViewModel = koinViewModel(),
) {
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
val form = viewModel.form.collectAsState()
GMItemEditContent(
modifier = Modifier.fillMaxSize(),
form = form,
onBack = {
screen.navigateBack()
},
onSave = {
scope.launch {
if (viewModel.save()) {
screen.navigateBack()
}
}
},
onTag = { tag ->
viewModel.addTag(tag = tag)
},
)
ErrorSnackHandler(
error = viewModel.error,
)
ItemEditKeyHandler(
onDismissRequest = {
screen.navigateBack()
},
)
}
@Composable
private fun GMItemEditContent(
modifier: Modifier = Modifier,
scope: CoroutineScope = rememberCoroutineScope(),
tagsState: LazyListState = rememberLazyListState(),
form: State<GMItemEditPageUio?>,
paddings: PaddingValues = GMItemEditDefault.paddings,
onBack: () -> Unit,
onSave: () -> Unit,
onTag: (GMTagUio) -> Unit,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(Res.string.game_master__item__create),
)
},
navigationIcon = {
IconButton(
onClick = onBack,
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null,
)
}
},
actions = {
TextButton(
onClick = onSave,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
color = MaterialTheme.lwa.colorScheme.base.primary,
fontWeight = FontWeight.SemiBold,
text = stringResource(Res.string.game_master__action__save),
)
Icon(
painter = painterResource(Res.drawable.ic_save_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
}
}
)
},
content = {
AnimatedContent(
targetState = form.value,
transitionSpec = {
if (initialState?.id == targetState?.id) {
EnterTransition.None togetherWith ExitTransition.None
} else {
fadeIn() togetherWith fadeOut()
}
}
) {
when (it) {
null -> Box(
modifier = Modifier.fillMaxSize(),
)
else -> {
val tags = it.tags.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = paddings,
verticalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
item(
key = "Id",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.id,
singleLine = true,
)
}
item(
key = "Name",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.label,
singleLine = true,
)
}
item(
key = "Description",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.description,
singleLine = false,
)
}
item(
key = "Image",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.image,
singleLine = false,
)
}
item(
key = "Thumbnail",
) {
LwaTextField(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
field = it.thumbnail,
singleLine = false,
)
}
item(
key = "Stackable",
) {
Row(
modifier = Modifier
.animateItem()
.fillMaxWidth().padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
style = MaterialTheme.lwa.typography.base.body1,
text = stringResource(Res.string.game_master__item__edit_stackable)
)
LwaCheckBox(
field = it.stackable,
)
}
}
item(
key = "Equipable",
) {
Row(
modifier = Modifier
.animateItem()
.fillMaxWidth().padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
style = MaterialTheme.lwa.typography.base.body1,
text = stringResource(Res.string.game_master__item__edit_equipable)
)
LwaCheckBox(
field = it.equipable,
)
}
}
item(
key = "Consumable",
) {
Row(
modifier = Modifier
.animateItem()
.fillMaxWidth().padding(start = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
style = MaterialTheme.lwa.typography.base.body1,
text = stringResource(Res.string.game_master__item__edit_consumable)
)
LwaCheckBox(
field = it.consumable,
)
}
}
item(
key = "Tags",
) {
LazyRow(
modifier = Modifier.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch {
tagsState.scrollBy(-delta)
}
},
),
state = tagsState,
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
) {
items(
items = tags.value,
) { tag ->
GMTagButton(
modifier = Modifier.height(48.dp),
tag = tag,
onTag = { onTag(tag) }
)
}
}
}
item(
key = "Actions",
) {
Column(
modifier = Modifier
.animateItem()
.fillMaxWidth(),
horizontalAlignment = Alignment.End
) {
Button(
colors = LwaButtonColors(),
shape = CircleShape,
onClick = onSave,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__action__save),
)
Icon(
painter = painterResource(Res.drawable.ic_save_24dp),
contentDescription = null,
)
}
}
}
}
}
}
}
},
)
}
@Composable
private fun ItemEditKeyHandler(
onDismissRequest: () -> Unit,
) {
KeyHandler {
when {
it.type == KeyEventType.KeyDown && it.key == Key.Escape -> {
onDismissRequest()
true
}
else -> false
}
}
}
private fun NavHostController.navigateBack() = popBackStack(
route = GMItemEditDestination.baseRoute(),
inclusive = true,
)

View file

@ -0,0 +1,77 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.edit
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.network.LwaNetworkException
import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.GMItemEditDestination
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.shared.lwa.protocol.rest.APIResponse.ErrorCode
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class GMItemEditViewModel(
private val itemRepository: ItemRepository,
private val tagRepository: TagRepository,
private val factory: GMItemEditFactory,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val argument = GMItemEditDestination.Argument(savedStateHandle)
private val _error = MutableSharedFlow<ErrorSnackUio>()
val error: SharedFlow<ErrorSnackUio> get() = _error
private val _form = MutableStateFlow<GMItemEditPageUio?>(null)
val form: StateFlow<GMItemEditPageUio?> get() = _form
init {
viewModelScope.launch {
_form.value = factory.createForm(
originId = argument.id,
item = itemRepository.item(itemId = argument.id),
tags = tagRepository.itemsTags(),
)
}
}
suspend fun save(): Boolean {
val edited = factory.createItem(form = form.value) ?: return false
try {
itemRepository.updateItem(
item = edited,
create = argument.id == null,
)
return true
} catch (exception: LwaNetworkException) {
_form.value?.id?.isError?.value = exception.code == ErrorCode.ItemId
_form.value?.label?.isError?.value = exception.code == ErrorCode.ItemName
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
return false
} catch (exception: Exception) {
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
return false
}
}
fun addTag(tag: GMTagUio) {
_form.value?.tags?.update { tags ->
tags.toMutableList().also {
val index = it.indexOf(tag)
if (index > -1) {
it[index] = tag.copy(highlight = tag.highlight.not())
}
}
}
}
}

View file

@ -0,0 +1,145 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTag
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.lwa
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__item__delete
import lwacharactersheet.composeapp.generated.resources.ic_delete_forever_24dp
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
@Stable
data class GMItemUio(
val itemId: String,
val label: String,
val tags: List<GMTagUio>,
)
@Stable
object GMItemDefault {
@Stable
val padding = PaddingValues(start = 16.dp)
}
@Composable
fun GMItem(
modifier: Modifier = Modifier,
padding: PaddingValues = GMItemDefault.padding,
item: GMItemUio,
onItem: () -> Unit,
onDelete: () -> Unit,
onTag: (String) -> Unit,
) {
Row(
modifier = Modifier
.clip(shape = MaterialTheme.lwa.shapes.gameMaster)
.clickable(onClick = onItem)
.background(color = MaterialTheme.lwa.colorScheme.elevated.base1dp)
.minimumInteractiveComponentSize()
.padding(paddingValues = padding)
.then(other = modifier),
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
style = MaterialTheme.lwa.typography.base.body1,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = item.label,
)
Row(
modifier = Modifier.weight(1f).height(intrinsicSize = IntrinsicSize.Min),
horizontalArrangement = Arrangement.spacedBy(space = 2.dp, alignment = Alignment.End)
) {
item.tags.forEach { tag ->
GMTag(
elevation = 4.dp,
tag = tag,
onTag = { onTag(tag.id) },
)
}
}
OverflowActionMenu(
item = item,
onDelete = onDelete,
)
}
}
@Composable
private fun OverflowActionMenu(
modifier: Modifier = Modifier,
item: GMItemUio,
onDelete: () -> Unit,
) {
val overflowMenu = remember(item) {
mutableStateOf(false)
}
IconButton(
modifier = modifier,
onClick = { overflowMenu.value = true },
) {
Icon(
imageVector = Icons.Default.MoreVert,
tint = MaterialTheme.colors.primary,
contentDescription = null,
)
DropdownMenu(
expanded = overflowMenu.value,
onDismissRequest = {
overflowMenu.value = false
},
content = {
DropdownMenuItem(
onClick = {
overflowMenu.value = false
onDelete()
},
) {
Row(
horizontalArrangement = Arrangement.spacedBy(space = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
painter = painterResource(Res.drawable.ic_delete_forever_24dp),
tint = MaterialTheme.lwa.colorScheme.base.primary,
contentDescription = null,
)
Text(
style = MaterialTheme.lwa.typography.base.body1,
color = MaterialTheme.lwa.colorScheme.base.primary,
text = stringResource(Res.string.game_master__item__delete),
)
}
}
},
)
}
}

View file

@ -0,0 +1,51 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.utils.extention.unAccent
import com.pixelized.shared.lwa.model.item.Item
import com.pixelized.shared.lwa.model.tag.Tag
import java.text.Collator
class GMItemFactory(
private val tagFactory: GMTagFactory,
) {
fun filterItem(
items: Collection<Item>,
unAccentFilter: String,
selectedTagId: String?,
): List<Item> {
return items.filter {
val matchName = it.metadata.name.unAccent().contains(
other = unAccentFilter,
ignoreCase = true
)
val matchTag = selectedTagId == null || it.tags.contains(
element = selectedTagId
)
matchName && matchTag
}
}
fun convertToGMItemUio(
items: List<Item>,
tags: Map<String, Tag>,
selectedTagId: String?,
): List<GMItemUio> {
return items
.map { item ->
GMItemUio(
itemId = item.id,
label = item.metadata.name,
tags = item.tags.mapNotNull {
tags[it]?.let { tag ->
tagFactory.convertToGMTagItemUio(
tag = tag,
selectedTagId = selectedTagId,
)
}
}
)
}
.sortedWith(compareBy(Collator.getInstance()) { it.label })
}
}

View file

@ -0,0 +1,162 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.Icon
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackHandler
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.navigation.screen.LocalScreenController
import com.pixelized.desktop.lwa.ui.navigation.screen.destination.gamemaster.navigateToGameMasterItemEditPage
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.GMFilterHeader
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.ui.theme.color.component.LwaButtonColors
import kotlinx.coroutines.launch
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__item__create
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun GMItemPage(
viewModel: GMItemViewModel = koinViewModel(),
) {
val screen = LocalScreenController.current
val scope = rememberCoroutineScope()
val items = viewModel.items.collectAsState()
val tags = viewModel.tags.collectAsState()
Box {
GMItemContent(
modifier = Modifier.fillMaxSize(),
filter = viewModel.filter,
tags = tags,
items = items,
onTag = viewModel::onTag,
onItemEdit = {
screen.navigateToGameMasterItemEditPage(itemId = it)
},
onItemDelete = {
scope.launch {
viewModel.deleteItem(itemId = it)
}
},
onItemCreate = {
screen.navigateToGameMasterItemEditPage(itemId = null)
},
)
ErrorSnackHandler(
error = viewModel.error,
)
}
}
@Composable
private fun GMItemContent(
modifier: Modifier = Modifier,
padding: Dp = 8.dp,
spacing: Dp = 8.dp,
filter: LwaTextFieldUio,
tags: State<List<GMTagUio>>,
items: State<List<GMItemUio>>,
onTag: (String) -> Unit,
onItemEdit: (String) -> Unit,
onItemDelete: (String) -> Unit,
onItemCreate: () -> Unit,
) {
Column(
modifier = modifier,
) {
Surface(
elevation = 1.dp,
) {
GMFilterHeader(
padding = padding,
spacing = spacing,
filter = filter,
tags = tags,
onTag = onTag,
)
}
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
) {
LazyColumn(
modifier = Modifier.matchParentSize(),
contentPadding = remember {
PaddingValues(
start = padding,
top = padding,
end = padding,
bottom = padding + 48.dp + padding,
)
},
verticalArrangement = Arrangement.spacedBy(space = spacing),
) {
items(
items = items.value,
key = { it.itemId },
) { item ->
GMItem(
modifier = Modifier
.fillMaxWidth()
.animateItem(),
item = item,
onItem = {
onItemEdit(item.itemId)
},
onDelete = {
onItemDelete(item.itemId)
},
onTag = onTag,
)
}
}
Row(
modifier = Modifier
.align(alignment = Alignment.BottomEnd)
.padding(all = padding),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Button(
colors = LwaButtonColors(),
shape = CircleShape,
onClick = onItemCreate,
) {
Text(
modifier = Modifier.padding(end = 4.dp),
text = stringResource(Res.string.game_master__item__create),
)
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
)
}
}
}
}
}

View file

@ -0,0 +1,102 @@
package com.pixelized.desktop.lwa.ui.screen.gamemaster.item.list
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.pixelized.desktop.lwa.repository.item.ItemRepository
import com.pixelized.desktop.lwa.repository.tag.TagRepository
import com.pixelized.desktop.lwa.ui.composable.error.ErrorSnackUio
import com.pixelized.desktop.lwa.ui.composable.textfield.LwaTextFieldUio
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagFactory
import com.pixelized.desktop.lwa.ui.screen.gamemaster.common.tag.GMTagUio
import com.pixelized.desktop.lwa.utils.extention.unAccent
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runBlocking
import lwacharactersheet.composeapp.generated.resources.Res
import lwacharactersheet.composeapp.generated.resources.game_master__character__filter
import org.jetbrains.compose.resources.getString
class GMItemViewModel(
private val itemRepository: ItemRepository,
itemFactory: GMItemFactory,
tagRepository: TagRepository,
tagFactory: GMTagFactory,
) : ViewModel() {
private val selectedTagId = MutableStateFlow<String?>(null)
private val filterValue = MutableStateFlow("")
private val _error = MutableSharedFlow<ErrorSnackUio>()
val error: SharedFlow<ErrorSnackUio> get() = _error
val filter = LwaTextFieldUio(
enable = true,
label = runBlocking { getString(Res.string.game_master__character__filter) },
valueFlow = filterValue,
isError = MutableStateFlow(false),
placeHolder = null,
onValueChange = { filterValue.value = it },
)
val tags: StateFlow<List<GMTagUio>> = combine(
tagRepository.itemsTagFlow(),
selectedTagId,
) { tags, selectedTagId ->
tagFactory.convertToGMTagItemUio(
tags = tags.values,
selectedTagId = selectedTagId,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
val items: StateFlow<List<GMItemUio>> = combine(
itemRepository.itemFlow,
tagRepository.itemsTagFlow(),
filter.valueFlow.map { it.unAccent() },
selectedTagId,
) { items, tags, unAccentFilter, selectedTagId ->
itemFactory.convertToGMItemUio(
items = itemFactory.filterItem(
items = items.values,
unAccentFilter = unAccentFilter,
selectedTagId = selectedTagId,
),
tags = tags,
selectedTagId = selectedTagId,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = emptyList(),
)
fun onTag(id: String) {
selectedTagId.update {
when (it) {
id -> null
else -> id
}
}
}
suspend fun deleteItem(itemId: String) {
try {
itemRepository.deleteItem(
itemId = itemId,
)
} catch (exception: Exception) {
val message = ErrorSnackUio.from(exception = exception)
_error.emit(message)
}
}
}

View file

@ -152,6 +152,7 @@ class LevelUpViewModel(
characterSheetRepository.updateCharacter(
sheet = levelUpCharacter,
create = false,
)
}
}

View file

@ -17,5 +17,6 @@ data class Item(
data class Options(
val stackable: Boolean,
val equipable: Boolean,
val consumable: Boolean,
)
}

View file

@ -23,5 +23,6 @@ data class ItemJsonV1(
data class ItemOptionJsonV1(
val stackable: Boolean,
val equipable: Boolean,
val consumable: Boolean,
)
}

View file

@ -17,6 +17,7 @@ class ItemJsonFactoryV1 {
options = Item.Options(
stackable = json.options.stackable,
equipable = json.options.equipable,
consumable = json.options.consumable,
),
tags = json.tags,
alterations = json.alterations,
@ -35,6 +36,7 @@ class ItemJsonFactoryV1 {
options = ItemJsonV1.ItemOptionJsonV1(
stackable = item.options.stackable,
equipable = item.options.equipable,
consumable = item.options.consumable,
),
tags = item.tags,
alterations = item.alterations,