From 27878149634438b47acd264bf884bd16b0a7af5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ma=C5=A1a?= Date: Wed, 2 Nov 2022 10:22:55 +0100 Subject: [PATCH 1/4] Replace compendium items when importing from Rulebook --- .../compendium/CompendiumImportScreen.kt | 1 + .../common/compendium/ImportDialog.kt | 46 +++++++++++++++---- .../common/compendium/domain/Blessing.kt | 2 + .../common/compendium/domain/Career.kt | 13 ++++++ .../common/compendium/domain/Miracle.kt | 2 + .../common/compendium/domain/Skill.kt | 2 + .../common/compendium/domain/Spell.kt | 2 + .../common/compendium/domain/Talent.kt | 2 + .../common/compendium/domain/Trait.kt | 2 + .../core/domain/compendium/CompendiumItem.kt | 2 + .../common/localization/Strings.kt | 1 + 11 files changed, 65 insertions(+), 10 deletions(-) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt index a1b54877f..d6193ccfe 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt @@ -110,6 +110,7 @@ class CompendiumImportScreen( miracles.await(), traits.await(), careers.await(), + replaceExistingByDefault = false, ) } }.onFailure { diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt index a04893623..02cef8fba 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt @@ -33,7 +33,6 @@ import cz.frantisekmasa.wfrp_master.common.compendium.domain.Talent import cz.frantisekmasa.wfrp_master.common.compendium.domain.Trait import cz.frantisekmasa.wfrp_master.common.core.domain.compendium.CompendiumItem import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId -import cz.frantisekmasa.wfrp_master.common.core.shared.IO import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.CloseButton import cz.frantisekmasa.wfrp_master.common.core.ui.dialogs.FullScreenDialog import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle @@ -61,7 +60,6 @@ internal fun ImportDialog( is ImportDialogState.PickingItemsToImport -> ImportedItemsPicker( screenModel = screenModel, state = state, - partyId = partyId, onDismissRequest = onDismissRequest, onComplete = onComplete, ) @@ -72,7 +70,6 @@ internal fun ImportDialog( @Composable private fun ImportedItemsPicker( screenModel: CompendiumScreenModel, - partyId: PartyId, state: ImportDialogState.PickingItemsToImport, onDismissRequest: () -> Unit, onComplete: () -> Unit, @@ -90,6 +87,7 @@ private fun ImportedItemsPicker( onContinue = { screen = ItemsScreen.TALENTS }, onClose = onDismissRequest, existingItems = screenModel.skills, + replaceExistingByDefault = state.replaceExistingByDefault, ) } ItemsScreen.TALENTS -> { @@ -100,6 +98,7 @@ private fun ImportedItemsPicker( onContinue = { screen = ItemsScreen.SPELLS }, onClose = onDismissRequest, existingItems = screenModel.talents, + replaceExistingByDefault = state.replaceExistingByDefault, ) } ItemsScreen.SPELLS -> { @@ -110,6 +109,7 @@ private fun ImportedItemsPicker( onContinue = { screen = ItemsScreen.BLESSINGS }, onClose = onDismissRequest, existingItems = screenModel.spells, + replaceExistingByDefault = state.replaceExistingByDefault, ) } ItemsScreen.BLESSINGS -> { @@ -120,6 +120,7 @@ private fun ImportedItemsPicker( onContinue = { screen = ItemsScreen.MIRACLES }, onClose = onDismissRequest, existingItems = screenModel.blessings, + replaceExistingByDefault = state.replaceExistingByDefault, ) } ItemsScreen.MIRACLES -> { @@ -130,6 +131,7 @@ private fun ImportedItemsPicker( onContinue = { screen = ItemsScreen.TRAITS }, onClose = onDismissRequest, existingItems = screenModel.miracles, + replaceExistingByDefault = state.replaceExistingByDefault, ) } ItemsScreen.TRAITS -> { @@ -140,6 +142,7 @@ private fun ImportedItemsPicker( onContinue = { screen = ItemsScreen.CAREERS }, onClose = onDismissRequest, existingItems = screenModel.traits, + replaceExistingByDefault = state.replaceExistingByDefault, ) } ItemsScreen.CAREERS -> { @@ -150,6 +153,7 @@ private fun ImportedItemsPicker( onContinue = onComplete, onClose = onDismissRequest, existingItems = screenModel.careers, + replaceExistingByDefault = state.replaceExistingByDefault, ) } } @@ -163,6 +167,7 @@ private fun > ItemPicker( onContinue: () -> Unit, existingItems: Flow>, items: List, + replaceExistingByDefault: Boolean, ) { val existingItemsList = existingItems.collectWithLifecycle(null).value @@ -179,12 +184,13 @@ private fun > ItemPicker( return } - val existingItemNames = remember(existingItemsList) { - existingItemsList.map { it.name }.toHashSet() + val existingItemsByName = remember(existingItemsList) { + existingItemsList.associateBy { it.name } } - val selectedItems = remember(items, existingItemNames) { - items.map { it.id to !existingItemNames.contains(it.name) }.toMutableStateMap() + val selectedItems = remember(items, existingItemsByName, replaceExistingByDefault) { + items.map { it.id to (replaceExistingByDefault || it.name !in existingItemsByName) } + .toMutableStateMap() } val atLeastOneSelected = selectedItems.containsValue(true) @@ -212,7 +218,20 @@ private fun > ItemPicker( if (atLeastOneSelected) { withContext(Dispatchers.IO) { - onSave(items.filter { selectedItems.contains(it.id) }) + onSave( + items + .asSequence() + .filter { selectedItems.contains(it.id) } + .map { + val existingItem = existingItemsByName[it.name] + + if (existingItem != null) + it.replace(existingItem) + else it + } + .distinctBy { it.id } + .toList() + ) } } @@ -253,8 +272,14 @@ private fun > ItemPicker( onValueChange = { selectedItems[item.id] = it }, ), text = { Text(item.name) }, - secondaryText = if (existingItemNames.contains(item.name)) { - { Text(strings.compendium.messages.itemAlreadyExists) } + secondaryText = if (item.name in existingItemsByName) { + { + Text( + if (selectedItems[item.id] == true) + strings.compendium.messages.willReplaceExistingItem + else strings.compendium.messages.itemAlreadyExists + ) + } } else null ) } @@ -278,6 +303,7 @@ internal sealed class ImportDialogState { val miracles: List, val traits: List, val careers: List, + val replaceExistingByDefault: Boolean, ) : ImportDialogState() } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Blessing.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Blessing.kt index 0b7fde598..636b2e2d0 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Blessing.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Blessing.kt @@ -36,5 +36,7 @@ data class Blessing( effect.requireMaxLength(EFFECT_MAX_LENGTH, "effect") } + override fun replace(original: Blessing) = copy(id = original.id) + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt index ede6e34f1..843e33972 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt @@ -33,6 +33,19 @@ data class Career( require(races.isNotEmpty()) } + override fun replace(original: Career): Career { + val originalLevelsByName = original.levels.associateBy { it.name } + + return copy( + id = original.id, + levels = levels.map { + val originalLevel = originalLevelsByName[it.name] + + if (originalLevel != null) it.copy(id = originalLevel.id) else it + } + ) + } + override fun duplicate(): Career = copy(name = duplicateName()) @Parcelize diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Miracle.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Miracle.kt index 33cc92c21..d12e08cfa 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Miracle.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Miracle.kt @@ -39,5 +39,7 @@ data class Miracle( cultName.requireMaxLength(CULT_NAME_MAX_LENGTH, "cultName") } + override fun replace(original: Miracle) = copy(id = original.id) + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Skill.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Skill.kt index b57b9c347..832f0232c 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Skill.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Skill.kt @@ -31,5 +31,7 @@ data class Skill( description.requireMaxLength(DESCRIPTION_MAX_LENGTH, "description") } + override fun replace(original: Skill) = copy(id = original.id) + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Spell.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Spell.kt index 93fd751c5..a33f27ca2 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Spell.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Spell.kt @@ -41,5 +41,7 @@ data class Spell( require(lore.length <= LORE_MAX_LENGTH) { "LORE must be shorter than $LORE_MAX_LENGTH" } } + override fun replace(original: Spell) = copy(id = original.id) + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Talent.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Talent.kt index 00eee79d0..9138e1d50 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Talent.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Talent.kt @@ -30,5 +30,7 @@ data class Talent( require(maxTimesTaken.length <= MAX_TIMES_TAKEN_MAX_LENGTH) { "Maximum length of is $MAX_TIMES_TAKEN_MAX_LENGTH" } } + override fun replace(original: Talent) = copy(id = original.id) + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt index a3379fd72..fdbfa6b61 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt @@ -27,6 +27,8 @@ data class Trait( require(description.length <= DESCRIPTION_MAX_LENGTH) { "Maximum allowed description length is $DESCRIPTION_MAX_LENGTH" } } + override fun replace(original: Trait) = copy(id = original.id) + override fun duplicate() = copy(id = uuid4(), name = duplicateName()) companion object { diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/compendium/CompendiumItem.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/compendium/CompendiumItem.kt index d2c1ef3fd..8c0a31939 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/compendium/CompendiumItem.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/compendium/CompendiumItem.kt @@ -10,5 +10,7 @@ abstract class CompendiumItem> : Parcelable { abstract fun duplicate(): T + abstract fun replace(original: T): T + protected fun duplicateName(): String = duplicateName(name) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt index 2a685770d..57454ed26 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt @@ -500,6 +500,7 @@ data class CompendiumMessageStrings( val itemAlreadyExists: String = "Item already exists", val noItems: String = "No items in compendium", val noItemsInCompendiumSubtextPlayer: String = "Your GM has to add them first.", + val willReplaceExistingItem: String = "Will replace existing item", ) @Immutable From bfa1a92f221716ec1d77e051a5d35f1b15176fb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ma=C5=A1a?= Date: Wed, 2 Nov 2022 11:16:41 +0100 Subject: [PATCH 2/4] Prevent default replace when importing from Rulebook --- .../frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt index 02cef8fba..3eb848c21 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt @@ -221,7 +221,7 @@ private fun > ItemPicker( onSave( items .asSequence() - .filter { selectedItems.contains(it.id) } + .filter { selectedItems[it.id] == true } .map { val existingItem = existingItemsByName[it.name] From c8dfc13b38fb6b8b2582264adec658cfaa134558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ma=C5=A1a?= Date: Wed, 2 Nov 2022 11:44:16 +0100 Subject: [PATCH 3/4] Make sure that Career level with duplicate names do not exist --- .../compendium/career/CareerDetailScreen.kt | 3 + .../compendium/career/CareerLevelDialog.kt | 69 +++++++++++-------- .../common/compendium/domain/Career.kt | 3 + .../common/localization/Strings.kt | 1 + 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerDetailScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerDetailScreen.kt index 28e49a50e..b886b1dee 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerDetailScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerDetailScreen.kt @@ -123,6 +123,7 @@ class CareerDetailScreen( } val isGameMaster = LocalUser.current.id == party.gameMasterId + val levelNames = remember(career) { career.levels.map { it.name }.toSet() } when (dialogState) { LevelDialogState.Closed -> {} @@ -132,6 +133,7 @@ class CareerDetailScreen( existingLevel = null, onSave = { screenModel.saveLevel(partyId, career.id, it) }, onDismissRequest = { setDialogState(LevelDialogState.Closed) }, + existingLevelNames = levelNames, ) } is LevelDialogState.EditLevel -> { @@ -140,6 +142,7 @@ class CareerDetailScreen( existingLevel = dialogState.level, onSave = { screenModel.saveLevel(partyId, career.id, it) }, onDismissRequest = { setDialogState(LevelDialogState.Closed) }, + existingLevelNames = levelNames - dialogState.level.name, ) } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerLevelDialog.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerLevelDialog.kt index 08d8179d3..076625577 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerLevelDialog.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/career/CareerLevelDialog.kt @@ -17,6 +17,7 @@ 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.InputLabel import cz.frantisekmasa.wfrp_master.common.core.ui.forms.InputValue +import cz.frantisekmasa.wfrp_master.common.core.ui.forms.Rule import cz.frantisekmasa.wfrp_master.common.core.ui.forms.Rules import cz.frantisekmasa.wfrp_master.common.core.ui.forms.SocialStatusInput import cz.frantisekmasa.wfrp_master.common.core.ui.forms.TextInput @@ -27,10 +28,11 @@ import cz.frantisekmasa.wfrp_master.common.localization.LocalStrings fun CareerLevelDialog( title: String, existingLevel: Career.Level?, + existingLevelNames: Set, onSave: suspend (Career.Level) -> Unit, onDismissRequest: () -> Unit, ) { - val data = CareerLevelDialogData.fromCareerLevel(existingLevel) + val data = CareerLevelDialogData.fromCareerLevel(existingLevel, existingLevelNames) FullScreenDialog(onDismissRequest = onDismissRequest) { FormDialog( @@ -137,7 +139,7 @@ data class CareerLevelDialogData( return Career.Level( id = id ?: uuid4(), - name = name.value, + name = name.value.trim(), status = status.value, skills = incomeSkills + nonIncomeSkills, talents = talents.commaSeparatedValues(), @@ -148,33 +150,44 @@ data class CareerLevelDialogData( companion object { @Composable - fun fromCareerLevel(level: Career.Level?) = CareerLevelDialogData( - id = level?.id, - name = inputValue(level?.name ?: "", Rules.NotBlank()), - status = rememberSaveable(level) { - mutableStateOf( - level?.status ?: SocialStatus(SocialStatus.Tier.BRASS, 0) + fun fromCareerLevel(level: Career.Level?, existingLevelNames: Set): CareerLevelDialogData { + val strings = LocalStrings.current.careers.messages + return CareerLevelDialogData( + id = level?.id, + name = inputValue( + level?.name ?: "", + Rules.NotBlank(), + Rule { + if (it.trim() in existingLevelNames) + strings.levelWithNameExists + else null + } + ), + status = rememberSaveable(level) { + mutableStateOf( + level?.status ?: SocialStatus(SocialStatus.Tier.BRASS, 0) + ) + }, + characteristics = rememberSaveable(level) { + mutableStateOf(level?.characteristics ?: emptySet()) + }, + incomeSkills = inputValue( + level?.skills + ?.filter { it.isIncomeSkill } + ?.joinToString(", ") { it.expression } ?: "" + ), + skills = inputValue( + level?.skills + ?.filterNot { it.isIncomeSkill } + ?.joinToString(", ") { it.expression } ?: "" + ), + talents = inputValue( + level?.talents?.joinToString(", ") ?: "" + ), + trappings = inputValue( + level?.trappings?.joinToString(", ") ?: "" ) - }, - characteristics = rememberSaveable(level) { - mutableStateOf(level?.characteristics ?: emptySet()) - }, - incomeSkills = inputValue( - level?.skills - ?.filter { it.isIncomeSkill } - ?.joinToString(", ") { it.expression } ?: "" - ), - skills = inputValue( - level?.skills - ?.filterNot { it.isIncomeSkill } - ?.joinToString(", ") { it.expression } ?: "" - ), - talents = inputValue( - level?.talents?.joinToString(", ") ?: "" - ), - trappings = inputValue( - level?.trappings?.joinToString(", ") ?: "" ) - ) + } } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt index 843e33972..c512c6fc9 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt @@ -31,6 +31,9 @@ data class Career( require(name.isNotBlank() && name.length <= NAME_MAX_LENGTH) require(description.length <= DESCRIPTION_MAX_LENGTH) require(races.isNotEmpty()) + require(levels.map { it.name }.toSet().size == levels.size) { + "Duplicate name for Career level" + } } override fun replace(original: Career): Career { diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt index 57454ed26..7353ef081 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt @@ -274,6 +274,7 @@ data class CareerMessageStrings( val notFound: String = "Career not found", val noLevel: String = "No career levels", val noLevelSubtext: String = "Create at least one level\nto let Characters use this career.", + val levelWithNameExists: String = "Career level with same name already exists", ) @Immutable From 4d432730977acbbb70b31ef7b7d2f2c9454d70eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Franti=C5=A1ek=20Ma=C5=A1a?= Date: Wed, 2 Nov 2022 13:08:23 +0100 Subject: [PATCH 4/4] Add JSON compendium export/import --- .../common/core/shared/FileChooser.kt | 59 ++++++++- .../common/compendium/CompendiumScreen.kt | 27 +++- .../compendium/CompendiumScreenModel.kt | 43 +++++++ .../common/compendium/ImportDialog.kt | 2 +- .../common/compendium/ImportFileChooser.kt | 58 +++++++++ .../compendium/JsonCompendiumExportScreen.kt | 106 ++++++++++++++++ .../compendium/JsonCompendiumImportScreen.kt | 115 ++++++++++++++++++ ...n.kt => RulebookCompendiumImportScreen.kt} | 57 ++------- .../common/compendium/domain/Career.kt | 6 +- .../common/compendium/domain/Trait.kt | 5 +- .../domain/importer/JsonCompendiumImporter.kt | 61 ++++++++++ .../compendium/import/BlessingImport.kt | 45 +++++++ .../common/compendium/import/CareerImport.kt | 101 +++++++++++++++ .../compendium/import/CompendiumBundle.kt | 16 +++ .../common/compendium/import/MiracleImport.kt | 49 ++++++++ .../common/compendium/import/SkillImport.kt | 40 ++++++ .../common/compendium/import/SpellImport.kt | 53 ++++++++ .../common/compendium/import/TalentImport.kt | 36 ++++++ .../common/compendium/import/TraitImport.kt | 42 +++++++ .../common/core/common/functions.kt | 2 +- .../core/domain/ExceptionWithUserMessage.kt | 6 + .../common/core/shared/FileChooser.kt | 23 +++- .../common/localization/Strings.kt | 19 ++- .../common/auth/AuthenticationManager.kt | 2 +- .../common/core/shared/FileChooser.kt | 30 ++++- .../frantisekmasa/wfrp_master/desktop/Main.kt | 3 + .../desktop/interop/NativeFileChooser.kt | 5 +- .../desktop/interop/NativeFileSaver.kt | 37 ++++++ 28 files changed, 975 insertions(+), 73 deletions(-) create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportFileChooser.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumExportScreen.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumImportScreen.kt rename common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/{CompendiumImportScreen.kt => RulebookCompendiumImportScreen.kt} (67%) create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/importer/JsonCompendiumImporter.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/BlessingImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CareerImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CompendiumBundle.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/MiracleImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SkillImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SpellImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TalentImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TraitImport.kt create mode 100644 common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/ExceptionWithUserMessage.kt create mode 100644 desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileSaver.kt diff --git a/common/src/androidMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt b/common/src/androidMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt index 1dcc58df1..b8a145f21 100644 --- a/common/src/androidMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt +++ b/common/src/androidMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt @@ -11,10 +11,11 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.io.InputStream +import java.io.OutputStream @Composable actual fun rememberFileChooser( - onFileChoose: suspend CoroutineScope.(Result) -> Unit + onFileChoose: suspend CoroutineScope.(Result) -> Unit ): FileChooser { val coroutineScope = rememberCoroutineScope() val context = LocalContext.current @@ -31,7 +32,7 @@ actual fun rememberFileChooser( onFileChoose( if (inputStream == null) Result.failure(Exception("Could not open input stream")) - else Result.success(File(inputStream)) + else Result.success(ReadableFile(inputStream)) ) } } @@ -39,12 +40,63 @@ actual fun rememberFileChooser( return AndroidFileChooser(launcher) } -actual class File( +@Composable +actual fun rememberFileSaver( + type: FileType, + defaultFileName: String, + onLocationChoose: suspend CoroutineScope.(Result) -> Unit, +): FileSaver { + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + val contract = ActivityResultContracts.CreateDocument( + when (type) { + FileType.IMAGE -> "image/jpeg" + FileType.PDF -> "application/pdf" + FileType.JSON -> "application/json" + } + ) + + val launcher = rememberLauncherForActivityResult(contract) { uri -> + coroutineScope.launch(Dispatchers.IO) { + if (uri == null) { + onLocationChoose(Result.failure(Exception("URI not selected"))) + return@launch + } + + val outputStream = context.contentResolver.openOutputStream(uri) + + onLocationChoose( + if (outputStream == null) + Result.failure(Exception("Could not open output stream")) + else Result.success(WriteableFile(outputStream)) + ) + } + } + + return FileSaver { + launcher.launch(defaultFileName) + } +} + +actual class ReadableFile( actual val stream: InputStream ) { actual fun readBytes(): ByteArray = stream.readBytes() } +actual class WriteableFile( + private val stream: OutputStream +) { + actual fun writeBytes(bytes: ByteArray) { + stream.write(bytes) + } + + actual fun close() { + stream.close() + } +} + class AndroidFileChooser( private val launcher: ManagedActivityResultLauncher ) : FileChooser { @@ -53,6 +105,7 @@ class AndroidFileChooser( when (type) { FileType.IMAGE -> "image/*" FileType.PDF -> "application/pdf" + FileType.JSON -> "application/json" } ) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreen.kt index eacb5ac75..aeb551bcf 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreen.kt @@ -36,17 +36,17 @@ import cz.frantisekmasa.wfrp_master.common.compendium.tabs.TalentCompendiumTab import cz.frantisekmasa.wfrp_master.common.compendium.tabs.TraitCompendiumTab import cz.frantisekmasa.wfrp_master.common.core.domain.compendium.CompendiumItem import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId -import cz.frantisekmasa.wfrp_master.common.core.shared.IO import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton import cz.frantisekmasa.wfrp_master.common.core.ui.dialogs.DialogState import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle +import cz.frantisekmasa.wfrp_master.common.core.ui.menu.DropdownMenuItem import cz.frantisekmasa.wfrp_master.common.core.ui.menu.WithContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.ContextMenu import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.FullScreenProgress import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel +import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.OptionsAction import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle -import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.TopBarAction import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.tabs.TabPager import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.tabs.tab import cz.frantisekmasa.wfrp_master.common.localization.LocalStrings @@ -93,10 +93,25 @@ class CompendiumScreen( }, navigationIcon = { BackButton() }, actions = { - TopBarAction( - text = strings.buttonImport, - onClick = { navigator.push(CompendiumImportScreen(partyId)) } - ) + OptionsAction { + DropdownMenuItem( + onClick = { navigator.push(RulebookCompendiumImportScreen(partyId)) } + ) { + Text(strings.buttonImportFromRulebook) + } + + DropdownMenuItem( + onClick = { navigator.push(JsonCompendiumImportScreen(partyId)) } + ) { + Text(strings.buttonImportFile) + } + + DropdownMenuItem( + onClick = { navigator.push(JsonCompendiumExportScreen(partyId)) } + ) { + Text(strings.buttonExportFile) + } + } } ) } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreenModel.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreenModel.kt index 568bae6a0..24f279d69 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreenModel.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumScreenModel.kt @@ -8,12 +8,25 @@ import cz.frantisekmasa.wfrp_master.common.compendium.domain.Skill import cz.frantisekmasa.wfrp_master.common.compendium.domain.Spell import cz.frantisekmasa.wfrp_master.common.compendium.domain.Talent import cz.frantisekmasa.wfrp_master.common.compendium.domain.Trait +import cz.frantisekmasa.wfrp_master.common.compendium.import.BlessingImport +import cz.frantisekmasa.wfrp_master.common.compendium.import.CareerImport +import cz.frantisekmasa.wfrp_master.common.compendium.import.CompendiumBundle +import cz.frantisekmasa.wfrp_master.common.compendium.import.MiracleImport +import cz.frantisekmasa.wfrp_master.common.compendium.import.SkillImport +import cz.frantisekmasa.wfrp_master.common.compendium.import.SpellImport +import cz.frantisekmasa.wfrp_master.common.compendium.import.TalentImport +import cz.frantisekmasa.wfrp_master.common.compendium.import.TraitImport import cz.frantisekmasa.wfrp_master.common.core.domain.compendium.Compendium import cz.frantisekmasa.wfrp_master.common.core.domain.party.Party import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyRepository import cz.frantisekmasa.wfrp_master.common.core.utils.right +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializer class CompendiumScreenModel( private val partyId: PartyId, @@ -119,4 +132,34 @@ class CompendiumScreenModel( suspend fun remove(career: Career) { careerCompendium.remove(partyId, career) } + + suspend fun buildExportJson(): String { + return coroutineScope { + val skills = async { skills.first().map(SkillImport::fromSkill) } + val talents = async { talents.first().map(TalentImport::fromTalent) } + val spells = async { spells.first().map(SpellImport::fromSpell) } + val blessings = async { blessings.first().map(BlessingImport::fromBlessing) } + val miracles = async { miracles.first().map(MiracleImport::fromMiracle) } + val traits = async { traits.first().map(TraitImport::fromTrait) } + val careers = async { careers.first().map(CareerImport::fromCareer) } + + val bundle = CompendiumBundle( + skills = skills.await(), + talents = talents.await(), + spells = spells.await(), + blessings = blessings.await(), + miracles = miracles.await(), + traits = traits.await(), + careers = careers.await(), + ) + + json.encodeToString(serializer(), bundle) + } + } + + companion object { + private val json = Json { + encodeDefaults = true + } + } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt index 3eb848c21..9f171250f 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportDialog.kt @@ -290,7 +290,7 @@ private fun > ItemPicker( } @Immutable -internal sealed class ImportDialogState { +sealed class ImportDialogState { @Immutable object LoadingItems : ImportDialogState() diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportFileChooser.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportFileChooser.kt new file mode 100644 index 000000000..c9cccc88c --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/ImportFileChooser.kt @@ -0,0 +1,58 @@ +package cz.frantisekmasa.wfrp_master.common.compendium + +import androidx.compose.material.SnackbarDuration +import androidx.compose.runtime.Composable +import cz.frantisekmasa.wfrp_master.common.compendium.domain.importer.CompendiumImporter +import cz.frantisekmasa.wfrp_master.common.core.shared.FileChooser +import cz.frantisekmasa.wfrp_master.common.core.shared.ReadableFile +import cz.frantisekmasa.wfrp_master.common.core.shared.rememberFileChooser +import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.LocalPersistentSnackbarHolder +import io.github.aakira.napier.Napier +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +@Composable +fun ImportFileChooser( + onStateChange: (ImportDialogState?) -> Unit, + importerFactory: suspend (ReadableFile) -> CompendiumImporter, + errorMessageFactory: (Throwable) -> String, +): FileChooser { + val snackbarHolder = LocalPersistentSnackbarHolder.current + + return rememberFileChooser { result -> + result.mapCatching { file -> + coroutineScope { + onStateChange(ImportDialogState.LoadingItems) + + val importer = importerFactory(file) + + val skills = async { importer.importSkills() } + val talents = async { importer.importTalents() } + val spells = async { importer.importSpells() } + val blessings = async { importer.importBlessings() } + val miracles = async { importer.importMiracles() } + val traits = async { importer.importTraits() } + val careers = async { importer.importCareers() } + + onStateChange( + ImportDialogState.PickingItemsToImport( + skills.await(), + talents.await(), + spells.await(), + blessings.await(), + miracles.await(), + traits.await(), + careers.await(), + replaceExistingByDefault = false, + ) + ) + } + }.onFailure { + Napier.e(it.toString(), it) + + snackbarHolder.showSnackbar(errorMessageFactory(it), SnackbarDuration.Long,) + + onStateChange(null) + } + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumExportScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumExportScreen.kt new file mode 100644 index 000000000..7de120d14 --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumExportScreen.kt @@ -0,0 +1,106 @@ +package cz.frantisekmasa.wfrp_master.common.compendium + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.Scaffold +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import cafe.adriel.voyager.core.screen.Screen +import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId +import cz.frantisekmasa.wfrp_master.common.core.shared.FileType +import cz.frantisekmasa.wfrp_master.common.core.shared.rememberFileSaver +import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton +import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle +import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.FullScreenProgress +import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing +import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel +import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.LocalPersistentSnackbarHolder +import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle +import cz.frantisekmasa.wfrp_master.common.localization.LocalStrings +import io.github.aakira.napier.Napier + +class JsonCompendiumExportScreen( + private val partyId: PartyId, +) : Screen { + @Composable + override fun Content() { + val screenModel: CompendiumScreenModel = rememberScreenModel(arg = partyId) + val party = screenModel.party.collectWithLifecycle(null).value + + if (party == null) { + FullScreenProgress() + return + } + + Scaffold(topBar = { TopBar(party.name) }) { + MainContainer(party.name, screenModel) + } + } + + @Composable + private fun TopBar(partyName: String) { + TopAppBar( + title = { + Column { + Text(LocalStrings.current.compendium.titleExportCompendium) + Subtitle(partyName) + } + }, + navigationIcon = { BackButton() }, + ) + } + + @Composable + private fun MainContainer(partyName: String, screenModel: CompendiumScreenModel) { + val strings = LocalStrings.current.compendium + val snackbarHolder = LocalPersistentSnackbarHolder.current + var exporting by remember { mutableStateOf(false) } + + val fileSaver = rememberFileSaver( + FileType.JSON, + "$partyName-compendium", + ) { result -> + result.mapCatching { file -> + try { + exporting = true + val json = screenModel.buildExportJson() + file.writeBytes(json.toByteArray()) + } finally { + file.close() + exporting = false + } + }.onFailure { + Napier.e(it.toString(), it) + + snackbarHolder.showSnackbar(strings.messages.exportFailed, SnackbarDuration.Long) + + exporting = false + } + } + + if (exporting) { + FullScreenProgress() + return + } + + Box( + modifier = Modifier.fillMaxSize().padding(Spacing.bodyPadding), + contentAlignment = Alignment.Center, + ) { + Button(onClick = { fileSaver.selectLocation() }) { + Text(strings.buttonExport.uppercase()) + } + } + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumImportScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumImportScreen.kt new file mode 100644 index 000000000..1f9480695 --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/JsonCompendiumImportScreen.kt @@ -0,0 +1,115 @@ +package cz.frantisekmasa.wfrp_master.common.compendium + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Button +import androidx.compose.material.ContentAlpha +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.screen.Screen +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import cz.frantisekmasa.wfrp_master.common.compendium.domain.importer.JsonCompendiumImporter +import cz.frantisekmasa.wfrp_master.common.core.domain.ExceptionWithUserMessage +import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId +import cz.frantisekmasa.wfrp_master.common.core.shared.FileType +import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton +import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle +import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing +import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel +import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle +import cz.frantisekmasa.wfrp_master.common.localization.LocalStrings +import java.lang.OutOfMemoryError + +class JsonCompendiumImportScreen( + private val partyId: PartyId, +) : Screen { + @Composable + override fun Content() { + val screenModel: CompendiumScreenModel = rememberScreenModel(arg = partyId) + Scaffold(topBar = { TopBar(screenModel) }) { + MainContainer() + } + } + + @Composable + private fun TopBar(screenModel: CompendiumScreenModel) { + TopAppBar( + title = { + Column { + Text(LocalStrings.current.compendium.titleImportCompendium) + screenModel.party.collectWithLifecycle(null).value?.let { + Subtitle(it.name) + } + } + }, + navigationIcon = { BackButton() }, + ) + } + + @Composable + private fun MainContainer() { + val strings = LocalStrings.current.compendium + + var importState by remember { mutableStateOf(null) } + + importState?.let { + val navigator = LocalNavigator.currentOrThrow + + ImportDialog( + state = it, + partyId = partyId, + onDismissRequest = { importState = null }, + onComplete = navigator::pop, + screenModel = rememberScreenModel(arg = partyId) + ) + } + + val fileChooser = ImportFileChooser( + onStateChange = { importState = it }, + importerFactory = { JsonCompendiumImporter(it.stream) }, + errorMessageFactory = { + when (it) { + is ExceptionWithUserMessage -> it.message ?: strings.messages.jsonImportFailed + is OutOfMemoryError -> strings.messages.outOfMemory + else -> strings.messages.jsonImportFailed + } + } + ) + + Column( + modifier = Modifier.fillMaxSize().padding(Spacing.bodyPadding), + verticalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + strings.jsonImportPrompt, + textAlign = TextAlign.Center, + ) + + Button(onClick = { fileChooser.open(FileType.JSON) }) { + Text(strings.buttonImport.uppercase()) + } + + Text( + strings.assurance, + color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2 + ) + } + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/RulebookCompendiumImportScreen.kt similarity index 67% rename from common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt rename to common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/RulebookCompendiumImportScreen.kt index d6193ccfe..3f7c26d6e 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/CompendiumImportScreen.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/RulebookCompendiumImportScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.ContentAlpha import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Scaffold -import androidx.compose.material.SnackbarDuration import androidx.compose.material.Text import androidx.compose.material.TopAppBar import androidx.compose.runtime.Composable @@ -28,21 +27,16 @@ import cafe.adriel.voyager.navigator.currentOrThrow import cz.frantisekmasa.wfrp_master.common.compendium.domain.importer.RulebookCompendiumImporter import cz.frantisekmasa.wfrp_master.common.core.domain.party.PartyId import cz.frantisekmasa.wfrp_master.common.core.shared.FileType -import cz.frantisekmasa.wfrp_master.common.core.shared.rememberFileChooser import cz.frantisekmasa.wfrp_master.common.core.shared.rememberUrlOpener import cz.frantisekmasa.wfrp_master.common.core.ui.buttons.BackButton import cz.frantisekmasa.wfrp_master.common.core.ui.flow.collectWithLifecycle import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.Spacing import cz.frantisekmasa.wfrp_master.common.core.ui.primitives.rememberScreenModel -import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.LocalPersistentSnackbarHolder import cz.frantisekmasa.wfrp_master.common.core.ui.scaffolding.Subtitle import cz.frantisekmasa.wfrp_master.common.localization.LocalStrings -import io.github.aakira.napier.Napier -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import java.lang.OutOfMemoryError -class CompendiumImportScreen( +class RulebookCompendiumImportScreen( private val partyId: PartyId, ) : Screen { @Composable @@ -86,47 +80,16 @@ class CompendiumImportScreen( ) } - val snackbarHolder = LocalPersistentSnackbarHolder.current - val fileChooser = rememberFileChooser { result -> - result.mapCatching { file -> - coroutineScope { - importState = ImportDialogState.LoadingItems - - val importer = RulebookCompendiumImporter(file.stream) - - val skills = async { importer.importSkills() } - val talents = async { importer.importTalents() } - val spells = async { importer.importSpells() } - val blessings = async { importer.importBlessings() } - val miracles = async { importer.importMiracles() } - val traits = async { importer.importTraits() } - val careers = async { importer.importCareers() } - - importState = ImportDialogState.PickingItemsToImport( - skills.await(), - talents.await(), - spells.await(), - blessings.await(), - miracles.await(), - traits.await(), - careers.await(), - replaceExistingByDefault = false, - ) + val fileChooser = ImportFileChooser( + onStateChange = { importState = it }, + importerFactory = { RulebookCompendiumImporter(it.stream) }, + errorMessageFactory = { + when (it) { + is OutOfMemoryError -> strings.messages.outOfMemory + else -> strings.messages.rulebookImportFailed } - }.onFailure { - Napier.e(it.toString(), it) - - snackbarHolder.showSnackbar( - when (it) { - is OutOfMemoryError -> strings.messages.outOfMemory - else -> strings.messages.importFailed - }, - SnackbarDuration.Long, - ) - - importState = null } - } + ) Column( modifier = Modifier.fillMaxSize().padding(Spacing.bodyPadding), @@ -134,7 +97,7 @@ class CompendiumImportScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - strings.importPrompt, + strings.rulebookImportPrompt, textAlign = TextAlign.Center, ) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt index c512c6fc9..4889d33d3 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Career.kt @@ -76,5 +76,9 @@ data class Career( data class Skill( val expression: String, val isIncomeSkill: Boolean, - ) : Parcelable + ) : Parcelable { + init { + require(expression.isNotBlank()) + } + } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt index fdbfa6b61..edd07dd32 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/Trait.kt @@ -18,11 +18,8 @@ data class Trait( val description: String, ) : CompendiumItem() { init { - if (description.length > DESCRIPTION_MAX_LENGTH) { - println(description) - } require(specifications.all { name.contains(it) }) - require(name.isNotEmpty()) + require(name.isNotBlank()) require(name.length <= NAME_MAX_LENGTH) { "Maximum allowed name length is $NAME_MAX_LENGTH" } require(description.length <= DESCRIPTION_MAX_LENGTH) { "Maximum allowed description length is $DESCRIPTION_MAX_LENGTH" } } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/importer/JsonCompendiumImporter.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/importer/JsonCompendiumImporter.kt new file mode 100644 index 000000000..d768b5e1f --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/domain/importer/JsonCompendiumImporter.kt @@ -0,0 +1,61 @@ +@file:OptIn(ExperimentalSerializationApi::class) + +package cz.frantisekmasa.wfrp_master.common.compendium.domain.importer + +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Blessing +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Career +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Miracle +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Skill +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Spell +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Talent +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Trait +import cz.frantisekmasa.wfrp_master.common.compendium.import.CompendiumBundle +import cz.frantisekmasa.wfrp_master.common.core.domain.ExceptionWithUserMessage +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import java.io.InputStream + +class JsonCompendiumImporter(stream: InputStream) : CompendiumImporter { + private val data: Result = runCatching { + try { + json.decodeFromStream(stream) + } catch (e: IllegalArgumentException) { + throw ExceptionWithUserMessage(e.message ?: "Unknown error", e) + } + } + + override suspend fun importSkills(): List { + return data.getOrThrow().skills.map { it.toSkill() } + } + + override suspend fun importTalents(): List { + return data.getOrThrow().talents.map { it.toTalent() } + } + + override suspend fun importSpells(): List { + return data.getOrThrow().spells.map { it.toSpell() } + } + + override suspend fun importBlessings(): List { + return data.getOrThrow().blessings.map { it.toBlessing() } + } + + override suspend fun importMiracles(): List { + return data.getOrThrow().miracles.map { it.toMiracle() } + } + + override suspend fun importTraits(): List { + return data.getOrThrow().traits.map { it.toTrait() } + } + + override suspend fun importCareers(): List { + return data.getOrThrow().careers.map { it.toCareer() } + } + + companion object { + private val json = Json { + ignoreUnknownKeys = true + } + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/BlessingImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/BlessingImport.kt new file mode 100644 index 000000000..17e864ced --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/BlessingImport.kt @@ -0,0 +1,45 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Blessing +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class BlessingImport( + val name: String, + val range: String, + val target: String, + val duration: String, + val effect: String, +) { + init { + require(name.isNotBlank()) { "Blessing name cannot be blank" } + name.requireMaxLength(Blessing.NAME_MAX_LENGTH, "blessing name") + range.requireMaxLength(Blessing.RANGE_MAX_LENGTH, "blessing range") + target.requireMaxLength(Blessing.TARGET_MAX_LENGTH, "blessing target") + duration.requireMaxLength(Blessing.DURATION_MAX_LENGTH, "blessing duration") + effect.requireMaxLength(Blessing.EFFECT_MAX_LENGTH, "blessing effect") + } + + fun toBlessing() = Blessing( + id = uuid4(), + name = name, + range = range, + target = target, + duration = duration, + effect = effect, + ) + + companion object { + fun fromBlessing(blessing: Blessing) = BlessingImport( + name = blessing.name, + range = blessing.range, + target = blessing.target, + duration = blessing.duration, + effect = blessing.effect, + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CareerImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CareerImport.kt new file mode 100644 index 000000000..fdaa45c9b --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CareerImport.kt @@ -0,0 +1,101 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Career +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import cz.frantisekmasa.wfrp_master.common.core.domain.Characteristic +import cz.frantisekmasa.wfrp_master.common.core.domain.SocialClass +import cz.frantisekmasa.wfrp_master.common.core.domain.character.Race +import cz.frantisekmasa.wfrp_master.common.core.domain.character.SocialStatus +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class CareerImport( + val name: String, + val description: String, + val socialClass: SocialClass, + val races: Set, + val levels: List, +) { + init { + require(name.isNotBlank()) { "Career name cannot be blank" } + name.requireMaxLength(Career.NAME_MAX_LENGTH, "career name") + description.requireMaxLength(Career.DESCRIPTION_MAX_LENGTH, "career description") + require(races.isNotEmpty()) { "At least one race required for career \"$name\"" } + require(levels.map { it.name }.toSet().size == levels.size) { + "Duplicate name for career level of career \"$name\"" + } + } + + fun toCareer() = Career( + id = uuid4(), + name = name, + description = description, + socialClass = socialClass, + races = races, + levels = levels.map { + Career.Level( + id = uuid4(), + name = it.name, + status = it.status, + characteristics = it.characteristics, + skills = it.skills.map { skill -> + Career.Skill( + skill.expression, + skill.isIncomeSkill + ) + }, + talents = it.talents, + trappings = it.trappings, + ) + } + ) + + @Serializable + @Immutable + data class Level( + val name: String, + val status: SocialStatus, + val characteristics: Set, + val skills: List, + val talents: List, + val trappings: List, + ) + + @Serializable + @Immutable + data class Skill( + val expression: String, + val isIncomeSkill: Boolean, + ) { + init { + require(expression.isNotBlank()) { "Career skill expression cannot be blank" } + } + } + + companion object { + fun fromCareer(career: Career) = CareerImport( + name = career.name, + description = career.description, + socialClass = career.socialClass, + races = career.races, + levels = career.levels.map { + Level( + name = it.name, + status = it.status, + characteristics = it.characteristics, + skills = it.skills.map { skill -> + Skill( + skill.expression, + skill.isIncomeSkill + ) + }, + talents = it.talents, + trappings = it.trappings, + ) + } + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CompendiumBundle.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CompendiumBundle.kt new file mode 100644 index 000000000..0f616536b --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/CompendiumBundle.kt @@ -0,0 +1,16 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class CompendiumBundle( + val skills: List = emptyList(), + val talents: List = emptyList(), + val spells: List = emptyList(), + val miracles: List = emptyList(), + val blessings: List = emptyList(), + val traits: List = emptyList(), + val careers: List = emptyList(), +) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/MiracleImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/MiracleImport.kt new file mode 100644 index 000000000..793d21d4e --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/MiracleImport.kt @@ -0,0 +1,49 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Miracle +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class MiracleImport( + val name: String, + val range: String, + val target: String, + val duration: String, + val effect: String, + val cultName: String, +) { + init { + require(name.isNotBlank()) { "Miracle name cannot be blank" } + name.requireMaxLength(Miracle.NAME_MAX_LENGTH, "miracle name") + range.requireMaxLength(Miracle.RANGE_MAX_LENGTH, "miracle range") + target.requireMaxLength(Miracle.TARGET_MAX_LENGTH, "miracle target") + duration.requireMaxLength(Miracle.DURATION_MAX_LENGTH, "miracle duration") + effect.requireMaxLength(Miracle.EFFECT_MAX_LENGTH, "miracle effect") + cultName.requireMaxLength(Miracle.CULT_NAME_MAX_LENGTH, "miracle cultName") + } + + fun toMiracle() = Miracle( + id = uuid4(), + name = name, + range = range, + target = target, + duration = duration, + effect = effect, + cultName = cultName, + ) + + companion object { + fun fromMiracle(miracle: Miracle) = MiracleImport( + name = miracle.name, + range = miracle.range, + target = miracle.target, + duration = miracle.duration, + effect = miracle.effect, + cultName = miracle.cultName, + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SkillImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SkillImport.kt new file mode 100644 index 000000000..a8a7cdffa --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SkillImport.kt @@ -0,0 +1,40 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Skill +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import cz.frantisekmasa.wfrp_master.common.core.domain.Characteristic +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class SkillImport( + val name: String, + val description: String, + val characteristic: Characteristic, + val advanced: Boolean, +) { + init { + require(name.isNotBlank()) { "Skill name cannot be blank" } + name.requireMaxLength(Skill.NAME_MAX_LENGTH, "skill name") + description.requireMaxLength(Skill.DESCRIPTION_MAX_LENGTH, "talent description") + } + + fun toSkill() = Skill( + id = uuid4(), + name = name, + description = description, + characteristic = characteristic, + advanced = advanced, + ) + + companion object { + fun fromSkill(skill: Skill) = SkillImport( + name = skill.name, + description = skill.description, + characteristic = skill.characteristic, + advanced = skill.advanced, + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SpellImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SpellImport.kt new file mode 100644 index 000000000..2054576ab --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/SpellImport.kt @@ -0,0 +1,53 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Spell +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class SpellImport( + val name: String, + val range: String, + val target: String, + val duration: String, + val castingNumber: Int, + val effect: String, + val lore: String, +) { + init { + require(name.isNotBlank()) { "Spell name cannot be blank" } + name.requireMaxLength(Spell.NAME_MAX_LENGTH, "spell name") + require(castingNumber >= 0) { "Casting number cannot be negative for spell \"$name\"" } + range.requireMaxLength(Spell.RANGE_MAX_LENGTH, "spell range") + target.requireMaxLength(Spell.TARGET_MAX_LENGTH, "spell target") + duration.requireMaxLength(Spell.DURATION_MAX_LENGTH, "spell duration") + effect.requireMaxLength(Spell.EFFECT_MAX_LENGTH, "spell effect") + lore.requireMaxLength(Spell.LORE_MAX_LENGTH, "spell lore") + } + + fun toSpell() = Spell( + id = uuid4(), + name = name, + range = range, + target = target, + duration = duration, + castingNumber = castingNumber, + effect = effect, + lore = lore, + ) + + companion object { + fun fromSpell(spell: Spell) = SpellImport( + name = spell.name, + range = spell.range, + target = spell.target, + duration = spell.duration, + castingNumber = spell.castingNumber, + effect = spell.effect, + lore = spell.lore, + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TalentImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TalentImport.kt new file mode 100644 index 000000000..f118fe14f --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TalentImport.kt @@ -0,0 +1,36 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Talent +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class TalentImport( + val name: String, + val maxTimesTaken: String, + val description: String, +) { + init { + require(name.isNotBlank()) { "Talent name cannot be blank" } + name.requireMaxLength(Talent.NAME_MAX_LENGTH, "talent name") + description.requireMaxLength(Talent.DESCRIPTION_MAX_LENGTH, "talent description") + } + + fun toTalent() = Talent( + id = uuid4(), + name = name, + maxTimesTaken = maxTimesTaken, + description = description, + ) + + companion object { + fun fromTalent(talent: Talent) = TalentImport( + name = talent.name, + maxTimesTaken = talent.maxTimesTaken, + description = talent.description, + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TraitImport.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TraitImport.kt new file mode 100644 index 000000000..41f105376 --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/compendium/import/TraitImport.kt @@ -0,0 +1,42 @@ +package cz.frantisekmasa.wfrp_master.common.compendium.import + +import androidx.compose.runtime.Immutable +import com.benasher44.uuid.uuid4 +import cz.frantisekmasa.wfrp_master.common.compendium.domain.Trait +import cz.frantisekmasa.wfrp_master.common.core.common.requireMaxLength +import kotlinx.serialization.Serializable + +@Serializable +@Immutable +data class TraitImport( + val name: String, + val specifications: Set, + val description: String, +) { + init { + require(name.isNotBlank()) { "Trait name cannot be blank" } + name.requireMaxLength(Trait.NAME_MAX_LENGTH, "trait name") + specifications.forEach { + require(it in name) { + "Trait name \"$name\" does not contain placeholder for specification \"$it\"" + } + } + require(specifications.all { name.contains(it) }) + description.requireMaxLength(Trait.DESCRIPTION_MAX_LENGTH, "trait description") + } + + fun toTrait() = Trait( + id = uuid4(), + name = name, + specifications = specifications, + description = description, + ) + + companion object { + fun fromTrait(trait: Trait) = TraitImport( + name = trait.name, + specifications = trait.specifications, + description = trait.description, + ) + } +} diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/common/functions.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/common/functions.kt index 7fc628ef4..2dafdf870 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/common/functions.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/common/functions.kt @@ -2,5 +2,5 @@ package cz.frantisekmasa.wfrp_master.common.core.common fun String.requireMaxLength(maxLength: Int, valueName: String) = require(length <= maxLength) { - "Maximum allowed length of \"$valueName\" is $maxLength, \"$this\" given" + "Maximum allowed length of \"$valueName\" is $maxLength characters, \"$this\" given" } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/ExceptionWithUserMessage.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/ExceptionWithUserMessage.kt new file mode 100644 index 000000000..beff6ada9 --- /dev/null +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/domain/ExceptionWithUserMessage.kt @@ -0,0 +1,6 @@ +package cz.frantisekmasa.wfrp_master.common.core.domain + +class ExceptionWithUserMessage( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt index 7c1304a5f..03c4f1762 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt @@ -6,20 +6,37 @@ import java.io.InputStream @Composable expect fun rememberFileChooser( - onFileChoose: suspend CoroutineScope.(Result) -> Unit + onFileChoose: suspend CoroutineScope.(Result) -> Unit ): FileChooser -expect class File { +@Composable +expect fun rememberFileSaver( + type: FileType, + defaultFileName: String, + onLocationChoose: suspend CoroutineScope.(Result) -> Unit, +): FileSaver + +expect class ReadableFile { val stream: InputStream fun readBytes(): ByteArray } +expect class WriteableFile { + fun writeBytes(bytes: ByteArray) + fun close() +} + interface FileChooser { fun open(type: FileType) } +fun interface FileSaver { + fun selectLocation() +} + enum class FileType { IMAGE, - PDF + PDF, + JSON } diff --git a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt index 7353ef081..87f6d8c62 100644 --- a/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt +++ b/common/src/commonMain/kotlin/cz/frantisekmasa/wfrp_master/common/localization/Strings.kt @@ -465,14 +465,26 @@ data class CompendiumStrings( val searchPlaceholder: String = "Search items", val buttonBuy: String = "Buy", val buttonImport: String = "Import", + val buttonImportFromRulebook: String = "Import from Rulebook", + val buttonImportFile: String = "Import file", val buttonImportRulebook: String = "Import rulebook PDF", + val buttonExport: String = "Save Export", + val buttonExportFile: String = "Export file", val iconAddCompendiumItem: String = "Add compendium item", - val importPrompt: AnnotatedString = buildAnnotatedString { + val rulebookImportPrompt: AnnotatedString = buildAnnotatedString { append("Import compendium from official WFRP Rulebook PDF.") withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { append("Only the latest version of English Rulebook is supported.") } }, + val jsonImportPrompt: AnnotatedString = buildAnnotatedString { + append("Import compendium previously exported from WFRP Master.\n") + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append("The export format is not set in stone yet!\n") + } + append("so while exports from the same version of WFRP Master will work,") + append("exports from previous versions may not work properly.") + }, val messages: CompendiumMessageStrings = CompendiumMessageStrings(), val pickPromptCareers: String = "Select which careers you want to import.", val pickPromptSkills: String = "Select which skills you want to import.", @@ -491,13 +503,16 @@ data class CompendiumStrings( val tabTraits: String = "Traits", val title: String = "Compendium", val titleImportCompendium: String = "Import Compendium", + val titleExportCompendium: String = "Export Compendium", val titleImportDialog: String = "Importing Rulebook…", ) @Immutable data class CompendiumMessageStrings( + val exportFailed: String = "JSON export failed.", val outOfMemory: String = "PDF import failed. Not enough available RAM on device.", - val importFailed: String = "PDF import failed. Check that you provided valid rulebook PDF.", + val rulebookImportFailed: String = "PDF import failed. Check that you provided valid rulebook PDF.", + val jsonImportFailed: String = "JSON import failed. Check that you provided valid WFRP Master export.", val itemAlreadyExists: String = "Item already exists", val noItems: String = "No items in compendium", val noItemsInCompendiumSubtextPlayer: String = "Your GM has to add them first.", diff --git a/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/auth/AuthenticationManager.kt b/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/auth/AuthenticationManager.kt index 9a7e881fb..ef51684e8 100644 --- a/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/auth/AuthenticationManager.kt +++ b/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/auth/AuthenticationManager.kt @@ -60,7 +60,7 @@ class AuthenticationManager( @SerialName("refresh_token") val refreshToken: String, @SerialName("grant_type") - val grantType: String = "refresh_token", + val grantType: String, ) @Serializable diff --git a/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt b/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt index 54b06e607..f590a8da5 100644 --- a/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt +++ b/common/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/common/core/shared/FileChooser.kt @@ -3,21 +3,47 @@ package cz.frantisekmasa.wfrp_master.common.core.shared import androidx.compose.runtime.Composable import androidx.compose.runtime.staticCompositionLocalOf import kotlinx.coroutines.CoroutineScope +import java.io.File import java.io.InputStream -typealias FileChooseListener = suspend CoroutineScope.(Result) -> Unit +typealias FileChooseListener = suspend CoroutineScope.(Result) -> Unit +typealias FileLocationListener = suspend CoroutineScope.(Result) -> Unit val LocalFileChooserFactory = staticCompositionLocalOf<(FileChooseListener) -> FileChooser> { error("LocalFileChooser was not set") } +val LocalFileSaverFactory = staticCompositionLocalOf<(FileLocationListener) -> FileSaver> { + error("LocalFileChooser was not set") +} + @Composable actual fun rememberFileChooser(onFileChoose: FileChooseListener): FileChooser { return LocalFileChooserFactory.current(onFileChoose) } -actual class File( +@Composable +actual fun rememberFileSaver( + type: FileType, + defaultFileName: String, + onLocationChoose: FileLocationListener, +): FileSaver { + return LocalFileSaverFactory.current(onLocationChoose) +} + +actual class ReadableFile( actual val stream: InputStream ) { actual fun readBytes(): ByteArray = stream.readBytes() } + +actual class WriteableFile( + private val file: File, +) { + actual fun writeBytes(bytes: ByteArray) { + file.writeBytes(bytes) + } + + actual fun close() { + } +} diff --git a/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/Main.kt b/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/Main.kt index 064f8eed8..6476a3637 100644 --- a/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/Main.kt +++ b/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/Main.kt @@ -15,6 +15,7 @@ import cz.frantisekmasa.wfrp_master.common.core.config.Platform import cz.frantisekmasa.wfrp_master.common.core.config.StaticConfiguration import cz.frantisekmasa.wfrp_master.common.core.shared.LocalEmailInitiator import cz.frantisekmasa.wfrp_master.common.core.shared.LocalFileChooserFactory +import cz.frantisekmasa.wfrp_master.common.core.shared.LocalFileSaverFactory import cz.frantisekmasa.wfrp_master.common.core.shared.LocalUrlOpener import cz.frantisekmasa.wfrp_master.common.core.ui.responsive.ScreenWithBreakpoints import cz.frantisekmasa.wfrp_master.common.core.ui.theme.Theme @@ -23,6 +24,7 @@ import cz.frantisekmasa.wfrp_master.common.shell.DrawerShell import cz.frantisekmasa.wfrp_master.desktop.interop.DesktopEmailInitiator import cz.frantisekmasa.wfrp_master.desktop.interop.DesktopUrlOpener import cz.frantisekmasa.wfrp_master.desktop.interop.NativeFileChooser +import cz.frantisekmasa.wfrp_master.desktop.interop.NativeFileSaver import kotlinx.coroutines.launch import org.kodein.di.compose.withDI @@ -36,6 +38,7 @@ fun main() { LocalUrlOpener provides DesktopUrlOpener, LocalEmailInitiator provides DesktopEmailInitiator, LocalFileChooserFactory provides { NativeFileChooser(coroutineScope, it) }, + LocalFileSaverFactory provides { NativeFileSaver(coroutineScope, it) }, LocalStaticConfiguration provides StaticConfiguration( isProduction = true, version = "dev", diff --git a/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileChooser.kt b/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileChooser.kt index bce726c3c..a00d466ce 100644 --- a/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileChooser.kt +++ b/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileChooser.kt @@ -1,9 +1,9 @@ package cz.frantisekmasa.wfrp_master.desktop.interop -import cz.frantisekmasa.wfrp_master.common.core.shared.File import cz.frantisekmasa.wfrp_master.common.core.shared.FileChooseListener import cz.frantisekmasa.wfrp_master.common.core.shared.FileChooser import cz.frantisekmasa.wfrp_master.common.core.shared.FileType +import cz.frantisekmasa.wfrp_master.common.core.shared.ReadableFile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.awt.FileDialog @@ -27,7 +27,7 @@ class NativeFileChooser( onFileChoose( when (file) { null -> Result.failure(Exception("File not selected")) - else -> Result.success(File(file.inputStream())) + else -> Result.success(ReadableFile(file.inputStream())) } ) } @@ -36,5 +36,6 @@ class NativeFileChooser( private fun allowedExtensions(fileType: FileType): List = when (fileType) { FileType.PDF -> listOf(".pdf") FileType.IMAGE -> listOf(".jpg", ".jpeg", ".png", ".gif") + FileType.JSON -> listOf(".json") } } diff --git a/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileSaver.kt b/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileSaver.kt new file mode 100644 index 000000000..2d089fa63 --- /dev/null +++ b/desktop/src/jvmMain/kotlin/cz/frantisekmasa/wfrp_master/desktop/interop/NativeFileSaver.kt @@ -0,0 +1,37 @@ +package cz.frantisekmasa.wfrp_master.desktop.interop + +import cz.frantisekmasa.wfrp_master.common.core.shared.FileLocationListener +import cz.frantisekmasa.wfrp_master.common.core.shared.FileSaver +import cz.frantisekmasa.wfrp_master.common.core.shared.WriteableFile +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.awt.FileDialog +import java.io.File +import javax.swing.JFrame + +class NativeFileSaver( + private val coroutineScope: CoroutineScope, + private val onFileChoose: FileLocationListener, +) : FileSaver { + override fun selectLocation() { + val dialog = FileDialog(JFrame("Save file"), "Save file", FileDialog.SAVE).apply { + isVisible = true + } + + val directory = dialog.directory + val fileName = dialog.file + + val file = if (directory.isNullOrBlank() || fileName.isNullOrBlank()) + null + else File("$directory/$fileName") + + coroutineScope.launch { + onFileChoose( + when (file) { + null -> Result.failure(Exception("File not selected")) + else -> Result.success(WriteableFile(file)) + } + ) + } + } +}