diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 1b1188457..e1693b204 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -102,6 +102,7 @@ kotlin { dependencies { implementation(kotlin("test")) implementation(libs.mockk) + implementation(libs.kotlinx.coroutines.test) } } @@ -153,6 +154,11 @@ kotlin { implementation(libs.korau) implementation(libs.pdfbox) + implementation("dev.gitlive:firebase-java-sdk") { + version { + strictly("0.4.7") + } + } } } @@ -206,6 +212,7 @@ buildkonfig { Properties() } + buildConfigField(STRING, "authEmulatorUrl", properties.getOrDefault("dev.authEmulatorUrl", "").toString()) buildConfigField(STRING, "functionsEmulatorUrl", properties.getOrDefault("dev.functionsEmulatorUrl", "").toString()) buildConfigField(STRING, "firestoreEmulatorUrl", properties.getOrDefault("dev.firestoreEmulatorUrl", "").toString()) buildConfigField(STRING, "versionName", System.getenv("SUPPLY_VERSION_NAME") ?: "dev") diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/CharacterItemsCard.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/CharacterItemsCard.kt index 6be8086c4..76dcef44b 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/CharacterItemsCard.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/CharacterItemsCard.kt @@ -13,8 +13,10 @@ import cafe.adriel.voyager.core.screen.Screen import cz.frantisekmasa.wfrp_master.common.Str import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardTitle import cz.frantisekmasa.wfrp_master.common.core.ui.cards.StickyHeader +import cz.frantisekmasa.wfrp_master.common.core.ui.menu.WithContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.CompactEmptyUI +import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu import dev.icerock.moko.resources.compose.stringResource import kotlinx.collections.immutable.ImmutableList @@ -25,10 +27,21 @@ fun LazyListScope.characterItemsCard( items: ImmutableList, actions: (@Composable () -> Unit)? = null, newItemScreen: () -> Screen, + detailScreen: (T) -> Screen, + contextMenuItems: @Composable (T) -> List = { emptyList() }, + leadingDivider: Boolean = false, + onRemove: (T) -> Unit, + noItems: @Composable () -> Unit = { + CompactEmptyUI(stringResource(Str.common_ui_messages_no_items)) + }, item: @Composable (T) -> Unit, ) { stickyHeader(key = "$key-header") { StickyHeader { + if (leadingDivider) { + Divider() + } + CardTitle( title(), actions = { @@ -49,7 +62,7 @@ fun LazyListScope.characterItemsCard( if (items.isEmpty()) { item(key = "$key-empty-ui") { if (items.isEmpty()) { - CompactEmptyUI(stringResource(Str.common_ui_messages_no_items)) + noItems() } } } @@ -60,7 +73,18 @@ fun LazyListScope.characterItemsCard( Divider() } - item(it) + val navigation = LocalNavigationTransaction.current + WithContextMenu( + items = + contextMenuItems(it) + + ContextMenu.Item( + stringResource(Str.common_ui_button_remove), + onClick = { onRemove(it) }, + ), + onClick = { navigation.navigate(detailScreen(it)) }, + ) { + item(it) + } } } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CharacterDiseaseDetailScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CharacterDiseaseDetailScreen.kt index a37dd50a8..dd1465dd9 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CharacterDiseaseDetailScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CharacterDiseaseDetailScreen.kt @@ -28,7 +28,7 @@ import cz.frantisekmasa.wfrp_master.common.character.CharacterItemDetailScreen import cz.frantisekmasa.wfrp_master.common.character.wellBeing.diseases.DiseaseSpecification import cz.frantisekmasa.wfrp_master.common.character.wellBeing.diseases.DiseaseSpecificationForm import cz.frantisekmasa.wfrp_master.common.compendium.disease.CompendiumDiseaseDetailScreen -import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Countdown +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Disease import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId import cz.frantisekmasa.wfrp_master.common.core.domain.localizedName diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CountdownInput.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CountdownInput.kt index 8b8d6dc46..9ed89d425 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CountdownInput.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/CountdownInput.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import com.halilibo.richtext.commonmark.Markdown import com.halilibo.richtext.ui.material.RichText -import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Countdown +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown import cz.frantisekmasa.wfrp_master.common.core.ui.forms.InputValue import cz.frantisekmasa.wfrp_master.common.core.ui.forms.SelectBox import cz.frantisekmasa.wfrp_master.common.core.ui.forms.TextInput diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/NonCompendiumDiseaseForm.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/NonCompendiumDiseaseForm.kt index 654700571..ad03eb32f 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/NonCompendiumDiseaseForm.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/diseases/NonCompendiumDiseaseForm.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import com.benasher44.uuid.Uuid import com.benasher44.uuid.uuid4 import cz.frantisekmasa.wfrp_master.common.Str -import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Countdown +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Disease import cz.frantisekmasa.wfrp_master.common.core.ui.forms.FormDialog import cz.frantisekmasa.wfrp_master.common.core.ui.forms.HydratedFormData diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/skills/SkillsCard.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/skills/SkillsCard.kt index f0dae165c..cde01004a 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/skills/SkillsCard.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/skills/SkillsCard.kt @@ -1,12 +1,8 @@ package cz.frantisekmasa.wfrp_master.common.character.skills -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon import androidx.compose.material.IconButton @@ -14,7 +10,6 @@ import androidx.compose.material.ListItem import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.rounded.Add import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -25,18 +20,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.benasher44.uuid.Uuid import cz.frantisekmasa.wfrp_master.common.Str +import cz.frantisekmasa.wfrp_master.common.character.characterItemsCard import cz.frantisekmasa.wfrp_master.common.character.skills.add.AddSkillScreen import cz.frantisekmasa.wfrp_master.common.character.skills.addBasic.AddBasicSkillsScreen import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId import cz.frantisekmasa.wfrp_master.common.core.shared.Resources -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardTitle -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.StickyHeader import cz.frantisekmasa.wfrp_master.common.core.ui.menu.DropdownMenu -import cz.frantisekmasa.wfrp_master.common.core.ui.menu.WithContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.EmptyUI -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import dev.icerock.moko.resources.compose.stringResource import kotlinx.collections.immutable.ImmutableList @@ -45,114 +36,66 @@ internal fun LazyListScope.skillsCard( skills: ImmutableList, onRemove: (SkillDataItem) -> Unit, ) { - stickyHeader(key = "skills-header") { - StickyHeader { - CardTitle( - stringResource(Str.skills_title_skills), - actions = { - var contextMenuExpanded by remember { mutableStateOf(false) } - val navigation = LocalNavigationTransaction.current - - IconButton( - onClick = { navigation.navigate(AddSkillScreen(characterId)) }, - ) { - Icon(Icons.Rounded.Add, stringResource(Str.skills_title_add)) - } - - IconButton(onClick = { contextMenuExpanded = true }) { - Icon( - Icons.Filled.MoreVert, - stringResource(Str.common_ui_label_open_context_menu), - ) - } - - DropdownMenu( - expanded = contextMenuExpanded, - onDismissRequest = { contextMenuExpanded = false }, - ) { - DropdownMenuItem( - onClick = { - contextMenuExpanded = false - navigation.navigate(AddBasicSkillsScreen(characterId)) - }, - ) { - Text(stringResource(Str.skills_button_import_basic_skills)) - } - } - }, + characterItemsCard( + title = { stringResource(Str.skills_title_skills) }, + key = "skills", + id = SkillDataItem::id, + items = skills, + newItemScreen = { AddSkillScreen(characterId) }, + noItems = { + EmptyUI( + text = stringResource(Str.skills_messages_character_has_no_skills), + icon = Resources.Drawable.Skill, + size = EmptyUI.Size.Small, ) - } - } + }, + actions = { + var contextMenuExpanded by remember { mutableStateOf(false) } + val navigation = LocalNavigationTransaction.current - if (skills.isEmpty()) { - item(key = "skills-empty-ui") { - if (skills.isEmpty()) { - EmptyUI( - text = stringResource(Str.skills_messages_character_has_no_skills), - icon = Resources.Drawable.Skill, - size = EmptyUI.Size.Small, + IconButton(onClick = { contextMenuExpanded = true }) { + Icon( + Icons.Filled.MoreVert, + stringResource(Str.common_ui_label_open_context_menu), ) } - } - } - - itemsIndexed(skills, key = { _, it -> "skill" to it.id }) { index, skill -> - val navigation = LocalNavigationTransaction.current - SkillItem( - skill, - onClick = { - navigation.navigate( - CharacterSkillDetailScreen( - characterId, - skill.id, - ), - ) - }, - onRemove = { onRemove(skill) }, - showDivider = index != 0, - ) - } + DropdownMenu( + expanded = contextMenuExpanded, + onDismissRequest = { contextMenuExpanded = false }, + ) { + DropdownMenuItem( + onClick = { + contextMenuExpanded = false + navigation.navigate(AddBasicSkillsScreen(characterId)) + }, + ) { + Text(stringResource(Str.skills_button_import_basic_skills)) + } + } + }, + detailScreen = { skill -> CharacterSkillDetailScreen(characterId, skill.id) }, + onRemove = onRemove, + item = { skill -> SkillItem(skill) }, + ) } @Composable -private fun SkillItem( - skill: SkillDataItem, - onClick: () -> Unit, - onRemove: () -> Unit, - showDivider: Boolean, -) { - Column(Modifier.padding(horizontal = Spacing.large)) { - if (showDivider) { - Divider() - } - - WithContextMenu( - items = - listOf( - ContextMenu.Item( - stringResource(Str.common_ui_button_remove), - onClick = { onRemove() }, - ), - ), - onClick = onClick, - ) { - ListItem( - text = { - Text( - skill.name, - fontWeight = - if (skill.advances > 0) { - FontWeight.SemiBold - } else { - FontWeight.Normal - }, - ) - }, - trailing = { TestNumber(skill.testNumber) }, +private fun SkillItem(skill: SkillDataItem) { + ListItem( + text = { + Text( + skill.name, + fontWeight = + if (skill.advances > 0) { + FontWeight.SemiBold + } else { + FontWeight.Normal + }, ) - } - } + }, + trailing = { TestNumber(skill.testNumber) }, + ) } @Composable diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/talents/TalentsCard.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/talents/TalentsCard.kt index bd5353e6d..f1aab31cd 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/talents/TalentsCard.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/talents/TalentsCard.kt @@ -1,27 +1,14 @@ package cz.frantisekmasa.wfrp_master.common.character.talents -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton +import androidx.compose.material.ListItem import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import com.benasher44.uuid.Uuid import cz.frantisekmasa.wfrp_master.common.Str +import cz.frantisekmasa.wfrp_master.common.character.characterItemsCard import cz.frantisekmasa.wfrp_master.common.character.talents.add.AddTalentScreen import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardItem -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardTitle -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.StickyHeader -import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import dev.icerock.moko.resources.compose.stringResource import kotlinx.collections.immutable.ImmutableList @@ -30,64 +17,24 @@ internal fun LazyListScope.talentsCard( talents: ImmutableList, onRemove: (TalentDataItem) -> Unit, ) { - stickyHeader(key = "talents-header") { - StickyHeader { - CardTitle( - stringResource(Str.talents_title_talents), - actions = { - val navigation = LocalNavigationTransaction.current - IconButton( - onClick = { navigation.navigate(AddTalentScreen(characterId)) }, - ) { - Icon(Icons.Rounded.Add, stringResource(Str.talents_title_add)) - } - }, - ) - } - } - - itemsIndexed(talents, key = { _, it -> "talent" to it.id }) { index, talent -> - val navigation = LocalNavigationTransaction.current - - TalentItem( - talent, - onClick = { - navigation.navigate( - CharacterTalentDetailScreen( - characterId, - talent.id, - ), - ) - }, - onRemove = { onRemove(talent) }, - showDivider = index != 0, - ) - } + characterItemsCard( + title = { stringResource(Str.talents_title_talents) }, + key = "talents", + id = TalentDataItem::id, + items = talents, + newItemScreen = { AddTalentScreen(characterId) }, + detailScreen = { talent -> CharacterTalentDetailScreen(characterId, talent.id) }, + onRemove = onRemove, + item = { talent -> TalentItem(talent) }, + ) } @Composable -private fun TalentItem( - talent: TalentDataItem, - onClick: () -> Unit, - onRemove: () -> Unit, - showDivider: Boolean, -) { - Column(Modifier.padding(horizontal = Spacing.large)) { - if (showDivider) { - Divider() - } - - CardItem( - name = talent.name, - onClick = onClick, - contextMenuItems = - listOf( - ContextMenu.Item(stringResource(Str.common_ui_button_remove), onClick = { onRemove() }), - ), - badge = { Text("+ ${talent.taken}") }, - showDivider = false, - ) - } +private fun TalentItem(talent: TalentDataItem) { + ListItem( + text = { Text(talent.name) }, + trailing = { Text("+ ${talent.taken}") }, + ) } data class TalentDataItem( diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/traits/TraitsCard.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/traits/TraitsCard.kt index bce1618b5..a38edba94 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/traits/TraitsCard.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/traits/TraitsCard.kt @@ -1,26 +1,14 @@ package cz.frantisekmasa.wfrp_master.common.character.traits -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.ListItem +import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier import com.benasher44.uuid.Uuid import cz.frantisekmasa.wfrp_master.common.Str +import cz.frantisekmasa.wfrp_master.common.character.characterItemsCard import cz.frantisekmasa.wfrp_master.common.character.traits.add.AddTraitScreen import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardItem -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardTitle -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.StickyHeader -import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import dev.icerock.moko.resources.compose.stringResource import kotlinx.collections.immutable.ImmutableList @@ -29,64 +17,21 @@ internal fun LazyListScope.traitsCard( traits: ImmutableList, onRemove: (TraitDataItem) -> Unit, ) { - stickyHeader(key = "traits-header") { - StickyHeader { - CardTitle( - stringResource(Str.traits_title_traits), - actions = { - val navigation = LocalNavigationTransaction.current - IconButton( - onClick = { navigation.navigate(AddTraitScreen(characterId)) }, - ) { - Icon(Icons.Rounded.Add, stringResource(Str.traits_title_add)) - } - }, - ) - } - } - - itemsIndexed( - traits, - contentType = { _, _ -> "skill" }, - key = { _, it -> "trait" to it.id }, - ) { index, trait -> - val navigation = LocalNavigationTransaction.current - - TraitItem( - trait, - onClick = { - navigation.navigate( - CharacterTraitDetailScreen(characterId, trait.id), - ) - }, - onRemove = { onRemove(trait) }, - showDivider = index != 0, - ) - } + characterItemsCard( + title = { stringResource(Str.traits_title_traits) }, + key = "traits", + id = TraitDataItem::id, + items = traits, + newItemScreen = { AddTraitScreen(characterId) }, + detailScreen = { trait -> CharacterTraitDetailScreen(characterId, trait.id) }, + onRemove = onRemove, + item = { trait -> TraitItem(trait) }, + ) } @Composable -private fun TraitItem( - trait: TraitDataItem, - onClick: () -> Unit, - onRemove: () -> Unit, - showDivider: Boolean, -) { - Column(Modifier.padding(horizontal = Spacing.large)) { - if (showDivider) { - Divider() - } - - CardItem( - name = trait.name, - onClick = onClick, - contextMenuItems = - listOf( - ContextMenu.Item(stringResource(Str.common_ui_button_remove), onClick = { onRemove() }), - ), - showDivider = false, - ) - } +private fun TraitItem(trait: TraitDataItem) { + ListItem(text = { Text(trait.name) }) } data class TraitDataItem( diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/trappings/TrappingsScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/trappings/TrappingsScreen.kt index 90c8cc410..f22512d2c 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/trappings/TrappingsScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/trappings/TrappingsScreen.kt @@ -2,22 +2,16 @@ package cz.frantisekmasa.wfrp_master.common.character.trappings import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column 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.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.Divider import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Text -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -29,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import cz.frantisekmasa.wfrp_master.common.Str +import cz.frantisekmasa.wfrp_master.common.character.characterItemsCard import cz.frantisekmasa.wfrp_master.common.character.trappings.add.AddTrappingScreen import cz.frantisekmasa.wfrp_master.common.core.domain.Money import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId @@ -36,9 +31,6 @@ import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.Encumbrance import cz.frantisekmasa.wfrp_master.common.core.domain.trappings.InventoryItem import cz.frantisekmasa.wfrp_master.common.core.shared.Resources import cz.frantisekmasa.wfrp_master.common.core.shared.drawableResource -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.CardTitle -import cz.frantisekmasa.wfrp_master.common.core.ui.cards.StickyHeader -import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.EmptyUI import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing @@ -109,67 +101,41 @@ fun TrappingsScreen( } } - stickyHeader { - StickyHeader { - Divider() - CardTitle( - stringResource(Str.trappings_title), - actions = { - val navigation = LocalNavigationTransaction.current - IconButton( - onClick = { - navigation.navigate( - AddTrappingScreen(characterId, containerId = null), - ) - }, - ) { - Icon(Icons.Rounded.Add, stringResource(Str.trappings_title_add)) - } - }, - ) - } - } - - if (state.trappings.isEmpty()) { - item("trappings-empty-ui") { + characterItemsCard( + leadingDivider = true, + title = { stringResource(Str.trappings_title) }, + key = "trappings", + id = { it.item.id }, + items = state.trappings, + newItemScreen = { AddTrappingScreen(characterId, containerId = null) }, + noItems = { EmptyUI( - text = stringResource(Str.trappings_messages_no_items), + stringResource(Str.trappings_messages_no_items), Resources.Drawable.TrappingContainer, size = EmptyUI.Size.Small, ) - } - } - - itemsIndexed( - state.trappings, - key = { _, it -> it.item.id }, - ) { index, trapping -> - Column { - if (index != 0) { - Divider() - } - - val navigation = LocalNavigationTransaction.current - - TrappingItem( - trapping = trapping, - onClick = { - navigation.navigate( - CharacterTrappingDetailScreen(characterId, trapping.item.id), - ) - }, - onRemove = { onRemove(trapping.item) }, - onDuplicate = { onDuplicate(trapping.item) }, - additionalContextItems = - listOf( - ContextMenu.Item( - stringResource(Str.trappings_button_move_to_container), - onClick = { addToContainerDialogTrapping = trapping.item }, - ), - ), + }, + detailScreen = { trapping -> + CharacterTrappingDetailScreen( + characterId, + trapping.item.id, ) - } - } + }, + onRemove = { onRemove(it.item) }, + contextMenuItems = { + listOf( + ContextMenu.Item( + stringResource(Str.trappings_button_move_to_container), + onClick = { addToContainerDialogTrapping = it.item }, + ), + ContextMenu.Item( + stringResource(Str.common_ui_button_duplicate), + onClick = { onDuplicate(it.item) }, + ), + ) + }, + item = { trapping -> TrappingItem(trapping) }, + ) } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseaseSpecificationForm.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseaseSpecificationForm.kt index 3f332cbdc..acd444097 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseaseSpecificationForm.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseaseSpecificationForm.kt @@ -8,7 +8,7 @@ import cz.frantisekmasa.wfrp_master.common.Str import cz.frantisekmasa.wfrp_master.common.character.diseases.CountdownInput import cz.frantisekmasa.wfrp_master.common.character.diseases.CountdownInputData import cz.frantisekmasa.wfrp_master.common.character.diseases.DiagnosedSwitch -import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Countdown +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown import cz.frantisekmasa.wfrp_master.common.core.ui.forms.FormDialog import cz.frantisekmasa.wfrp_master.common.core.ui.forms.HydratedFormData import cz.frantisekmasa.wfrp_master.common.core.ui.forms.Rules diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseasesCard.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseasesCard.kt index bd48159a7..c19d125f4 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseasesCard.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/character/wellBeing/diseases/DiseasesCard.kt @@ -17,12 +17,9 @@ import com.benasher44.uuid.Uuid import cz.frantisekmasa.wfrp_master.common.Str import cz.frantisekmasa.wfrp_master.common.character.characterItemsCard import cz.frantisekmasa.wfrp_master.common.character.diseases.CharacterDiseaseDetailScreen -import cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases.Countdown +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown import cz.frantisekmasa.wfrp_master.common.core.domain.identifiers.CharacterId import cz.frantisekmasa.wfrp_master.common.core.shared.Resources -import cz.frantisekmasa.wfrp_master.common.core.ui.menu.WithContextMenu -import cz.frantisekmasa.wfrp_master.common.core.ui.navigation.LocalNavigationTransaction -import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ItemIcon import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import dev.icerock.moko.resources.compose.stringResource @@ -35,77 +32,61 @@ fun LazyListScope.diseasesCard( ) { characterItemsCard( title = { stringResource(Str.diseases_title_diseases) }, + leadingDivider = true, key = "diseases", id = DiseaseItem::id, items = diseases, newItemScreen = { AddDiseaseScreen(characterId) }, + detailScreen = { disease -> CharacterDiseaseDetailScreen(characterId, disease.id) }, + onRemove = onRemoveRequest, item = { disease -> - val navigation = LocalNavigationTransaction.current - WithContextMenu( - items = - listOf( - ContextMenu.Item( - stringResource(Str.common_ui_button_remove), - onClick = { onRemoveRequest(disease) }, - ), - ), - onClick = { - navigation.navigate( - CharacterDiseaseDetailScreen( - characterId, - disease.id, - ), - ) - }, - ) { - ListItem( - text = { Text(disease.name) }, - icon = { ItemIcon(Resources.Drawable.Disease) }, - secondaryText = - if (!disease.isDiagnosed) { - ( - { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.tiny), - ) { - Icon( - Icons.Rounded.VisibilityOff, - stringResource(Str.diseases_messages_visible_to_player_false), - Modifier.height(Spacing.large), - ) - Text( - stringResource(Str.diseases_label_not_diagnosed), - ) - } + ListItem( + text = { Text(disease.name) }, + icon = { ItemIcon(Resources.Drawable.Disease) }, + secondaryText = + if (!disease.isDiagnosed) { + ( + { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.tiny), + ) { + Icon( + Icons.Rounded.VisibilityOff, + stringResource(Str.diseases_messages_visible_to_player_false), + Modifier.height(Spacing.large), + ) + Text( + stringResource(Str.diseases_label_not_diagnosed), + ) } - ) - } else { - null - }, - trailing = { - when { - disease.isHealed -> { - Text(stringResource(Str.diseases_label_healed)) } + ) + } else { + null + }, + trailing = { + when { + disease.isHealed -> { + Text(stringResource(Str.diseases_label_healed)) + } - disease.incubation.value > 0 -> { - Time( - stringResource(Str.diseases_label_incubation), - disease.incubation, - ) - } + disease.incubation.value > 0 -> { + Time( + stringResource(Str.diseases_label_incubation), + disease.incubation, + ) + } - else -> { - Time( - stringResource(Str.diseases_label_duration), - disease.duration, - ) - } + else -> { + Time( + stringResource(Str.diseases_label_duration), + disease.duration, + ) } - }, - ) - } + } + }, + ) }, ) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Injury.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Injury.kt new file mode 100644 index 000000000..5c5915aab --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Injury.kt @@ -0,0 +1,33 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.domain + +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import cz.frantisekmasa.wfrp_master.common.core.serialization.UuidAsString + +data class Injury( + override val id: UuidAsString, + override val name: String, + override val isVisibleToPlayers: Boolean = true, + val duration: String, + val possibleLocations: List, + val description: String, +): CompendiumItem() { + init { + require(name.isNotEmpty()) + name.requireMaxLength(NAME_MAX_LENGTH, "name") + duration.requireMaxLength(DURATION_MAX_LENGTH, "duration") + description.requireMaxLength(DESCRIPTION_MAX_LENGTH, "description") + } + + override fun replace(original: Injury) = copy(id = original.id) + + override fun changeVisibility(isVisibleToPlayers: Boolean) = copy(isVisibleToPlayers = !isVisibleToPlayers) + + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) + + companion object { + const val NAME_MAX_LENGTH = 50 + const val DURATION_MAX_LENGTH = 500 + const val DESCRIPTION_MAX_LENGTH = 1000 + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/Countdown.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/Countdown.kt new file mode 100644 index 000000000..6c686cd0e --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/Countdown.kt @@ -0,0 +1,39 @@ +package cz.frantisekmasa.wfrp_master.common.core.domain.character + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import cz.frantisekmasa.wfrp_master.common.Plurals +import cz.frantisekmasa.wfrp_master.common.Str +import cz.frantisekmasa.wfrp_master.common.core.domain.NamedEnum +import dev.icerock.moko.parcelize.Parcelable +import dev.icerock.moko.parcelize.Parcelize +import dev.icerock.moko.resources.PluralsResource +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.serialization.Serializable + +@Parcelize +@Serializable +@Immutable +data class Countdown( + val value: Int, + val unit: Unit, +) : Parcelable { + init { + require(value >= 0) { "Remaining time must be non-negative" } + } + + enum class Unit( + override val translatableName: StringResource, + val plural: PluralsResource, + ) : NamedEnum { + DAYS(Str.common_ui_units_days, Plurals.duration_days), + HOURS(Str.common_ui_units_hours, Plurals.duration_hours), + MINUTES(Str.common_ui_units_minutes, Plurals.duration_minutes), + } + + @Composable + @Stable + fun toText(): String = stringResource(unit.plural, value, value) +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/diseases/Disease.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/diseases/Disease.kt index 11a04e1ae..4a2826212 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/diseases/Disease.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/character/diseases/Disease.kt @@ -1,20 +1,12 @@ package cz.frantisekmasa.wfrp_master.common.core.domain.character.diseases -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.benasher44.uuid.uuid4 -import cz.frantisekmasa.wfrp_master.common.Plurals -import cz.frantisekmasa.wfrp_master.common.Str import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength -import cz.frantisekmasa.wfrp_master.common.core.domain.NamedEnum import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterItem +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown import cz.frantisekmasa.wfrp_master.common.core.serialization.UuidAsString -import dev.icerock.moko.parcelize.Parcelable import dev.icerock.moko.parcelize.Parcelize -import dev.icerock.moko.resources.PluralsResource -import dev.icerock.moko.resources.StringResource -import dev.icerock.moko.resources.compose.stringResource import kotlinx.serialization.Serializable import cz.frantisekmasa.wfrp_master.common.compendium.domain.Disease as CompendiumDisease @@ -76,28 +68,3 @@ data class Disease( ) } } - -@Parcelize -@Serializable -@Immutable -data class Countdown( - val value: Int, - val unit: Unit, -) : Parcelable { - init { - require(value >= 0) { "Remaining time must be non-negative" } - } - - enum class Unit( - override val translatableName: StringResource, - val plural: PluralsResource, - ) : NamedEnum { - DAYS(Str.common_ui_units_days, Plurals.duration_days), - HOURS(Str.common_ui_units_hours, Plurals.duration_hours), - MINUTES(Str.common_ui_units_minutes, Plurals.duration_minutes), - } - - @Composable - @Stable - fun toText(): String = stringResource(unit.plural, value, value) -} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/injuries/Injury.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/injuries/Injury.kt new file mode 100644 index 000000000..bbc75cee0 --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/injuries/Injury.kt @@ -0,0 +1,56 @@ +package cz.frantisekmasa.wfrp_master.common.core.domain.injuries + +import com.benasher44.uuid.Uuid +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Injury as CompendiumInjury +import cz.frantisekmasa.wfrp_master.common.core.domain.character.CharacterItem +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Countdown +import cz.frantisekmasa.wfrp_master.common.core.serialization.UuidAsString + +data class Injury( + override val id: UuidAsString, + val name: String, + val duration: Countdown?, + val location: String, + val description: String, + /** + * Used as custom player note for injury where description is taken from compendium. + */ + val note: String, + override val compendiumId: Uuid?, +) : CharacterItem { + + init { + name.requireMaxLength(CompendiumInjury.NAME_MAX_LENGTH, "name") + note.requireMaxLength(NOTE_MAX_LENGTH, "note") + description.requireMaxLength(CompendiumInjury.DESCRIPTION_MAX_LENGTH, "description") + } + + override fun unlinkFromCompendium(): Injury = copy(compendiumId = null) + + override fun updateFromCompendium(compendiumItem: CompendiumInjury): Injury = + copy( + name = compendiumItem.name, + ) + + companion object { + const val NOTE_MAX_LENGTH = 500 + + fun fromCompendium( + compendiumInjury: CompendiumInjury, + duration: Countdown?, + location: String, + note: String, + ): Injury = + Injury( + id = uuid4(), + name = compendiumInjury.name, + duration = duration, + location = location, + note = note, + compendiumId = compendiumInjury.id, + description = compendiumInjury.description, + ) + } +} \ No newline at end of file diff --git a/common/src/jvmTest/kotlin/cz/frantisekmasa/wfrp_master/common/FirestoreTest.kt b/common/src/jvmTest/kotlin/cz/frantisekmasa/wfrp_master/common/FirestoreTest.kt new file mode 100644 index 000000000..fc79eefbe --- /dev/null +++ b/common/src/jvmTest/kotlin/cz/frantisekmasa/wfrp_master/common/FirestoreTest.kt @@ -0,0 +1,47 @@ +package cz.frantisekmasa.wfrp_master.common + +import android.app.Application +import com.google.firebase.FirebasePlatform +import dev.gitlive.firebase.Firebase +import dev.gitlive.firebase.FirebaseOptions +import dev.gitlive.firebase.auth.auth +import dev.gitlive.firebase.firestore.firestore +import dev.gitlive.firebase.initialize +import kotlinx.coroutines.test.runTest +import java.io.File +import kotlin.test.Test + +class FirestoreTest { + @Test + fun test() = runTest { + FirebasePlatform.initializeFirebasePlatform(object : FirebasePlatform() { + val storage = mutableMapOf() + override fun store(key: String, value: String) = storage.set(key, value) + override fun retrieve(key: String) = storage[key] + override fun clear(key: String) { + storage.remove(key) + } + + override fun log(msg: String) = println(msg) + override fun getDatabasePath(name: String) = File("./build/$name") + }) + + val options = FirebaseOptions( + projectId = "my-firebase-project", + applicationId = "1:27992087142:android:ce3b6448250083d1", + apiKey = "AIzaSyADUe90ULnQDuGShD9W23RDP0xmeDc6Mvw", + ) + + val app = Firebase.initialize(Application(), options) + + val auth = Firebase.auth(app).apply { + useEmulator("localhost", 9099) + } + + val firestore = Firebase.firestore(app).apply { + useEmulator("localhost", 8080) + } + + val user = Firebase.auth.signInAnonymously().user ?: error("Could not sign in") + } +} \ No newline at end of file diff --git a/firebase.json b/firebase.json index 1fdfa3be4..0c6bdcf4a 100644 --- a/firebase.json +++ b/firebase.json @@ -4,6 +4,10 @@ "indexes": "firebase/firestore.indexes.json" }, "emulators": { + "auth": { + "host": "0.0.0.0", + "port": 9099 + }, "firestore": { "host": "0.0.0.0", "port": 8080 diff --git a/firebase/firestore.rules b/firebase/firestore.rules index b92ef8d12..deb386190 100644 --- a/firebase/firestore.rules +++ b/firebase/firestore.rules @@ -236,6 +236,29 @@ service cloud.firestore { } } + match /injuries/{injuryId} { + allow read: if hasAccessToParty(); + allow create: if isGameMaster() && isValidInjury(request.resource.data); + allow update: if isGameMaster() && isValidInjury(request.resource.data); + allow delete: if isGameMaster(); + + function isValidInjury(injury) { + return [ + "id", + "name", + "duration", + "possibleLocations", + "description", + "isVisibleToPlayers" + ].hasAll(injury.keys()) + && injury.id is string && injury.id == diseaseId && isValidUuid(injury.id) + && injury.name is string && isNotBlank(injury.name) && injury.name.size() <= 50 + && injury.duration is string && injury.duration.size() <= 500 + && injury.description is string && injury.description.size() <= 1000 + && injury.isVisibleToPlayers is bool; + } + } + match /careers/{careerId} { allow read: if hasAccessToParty(); allow create: if isGameMaster() && isValidCareer(request.resource.data); @@ -541,14 +564,36 @@ service cloud.firestore { ) && disease.note is string && disease.note.size() <= 500; } + } + + match /injuries/{injuryId} { + allow read: if hasAccessToParty(); + allow create: if canEditCharacter() && isValidInjury(request.resource.data); + allow update: if canEditCharacter() && isValidInjury(request.resource.data); + allow delete: if canEditCharacter(); - function isValidCountdown(countdown) { - return countdown.keys().toSet() == ["value", "unit"].toSet() - && countdown.value is int && countdown.value >= 0 - && countdown.unit in ["DAYS", "HOURS", "MINUTES"]; + function isValidInjury(injury) { + return injury.keys().toSet() == ["id", "name", description", "duration", "location", "note", "compendiumId"].toSet() + && injury.id is string && injury.id == diseaseId && isValidUuid(injury.id) + && injury.name is string && isNotBlank(injury.name) && injury.name.size() <= 50 + && injury.description is string && injury.description.size() <= 1000 + && injury.location is string + && isValidCountdown(disease.incubation) + && (injury.duration == null || isValidCountdown(injury.duration)) + && ( + injury.compendiumId == null + || (injury.compendiumId is string && exists(/databases/$(database)/documents/parties/$(partyId)/injuries/$(injury.compendiumId))) + ) + && injury.note is string && injury.note.size() <= 500; } } + function isValidCountdown(countdown) { + return countdown.keys().toSet() == ["value", "unit"].toSet() + && countdown.value is int && countdown.value >= 0 + && countdown.unit in ["DAYS", "HOURS", "MINUTES"]; + } + match /features/{featureName} { allow read: if hasAccessToParty(); allow create: if canEditCharacter() && isValidFeature(request.resource.data); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4b2f9f960..6ebc7f077 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,7 @@ kodein-di-framework-android-core = { module = "org.kodein.di:kodein-di-framework kodein-di-framework-compose = { module = "org.kodein.di:kodein-di-framework-compose", version.ref = "kodein" } korau = { module = "com.soywiz.korlibs.korau:korau", version.ref = "korau" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-rx2 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2", version.ref = "coroutines" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }