Fixes the xcode build and adds stubs for missing classes.
This commit is contained in:
@@ -3,11 +3,13 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 58;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
E20209D325D8DD9600EFE733 /* Skill+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */; };
|
||||
E20209D425D8DD9600EFE734 /* Collection+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209D425D8DD9600EFE736 /* Collection+CoreDataClass.swift */; };
|
||||
E20209D525D8DD9600EFE735 /* CollectionMonster+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209D525D8DD9600EFE737 /* CollectionMonster+CoreDataClass.swift */; };
|
||||
E20209E825D8DEC100EFE733 /* AbilityScore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209E725D8DEC100EFE733 /* AbilityScore.swift */; };
|
||||
E20209F425D8E04300EFE733 /* ProficiencyType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209F225D8E04300EFE733 /* ProficiencyType.swift */; };
|
||||
E20209F525D8E04300EFE733 /* AdvantageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209F325D8E04300EFE733 /* AdvantageType.swift */; };
|
||||
@@ -100,6 +102,11 @@
|
||||
E2CB0DE1260887ED00142591 /* StringViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CB0DE0260887ED00142591 /* StringViewModel.swift */; };
|
||||
E2CB0DE626088CE400142591 /* EditStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CB0DE526088CE400142591 /* EditStrings.swift */; };
|
||||
E2D473FD25B532C900CB36D7 /* Color+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D473FC25B532C900CB36D7 /* Color+Hex.swift */; };
|
||||
E2FE0021261FE0000055B23B /* EntityImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0001261FE0000083EAC5 /* EntityImporter.swift */; };
|
||||
E2FE0022261FE0000055B23B /* BinderImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0002261FE0000083EAC5 /* BinderImporter.swift */; };
|
||||
E2FE0023261FE0000055B23B /* BinderExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0003261FE0000083EAC5 /* BinderExporter.swift */; };
|
||||
E2FE0024261FE0000055B23B /* BinderImportModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0004261FE0000083EAC5 /* BinderImportModels.swift */; };
|
||||
E2FE0025261FE0000055B23B /* TetraCubeMonsterImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FE0005261FE0000083EAC5 /* TetraCubeMonsterImporter.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -142,6 +149,8 @@
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Skill+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
E20209D425D8DD9600EFE736 /* Collection+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
E20209D525D8DD9600EFE737 /* CollectionMonster+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionMonster+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
E20209E725D8DEC100EFE733 /* AbilityScore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbilityScore.swift; sourceTree = "<group>"; };
|
||||
E20209F225D8E04300EFE733 /* ProficiencyType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProficiencyType.swift; sourceTree = "<group>"; };
|
||||
E20209F325D8E04300EFE733 /* AdvantageType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdvantageType.swift; sourceTree = "<group>"; };
|
||||
@@ -217,6 +226,11 @@
|
||||
E2CB0DE0260887ED00142591 /* StringViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringViewModel.swift; sourceTree = "<group>"; };
|
||||
E2CB0DE526088CE400142591 /* EditStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditStrings.swift; sourceTree = "<group>"; };
|
||||
E2D473FC25B532C900CB36D7 /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = "<group>"; };
|
||||
E2FE0001261FE0000083EAC5 /* EntityImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityImporter.swift; sourceTree = "<group>"; };
|
||||
E2FE0002261FE0000083EAC5 /* BinderImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinderImporter.swift; sourceTree = "<group>"; };
|
||||
E2FE0003261FE0000083EAC5 /* BinderExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinderExporter.swift; sourceTree = "<group>"; };
|
||||
E2FE0004261FE0000083EAC5 /* BinderImportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinderImportModels.swift; sourceTree = "<group>"; };
|
||||
E2FE0005261FE0000083EAC5 /* TetraCubeMonsterImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetraCubeMonsterImporter.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -316,6 +330,7 @@
|
||||
E20CF008261FF37700B55D72 /* MonsterCards.entitlements */,
|
||||
E2570FBC25B1AC550055B23B /* Assets.xcassets */,
|
||||
E2D473FB25B5328800CB36D7 /* Helpers */,
|
||||
E2FE0000261FE0000083EAC5 /* ImportExport */,
|
||||
E2570FC625B1AC550055B23B /* Info.plist */,
|
||||
E257101225B1B2790055B23B /* Models */,
|
||||
E2570FC325B1AC550055B23B /* MonsterCards.xcdatamodeld */,
|
||||
@@ -406,6 +421,8 @@
|
||||
E216E46C261FDE5600FD9262 /* MonsterViewModel+CoreData.swift */,
|
||||
E219247F261989F700C84E12 /* SavingThrowDTO.swift */,
|
||||
E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */,
|
||||
E20209D425D8DD9600EFE736 /* Collection+CoreDataClass.swift */,
|
||||
E20209D525D8DD9600EFE737 /* CollectionMonster+CoreDataClass.swift */,
|
||||
E219248426198A1200C84E12 /* SkillDTO.swift */,
|
||||
E20209F925D8E19100EFE733 /* SkillViewModel.swift */,
|
||||
E216E471261FDF3200FD9262 /* SkillViewModel+CoreData.swift */,
|
||||
@@ -425,6 +442,18 @@
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2FE0000261FE0000083EAC5 /* ImportExport */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2FE0001261FE0000083EAC5 /* EntityImporter.swift */,
|
||||
E2FE0002261FE0000083EAC5 /* BinderImporter.swift */,
|
||||
E2FE0003261FE0000083EAC5 /* BinderExporter.swift */,
|
||||
E2FE0004261FE0000083EAC5 /* BinderImportModels.swift */,
|
||||
E2FE0005261FE0000083EAC5 /* TetraCubeMonsterImporter.swift */,
|
||||
);
|
||||
path = ImportExport;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -650,6 +679,11 @@
|
||||
E2570FF525B1ADEB0055B23B /* Dashboard.swift in Sources */,
|
||||
E2CB0DB826081A2F00142591 /* MCAbilityScorePicker.swift in Sources */,
|
||||
E219249926198E0D00C84E12 /* MonsterImportHelper.swift in Sources */,
|
||||
E2FE0021261FE0000055B23B /* EntityImporter.swift in Sources */,
|
||||
E2FE0022261FE0000055B23B /* BinderImporter.swift in Sources */,
|
||||
E2FE0023261FE0000055B23B /* BinderExporter.swift in Sources */,
|
||||
E2FE0024261FE0000055B23B /* BinderImportModels.swift in Sources */,
|
||||
E2FE0025261FE0000055B23B /* TetraCubeMonsterImporter.swift in Sources */,
|
||||
E2CB0DC026086E3C00142591 /* ChallengeRating.swift in Sources */,
|
||||
E257100425B1AF4A0055B23B /* SearchBar.swift in Sources */,
|
||||
E20209F525D8E04300EFE733 /* AdvantageType.swift in Sources */,
|
||||
@@ -675,6 +709,8 @@
|
||||
E2192480261989F700C84E12 /* SavingThrowDTO.swift in Sources */,
|
||||
E216B7B7260C5A9800FB205F /* ChallengeRatingViewModel.swift in Sources */,
|
||||
E20209D325D8DD9600EFE733 /* Skill+CoreDataClass.swift in Sources */,
|
||||
E20209D425D8DD9600EFE734 /* Collection+CoreDataClass.swift in Sources */,
|
||||
E20209D525D8DD9600EFE735 /* CollectionMonster+CoreDataClass.swift in Sources */,
|
||||
E24ACE652607F55D009BF703 /* EditSavingThrows.swift in Sources */,
|
||||
E2BD702C25B3A8D70058ED69 /* MCTextField.swift in Sources */,
|
||||
E216B7BC260C691400FB205F /* EditChallengeRating.swift in Sources */,
|
||||
@@ -738,6 +774,7 @@
|
||||
E216E48A261FE76F00FD9262 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = J793L9LQJ2;
|
||||
@@ -760,6 +797,7 @@
|
||||
E216E48B261FE76F00FD9262 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = J793L9LQJ2;
|
||||
@@ -782,6 +820,7 @@
|
||||
E2570FDD25B1AC550055B23B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
@@ -830,7 +869,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -843,6 +882,7 @@
|
||||
E2570FDE25B1AC550055B23B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
@@ -885,7 +925,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -898,6 +938,7 @@
|
||||
E2570FE025B1AC550055B23B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
@@ -924,6 +965,7 @@
|
||||
E2570FE125B1AC550055B23B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
@@ -950,12 +992,13 @@
|
||||
E2570FE325B1AC550055B23B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = J793L9LQJ2;
|
||||
INFOPLIST_FILE = MonsterCardsTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -972,12 +1015,13 @@
|
||||
E2570FE425B1AC550055B23B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = J793L9LQJ2;
|
||||
INFOPLIST_FILE = MonsterCardsTests/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -994,6 +1038,7 @@
|
||||
E2570FE625B1AC550055B23B /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = J793L9LQJ2;
|
||||
@@ -1014,6 +1059,7 @@
|
||||
E2570FE725B1AC550055B23B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
SWIFT_VERSION = 6.0;
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_TEAM = J793L9LQJ2;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"originHash" : "427e2f78970f33b38feca26c7986bebb5f56cf55a388a29b6bade958aaa6607e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "markdownui",
|
||||
@@ -23,10 +24,10 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "924936d0427cb25a61169739a7660230bffa6ea6",
|
||||
"version" : "0.8.0"
|
||||
"revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a",
|
||||
"version" : "0.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
||||
@@ -109,26 +109,3 @@ class MonsterImportHelper {
|
||||
return monster
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generic JSON import (auto-detects schema via EntityImporter)
|
||||
|
||||
extension MonsterImportHelper {
|
||||
|
||||
/// Attempts to parse raw JSON into a ``MonsterViewModel`` by trying all registered
|
||||
/// ``EntityImporter`` implementations in turn. Returns `nil` if no importer recognises the input.
|
||||
static func fromJSON(_ json: String) -> MonsterViewModel? {
|
||||
// Try each EntityImporter until one says it can handle this format.
|
||||
let importers: [EntityImporter.Type] = [TetraCubeMonsterImporter.self, BinderImporter.self]
|
||||
|
||||
for importer in importers where importer.canImport(json) {
|
||||
do {
|
||||
return try importer.parse(json)
|
||||
} catch {
|
||||
print("⚠️ EntityImporter \(importer) threw while parsing: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
// BinderExporter.swift
|
||||
// MonsterCards
|
||||
//
|
||||
// Port of com.majinnaibu.monstercards.exporters.BinderExporter.java
|
||||
// Serialises MonsterViewModel → Binder platform JSON (pretty-printed, Gson-compatible).
|
||||
// Serialises MonsterViewModel -> Binder platform JSON.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
// MARK: - Binder export DTOs
|
||||
|
||||
@@ -19,17 +19,16 @@ struct BinderExport {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let cards: [MonsterExport]
|
||||
let cards: [[String: Any]]
|
||||
|
||||
init(id: String, name: String, description: String, cards: [MonsterExport]) {
|
||||
init(id: String, name: String, description: String, cards: [[String: Any]]) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
// Preserve empty list explicitly (Android uses new ArrayList<>())
|
||||
self.cards = cards.count > 0 ? cards : []
|
||||
}
|
||||
|
||||
static func named(_ name: String, cards: [MonsterExport]) -> CollectionExport {
|
||||
static func named(_ name: String, cards: [[String: Any]]) -> CollectionExport {
|
||||
CollectionExport(id: UUID().uuidString, name: name, description: "", cards: cards)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +36,7 @@ struct BinderExport {
|
||||
|
||||
struct MonsterExport {
|
||||
/// Build a JSON-serialisable dictionary from a `MonsterViewModel`.
|
||||
@MainActor
|
||||
static func makeDictionary(monster: MonsterViewModel, idOverride: UUID? = nil) -> [String: Any] {
|
||||
var d: [String: Any] = [
|
||||
"$schema": "https://majinnaibu.com/schemas/monster-card.schema.json",
|
||||
@@ -44,13 +44,13 @@ struct MonsterExport {
|
||||
|
||||
// Core identity
|
||||
"id": idOverride?.uuidString ?? UUID().uuidString,
|
||||
"name": monster.name ?? "",
|
||||
"size": monster.size ?? "",
|
||||
"type": monster.type ?? "",
|
||||
"subtype": monster.subType ?? "",
|
||||
"alignment": monster.alignment ?? "",
|
||||
"name": monster.name,
|
||||
"size": monster.size,
|
||||
"type": monster.type,
|
||||
"subtype": monster.subType,
|
||||
"alignment": monster.alignment,
|
||||
|
||||
// Ability scores — keys match Android Monster.java Gson output literally.
|
||||
// Ability scores
|
||||
"strengthScore": monster.strengthScore,
|
||||
"dexterityScore": monster.dexterityScore,
|
||||
"constitutionScore": monster.constitutionScore,
|
||||
@@ -58,7 +58,7 @@ struct MonsterExport {
|
||||
"wisdomScore": monster.wisdomScore,
|
||||
"charismaScore": monster.charismaScore,
|
||||
|
||||
// Saving throw proficiencies & advantages (raw string from Android enum.toString()).
|
||||
// Saving throw proficiencies & advantages
|
||||
"strengthSavingThrowProficiency": monster.strengthSavingThrowProficiency.rawValue,
|
||||
"strengthSavingThrowAdvantage": monster.strengthSavingThrowAdvantage.rawValue,
|
||||
"dexteritySavingThrowProficiency": monster.dexteritySavingThrowProficiency.rawValue,
|
||||
@@ -76,12 +76,12 @@ struct MonsterExport {
|
||||
"armorType": monster.armorType.rawValue,
|
||||
"shieldBonus": Int32(monster.shieldBonus),
|
||||
"naturalArmorBonus": monster.naturalArmorBonus,
|
||||
"otherArmorDescription": monster.otherArmorDescription ?? "",
|
||||
"otherArmorDescription": monster.customArmor,
|
||||
|
||||
// Hit points
|
||||
"hitDice": monster.hitDice,
|
||||
"hasCustomHP": monster.hasCustomHP,
|
||||
"customHitPointsDescription": monster.customHP ?? "",
|
||||
"customHitPointsDescription": monster.customHP,
|
||||
|
||||
// Speeds
|
||||
"walkSpeed": monster.walkSpeed,
|
||||
@@ -91,47 +91,44 @@ struct MonsterExport {
|
||||
"canHover": monster.canHover,
|
||||
"swimSpeed": monster.swimSpeed,
|
||||
"hasCustomSpeed": monster.hasCustomSpeed,
|
||||
"customSpeedDescription": monster.customSpeed ?? "",
|
||||
"customSpeedDescription": monster.customSpeed,
|
||||
|
||||
// Challenge rating
|
||||
"challengeRating": monster.challengeRating.rawValue,
|
||||
"customChallengeRatingDescription": monster.customChallengeRating ?? "",
|
||||
"customChallengeRatingDescription": monster.customChallengeRating,
|
||||
"customProficiencyBonus": monster.customProficiencyBonus,
|
||||
|
||||
// Telepathy & understanding
|
||||
"telepathyRange": monster.telepathy,
|
||||
"understandsButDescription": monster.understandsBut ?? "",
|
||||
"understandsButDescription": monster.understandsBut,
|
||||
|
||||
// Lists (strings) — keys match Android Monster.java @ColumnInfo / Gson names.
|
||||
"senses": monster.senses.map { ($0.name ?? "") },
|
||||
"damageImmunities": monster.damageImmunities.map { ($0.name ?? "") },
|
||||
"damageResistances": monster.damageResistances.map { ($0.name ?? "") },
|
||||
"damageVulnerabilities": monster.damageVulnerabilities.map { ($0.name ?? "") },
|
||||
"conditionImmunities": monster.conditionImmunities.map { ($0.name ?? "") },
|
||||
// Lists
|
||||
"senses": monster.senses.map { $0.name },
|
||||
"damageImmunities": monster.damageImmunities.map { $0.name },
|
||||
"damageResistances": monster.damageResistances.map { $0.name },
|
||||
"damageVulnerabilities": monster.damageVulnerabilities.map { $0.name },
|
||||
"conditionImmunities": monster.conditionImmunities.map { $0.name },
|
||||
|
||||
// Skills — each is {"name": ..., "stat": "...", "proficiency": "..."}
|
||||
// Skills
|
||||
"skills": monster.skills.map { s -> [String: Any] in
|
||||
return ["name": s.name ?? "", "stat": s.stat.rawValue, "proficiency": s.proficiency.rawValue]
|
||||
return ["name": s.name, "stat": s.abilityScore.rawValue, "proficiency": s.proficiency.rawValue]
|
||||
},
|
||||
|
||||
// Languages — each is {"name": ..., "speaks": Bool}
|
||||
// Languages
|
||||
"languages": monster.languages.map { l -> [String: Any] in
|
||||
return ["name": l.name ?? "", "speaks": l.speaks]
|
||||
return ["name": l.name, "speaks": l.speaks]
|
||||
},
|
||||
|
||||
// Combat lists — each is {"name": ..., "desc": ...}
|
||||
"abilities": monster.abilities.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"actions": monster.actions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"reactions": monster.reactions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"legendaryActions": monster.legendaryActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"lairActions": monster.lairActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"regionalActions": monster.regionalActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
"bonusActions": monster.bonusActions.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] },
|
||||
// Combat lists
|
||||
"abilities": monster.abilities.map { ["name": $0.name, "desc": $0.description] },
|
||||
"actions": monster.actions.map { ["name": $0.name, "desc": $0.description] },
|
||||
"reactions": monster.reactions.map { ["name": $0.name, "desc": $0.description] },
|
||||
"legendaryActions": monster.legendaryActions.map { ["name": $0.name, "desc": $0.description] },
|
||||
"lairActions": monster.lairActions.map { ["name": $0.name, "desc": $0.description] },
|
||||
"regionalActions": monster.regionalActions.map { ["name": $0.name, "desc": $0.description] },
|
||||
|
||||
// Mythic actions — Android Monster model has this; iOS lacks it.
|
||||
"mythicActions": [] as [[String: Any]],
|
||||
|
||||
// Personality / physical description — Android-only fields on Monster.java.
|
||||
"playerName": "",
|
||||
"background": "",
|
||||
"personalityTraits": "",
|
||||
@@ -153,20 +150,16 @@ struct MonsterExport {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - BinderExporter (mirrors public API of com.majinnaibu.monstercards.exporters.BinderExporter)
|
||||
// MARK: - BinderExporter
|
||||
|
||||
struct BinderExporter {
|
||||
|
||||
// MARK: -- Utility — pretty-print JSON [String:Any] → string (Gson-compatible format)
|
||||
|
||||
private static func toJsonString(_ obj: Any) -> String? {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) else { return nil }
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
// MARK: -- Public API matching Android exactly
|
||||
|
||||
/// `exportBinder(String collectionName, List<Monster> monsters)`
|
||||
@MainActor
|
||||
static func exportBinder(collectionName: String, monsters: [MonsterViewModel], idOverride: UUID? = nil) -> String {
|
||||
let col = BinderExport.CollectionExport(
|
||||
id: UUID().uuidString,
|
||||
@@ -189,10 +182,9 @@ struct BinderExporter {
|
||||
return toJsonString(root) ?? "{}"
|
||||
}
|
||||
|
||||
/// `exportBinder(Collection collection, List<Monster> monsters)`
|
||||
@MainActor
|
||||
static func exportBinder(collection: NSManagedObject?, monsters: [MonsterViewModel]) -> String {
|
||||
guard let colObj = collection else {
|
||||
// Empty-collection fallback (same path as Android with null).
|
||||
return exportBinder(collectionName: "", monsters: monsters)
|
||||
}
|
||||
|
||||
@@ -221,14 +213,14 @@ struct BinderExporter {
|
||||
return toJsonString(root) ?? "{}"
|
||||
}
|
||||
|
||||
/// `exportFullBackup(List<CollectionExport> collections, List<Monster> dashboardMonsters)`
|
||||
@MainActor
|
||||
static func exportFullBackup(collections: [BinderExport.CollectionExport], dashboardMonsters: [MonsterViewModel]?) -> String {
|
||||
let colDicts = collections.map { col -> [String: Any] in
|
||||
return [
|
||||
"id": col.id,
|
||||
"name": col.name,
|
||||
"description": col.description,
|
||||
"cards": col.cards.map { $0 } // already a [String: Any] from makeDictionary
|
||||
"cards": col.cards.map { $0 }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -239,7 +231,6 @@ struct BinderExporter {
|
||||
]
|
||||
|
||||
guard let dashboard = dashboardMonsters, !dashboard.isEmpty else {
|
||||
// Android Gson only includes the field when it's populated.
|
||||
return toJsonString(base) ?? "{}"
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,14 @@
|
||||
// MonsterCards
|
||||
//
|
||||
// Data models for parsing Binder (TRPG platform) JSON format.
|
||||
// Companion to Android BinderExporter / Monster classes.
|
||||
// Field names here must match the keys produced by Binder's Gson serializer,
|
||||
// which serializes the Android `Monster` POJO.
|
||||
// Field names match the keys produced by Binder's serializer.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Top-level Binder export file containing collections of monster cards.
|
||||
struct BinderRoot: Decodable {
|
||||
let schemaVersion: Int?
|
||||
@Environment(\.codingVersion) var codingScheme
|
||||
let collections: [CollectionModel]
|
||||
}
|
||||
|
||||
@@ -27,9 +25,7 @@ struct MonsterCard: Decodable {
|
||||
|
||||
// MARK: - Basic info
|
||||
|
||||
typealias CodingKeys = MonsterCardCodingKey
|
||||
|
||||
enum MonsterCardCodingKey: String, CodingKey {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, displayName, displayType, alignment, sizeAbbrv
|
||||
case shieldName, baseAcFormula, baseAcVal, shieldAcNum, extraArmorAcMod
|
||||
case hitDice, customHpText, speedText
|
||||
@@ -39,7 +35,6 @@ struct MonsterCard: Decodable {
|
||||
case specialdamage, languageLine, sensesDescription
|
||||
case crDisplay, proficiencyBonusValue
|
||||
case cardTraits, legendaryActionsWithHeader
|
||||
case attributes
|
||||
}
|
||||
|
||||
let name: String?
|
||||
@@ -65,7 +60,7 @@ struct MonsterCard: Decodable {
|
||||
/// Additional armor modifier (e.g. "+4", "-1").
|
||||
let extraArmorAcMod: String?
|
||||
|
||||
/// Hit dice (e.g. "38d10"). When nil, uses hitDice from attributes[0].
|
||||
/// Hit dice (e.g. "38d10").
|
||||
let hitDice: String?
|
||||
|
||||
/// Custom HP text when the monster has custom HP.
|
||||
@@ -124,11 +119,6 @@ struct MonsterCard: Decodable {
|
||||
/// Proficiency bonus numeric value.
|
||||
let proficiencyBonusValue: Int?
|
||||
|
||||
/// Display armor class line (e.g. "AC 16; no armor, +4 shield (+2 AC), base AC 10").
|
||||
let acLine: String?
|
||||
|
||||
// MARK: - Traits from card
|
||||
|
||||
/// Card trait entries (abilities/actions) with section headers and grouping flags.
|
||||
let cardTraits: [CardTraitEntry]?
|
||||
|
||||
@@ -146,7 +136,6 @@ struct CardAttributeBox: Decodable {
|
||||
/// A proficiency-based skill entry (e.g. Perception).
|
||||
struct ProfSkillBox: Decodable {
|
||||
let name: String?
|
||||
@Environment(\.codingVersion) var codingScheme
|
||||
let isExpertise: Int? // 0 or 1 stored as int; nil means false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
@@ -157,7 +146,6 @@ struct ProfSkillBox: Decodable {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
|
||||
// "expertise" field may be 0 or 1 as Int. Also store it in isExpertise.
|
||||
if let expertiseValue = (try? container.decode(Int.self, forKey: .expertise)), expertiseValue == 1 {
|
||||
self.isExpertise = 1
|
||||
} else {
|
||||
@@ -177,6 +165,6 @@ struct SpecialDamageBox: Decodable {
|
||||
struct CardTraitEntry: Decodable {
|
||||
let displayName: String? // action/ability name
|
||||
let desc: String? // ability text
|
||||
let isActionHeader: Int? // 0 or 1 flag for section headers (e.g. "Spellcasting", "Legendary Actions")
|
||||
let isActionHeader: Int? // 0 or 1 flag for section headers
|
||||
let actionSection: Int? // grouping key (0=bonus actions, 1=actions, 2=reactions, etc.)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// MonsterCards
|
||||
//
|
||||
// Detects and parses Binder (TRPG platform) JSON format into a MonsterViewModel.
|
||||
// Companion to Android BinderImporter.java — shares the same detection logic (schema or collections key).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -20,14 +19,14 @@ struct BinderImporter: EntityImporter {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for $schema reference to binder.schema.json (Android BinderImporter.java line ~32)
|
||||
// Check for $schema reference to binder.schema.json
|
||||
if let schema = root["$schema"] as? String, schema.contains("binder.schema.json") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Fall back: check known Binder fields from collections array (matches Android logic)
|
||||
if root["collections"] is [Any] {
|
||||
return !CollectionsCheck.isEmpty(try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]])
|
||||
// Fall back: check known Binder fields from collections array
|
||||
if let collections = root["collections"] as? [Any] {
|
||||
return !collections.isEmpty
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -35,6 +34,7 @@ struct BinderImporter: EntityImporter {
|
||||
|
||||
// MARK: - parse
|
||||
|
||||
@MainActor
|
||||
static func parse(_ raw: String) throws -> MonsterViewModel {
|
||||
guard let data = raw.data(using: .utf8) else {
|
||||
throw BinderParseError.unsupportedEncoding
|
||||
@@ -57,59 +57,57 @@ struct BinderImporter: EntityImporter {
|
||||
|
||||
// MARK: - Mapping
|
||||
|
||||
/// Maps a `MonsterCard` (Binder inner type or top-level export entry) to MonsterViewModel.
|
||||
/// Maps a `MonsterCard` to MonsterViewModel.
|
||||
@MainActor
|
||||
private static func mapMonster(_ card: MonsterCard) -> MonsterViewModel {
|
||||
let monster = MonsterViewModel()
|
||||
|
||||
// MARK: Basic info (Android: name, displayType, sizeAbbrv, alignment)
|
||||
// MARK: Basic info
|
||||
monster.name = card.displayName ?? card.name ?? ""
|
||||
monster.type = card.displayType ?? ""
|
||||
// sizeAbbrv maps like Android: "T" → tiny, "S" → small, etc.
|
||||
monster.size = abrvToSize(card.sizeAbbrv)
|
||||
monster.subType = "" // Binder does not have a separate subtype field
|
||||
monster.subType = ""
|
||||
monster.alignment = card.alignment ?? ""
|
||||
|
||||
// MARK: AC & armor (from Android Monster.java fields: shieldName, baseAcFormula, etc.)
|
||||
// MARK: AC & armor
|
||||
monster.hasShield = (card.shieldAcNum ?? 0) != 0 || ((card.baseAcFormula?.contains("shield")) ?? false)
|
||||
monster.shieldBonus = card.shieldAcNum ?? (monster.hasShield ? 2 : 0)
|
||||
|
||||
if card.baseAcFormula != nil || (card.shieldName != nil && !card.shieldName!.isEmpty) {
|
||||
monster.armorType = .other // unarmored-defense / custom approach
|
||||
monster.otherArmorDescription = card.shieldName ?? "no shield"
|
||||
monster.armorType = .other
|
||||
monster.customArmor = card.shieldName ?? "no shield"
|
||||
} else {
|
||||
monster.armorType = .none
|
||||
}
|
||||
|
||||
if let armorModStr = card.extraArmorAcMod, let modVal = Int(armorModStr) {
|
||||
// For "no armor" style, extraArmorAcMod is added directly to the AC formula text.
|
||||
let suffix = (card.otherArmorDescription.isEmpty ? "" : ", ") + "(+\(modVal))"
|
||||
monster.otherArmorDescription += suffix
|
||||
let suffix = (monster.customArmor.isEmpty ? "" : ", ") + "(+\(modVal))"
|
||||
monster.customArmor += suffix
|
||||
}
|
||||
|
||||
// MARK: HP & hit dice (Android: hitDice → String like "38d10"; customHpText for override)
|
||||
if card.customHpText != nil && !card.customHpText!.isEmpty {
|
||||
// MARK: HP & hit dice
|
||||
if let customHpText = card.customHpText, !customHpText.isEmpty {
|
||||
monster.hasCustomHP = true
|
||||
monster.customHP = card.customHpText!
|
||||
monster.customHP = customHpText
|
||||
} else {
|
||||
// Parse hit dice string like "38d10" → hitDice = 38
|
||||
if let hdStr = card.hitDice, let parts = splitHitDice(hdStr) {
|
||||
monster.hitDice = Int64(parts.diceCount ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Speed (Android MonsterImportHelper.speedFromString)
|
||||
if let stxt = card.speedText where !stxt.isEmpty && !stxt.contains("feet") {
|
||||
// MARK: Speed
|
||||
if let stxt = card.speedText, !stxt.isEmpty, !stxt.contains("feet") {
|
||||
monster.customSpeed = stxt
|
||||
monster.hasCustomSpeed = true
|
||||
} else if card.speedText != nil {
|
||||
monster.walkSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "walk"))
|
||||
monster.burrowSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "burrow"))
|
||||
monster.climbSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "climb"))
|
||||
monster.flySpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "fly"))
|
||||
monster.swimSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "swim"))
|
||||
} else if let speedText = card.speedText {
|
||||
monster.walkSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "walk"))
|
||||
monster.burrowSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "burrow"))
|
||||
monster.climbSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "climb"))
|
||||
monster.flySpeed = Int64(extractSpeedMeters(speedText, forSpeed: "fly"))
|
||||
monster.swimSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "swim"))
|
||||
}
|
||||
|
||||
// MARK: Ability scores (Android Monster.primaryAttributes[0…5] in [STR, DEX, CON, INT, WIS, CHA])
|
||||
// MARK: Ability scores
|
||||
if let attrs = card.primaryAttributes, attrs.count >= 6 {
|
||||
monster.strengthScore = Int64(attrs[0].value ?? 10)
|
||||
monster.dexterityScore = Int64(attrs[1].value ?? 10)
|
||||
@@ -119,7 +117,7 @@ struct BinderImporter: EntityImporter {
|
||||
monster.charismaScore = Int64(attrs[5].value ?? 10)
|
||||
}
|
||||
|
||||
// MARK: Saving throws proficiency (Android Monster.proSavingThrows)
|
||||
// MARK: Saving throws proficiency
|
||||
for throwName in (card.proSavingThrows ?? []) {
|
||||
switch throwName.lowercased() {
|
||||
case "str" : monster.strengthSavingThrowProficiency = .proficient
|
||||
@@ -132,14 +130,13 @@ struct BinderImporter: EntityImporter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Skills (Android Monster.proficiencySkills)
|
||||
// MARK: Skills
|
||||
for skillEntry in (card.proficiencySkills ?? []) {
|
||||
guard let sname = skillEntry.name else { continue }
|
||||
let prof: ProficiencyType = skillEntry.isExpertise == 1 ? .expertise : .proficient
|
||||
if let ability = SkillViewModel.knownSkillForName(sname) {
|
||||
monster.skills.append(SkillViewModel(sname, ability, prof))
|
||||
} else {
|
||||
// Fallback
|
||||
monster.skills.append(SkillViewModel(sname, .dexterity, prof))
|
||||
}
|
||||
}
|
||||
@@ -151,24 +148,21 @@ struct BinderImporter: EntityImporter {
|
||||
if let dv = card.damageVulnerabilities {
|
||||
monster.damageVulnerabilities = parseDelimitedString(dv)
|
||||
}
|
||||
|
||||
// Condition immunities
|
||||
if let ci = card.conditionImmunities {
|
||||
monster.conditionImmunities = parseDelimitedString(ci)
|
||||
}
|
||||
|
||||
// MARK: Languages (Android MonsterImportHelper.parseLanguageLine)
|
||||
// MARK: Languages
|
||||
if let langLine = card.languageLine {
|
||||
monster.languages = parseLanguages(langLine)
|
||||
}
|
||||
|
||||
// MARK: Senses (Android sensesDescription → split into sense entries)
|
||||
// MARK: Senses
|
||||
if let sensesStr = card.sensesDescription, !sensesStr.isEmpty {
|
||||
// Each item is typically "<senseName> <distance> ft." or just a name.
|
||||
monster.senses = parseDelimitedString(sensesStr)
|
||||
}
|
||||
|
||||
// MARK: Challenge rating (Android Monster.crDisplay)
|
||||
// MARK: Challenge rating
|
||||
let crText = card.crDisplay ?? "0"
|
||||
if let cr = challengeRating(for: crText) {
|
||||
monster.challengeRating = cr
|
||||
@@ -181,7 +175,7 @@ struct BinderImporter: EntityImporter {
|
||||
monster.customProficiencyBonus = Int64(pbVal)
|
||||
}
|
||||
|
||||
// MARK: Traits / actions (from Android Monster.cardTraits grouped by actionSection)
|
||||
// MARK: Traits / actions
|
||||
processCardTraits(card, into: monster)
|
||||
|
||||
return monster
|
||||
@@ -192,7 +186,6 @@ struct BinderImporter: EntityImporter {
|
||||
|
||||
private extension BinderImporter {
|
||||
|
||||
/// Convert a size abbreviation to the full size enum name. (Android uses the same mapping.)
|
||||
static func abrvToSize(_ abbrv: String?) -> String {
|
||||
guard let a = abbrv else { return "" }
|
||||
switch a.uppercased() {
|
||||
@@ -206,7 +199,6 @@ private extension BinderImporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse "38d10" → (diceCount: 38, dieSize: 10). Android uses this format.
|
||||
static func splitHitDice(_ hdStr: String) -> (diceCount: Int?, dieSize: Int)? {
|
||||
let parts = hdStr.split(separator: "d")
|
||||
guard parts.count == 2 else { return nil }
|
||||
@@ -215,7 +207,6 @@ private extension BinderImporter {
|
||||
return (count, size)
|
||||
}
|
||||
|
||||
/// Extract a specific movement speed type from formatted string like "40 feet, climb 20 ft."
|
||||
static func extractSpeedMeters(_ text: String?, forSpeed type: String) -> Int {
|
||||
guard let t = text else { return 0 }
|
||||
let pattern = "\\b\(type)\\b"
|
||||
@@ -223,19 +214,13 @@ private extension BinderImporter {
|
||||
let range = NSRange(t.startIndex..., in: t)
|
||||
if let match = regex.firstMatch(in: t, options: [], range: range),
|
||||
let numRange = Range(match.range(at: 1), in: t) {
|
||||
var extractedText = ""
|
||||
#if swift(>=5.0)
|
||||
extractedText = String(t[numRange])
|
||||
#else
|
||||
extractedText = (t as NSString).substring(with: numRange)
|
||||
#endif
|
||||
let extractedText = String(t[numRange])
|
||||
if let val = Int(extractedText) { return val }
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/// Parse comma-separated string into [StringViewModel].
|
||||
static func parseDelimitedString(_ input: String) -> [StringViewModel] {
|
||||
return input.components(separatedBy: ",").compactMap {
|
||||
let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -243,7 +228,6 @@ private extension BinderImporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a language line like "Common, Deep Speech (150 ft.)" into [LanguageViewModel].
|
||||
static func parseLanguages(_ line: String) -> [LanguageViewModel] {
|
||||
var result: [LanguageViewModel] = []
|
||||
let components = line.components(separatedBy: ",")
|
||||
@@ -254,12 +238,10 @@ private extension BinderImporter {
|
||||
|
||||
var hasSpeaks = true
|
||||
|
||||
// Check for understands-only patterns
|
||||
if trimmed.lowercased().contains("understand") || trimmed.lowercased().contains("(silently)") {
|
||||
hasSpeaks = false
|
||||
}
|
||||
|
||||
// Remove trailing range annotation like " (150 ft.)" → keep just the name
|
||||
let parts = trimmed.split(separator: " (")
|
||||
var nameOnly = trimmed
|
||||
if parts.count >= 2 {
|
||||
@@ -272,11 +254,9 @@ private extension BinderImporter {
|
||||
return result
|
||||
}
|
||||
|
||||
/// Determine ChallengeRating from text (Android Monster.crDisplay → challengeRating conversion).
|
||||
static func challengeRating(for text: String?) -> ChallengeRating? {
|
||||
guard let crText = text else { return .zero }
|
||||
|
||||
// Handle common text aliases
|
||||
switch crText.lowercased() {
|
||||
case "half": return .oneHalf
|
||||
case "quarter", "one quarter": return .oneQuarter
|
||||
@@ -285,12 +265,10 @@ private extension BinderImporter {
|
||||
default: break
|
||||
}
|
||||
|
||||
// Try numeric parse first (e.g. "0" → zero, "3" → three)
|
||||
if let val = Int(crText), let cr = ChallengeRating(rawValue: String(val)) {
|
||||
return cr
|
||||
}
|
||||
|
||||
// Handle fractions like "1/2", "5/2"
|
||||
if let rangeIdx = crText.range(of: "/") {
|
||||
let numStr = crText[..<rangeIdx.lowerBound]
|
||||
let denStr = crText[rangeIdx.upperBound...]
|
||||
@@ -309,7 +287,7 @@ private extension BinderImporter {
|
||||
return .zero
|
||||
}
|
||||
|
||||
/// Process cardTraits from Android Monster.exportCards (grouped by actionSection).
|
||||
@MainActor
|
||||
static func processCardTraits(_ card: MonsterCard, into monster: MonsterViewModel) {
|
||||
guard let traits = card.cardTraits else { return }
|
||||
|
||||
@@ -317,24 +295,21 @@ private extension BinderImporter {
|
||||
guard let displayName = trait.displayName, !displayName.isEmpty else { continue }
|
||||
|
||||
if let isHeader = trait.isActionHeader, isHeader == 1 {
|
||||
// Section header like "Spellcasting", "Legendary Actions" etc.
|
||||
if displayName.lowercased().contains("legendary") {
|
||||
monster.legendaryActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
} else if displayName.lowercased().contains("lair") || displayName.lowercased().contains("mythic") {
|
||||
monster.lairActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
} else {
|
||||
// Action section header gets added to actions as a category label.
|
||||
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
}
|
||||
} else if let section = trait.actionSection {
|
||||
switch section {
|
||||
case 0: monster.bonusActions .append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
case 0: monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
case 1: monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
case 2: monster.reactions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
default: break
|
||||
}
|
||||
} else {
|
||||
// No section key — add to actions by default (standard Android behavior)
|
||||
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,31 +2,33 @@
|
||||
// EntityImporter.swift
|
||||
// MonsterCards
|
||||
//
|
||||
// Imported from Android commit 044202c — refactors monster importers to a common interface.
|
||||
// Created by Tom Hicks on 4/3/21.
|
||||
// Last Modified by Tom Hicks on 9/23/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Protocol that all entity importers implement, matching the Java `EntityImporter<T>` interface.
|
||||
/// Protocol that all entity importers implement.
|
||||
/// Each importer decides whether it can handle the input and then parses it into domain objects.
|
||||
protocol EntityImporter {
|
||||
/// Determines whether this importer can handle the given input string or payload.
|
||||
func canImport(_ input: String) -> Bool
|
||||
static func canImport(_ input: String) -> Bool
|
||||
|
||||
/// Parses the raw input string/JSON into a `MonsterViewModel`.
|
||||
func parse(_ input: String) throws -> MonsterViewModel
|
||||
@MainActor
|
||||
static func parse(_ input: String) throws -> MonsterViewModel
|
||||
}
|
||||
|
||||
/// Registry type that dispatches parsing to the correct importer based on format sniffing,
|
||||
/// mirroring the Android `MonsterImportHelper` dispatcher logic.
|
||||
/// Registry type that dispatches parsing to the correct importer based on format sniffing.
|
||||
enum ImporterRegistry {
|
||||
|
||||
private static let importers: [EntityImporter] = [
|
||||
MonsterJsonImporter(),
|
||||
DnDBeyondImporter(),
|
||||
Open5eImporter(),
|
||||
BinderImporter(),
|
||||
@MainActor
|
||||
private static var importers: [any EntityImporter.Type] {
|
||||
[
|
||||
TetraCubeMonsterImporter.self,
|
||||
BinderImporter.self,
|
||||
]
|
||||
}
|
||||
|
||||
/// Detects which importer handles the input and delegates parse to it.
|
||||
@MainActor
|
||||
@@ -40,7 +42,7 @@ enum ImporterRegistry {
|
||||
let monster = try importer.parse(input)
|
||||
return monster
|
||||
} catch {
|
||||
print("Importer \(type(of: importer)) error: \(error)")
|
||||
print("Importer \(importer) error: \(error)")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
53
iOS/MonsterCards/ImportExport/TetraCubeMonsterImporter.swift
Normal file
53
iOS/MonsterCards/ImportExport/TetraCubeMonsterImporter.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// TetraCubeMonsterImporter.swift
|
||||
// MonsterCards
|
||||
//
|
||||
// Created by Tom Hicks on 9/23/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Importer for TetraCube monster generator JSON format.
|
||||
struct TetraCubeMonsterImporter: EntityImporter {
|
||||
|
||||
// MARK: - canImport
|
||||
|
||||
static func canImport(_ input: String) -> Bool {
|
||||
guard let data = input.data(using: .utf8),
|
||||
let root = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Detect TetraCube specific keys
|
||||
return root["name"] != nil && root["strPoints"] != nil
|
||||
}
|
||||
|
||||
// MARK: - parse
|
||||
|
||||
@MainActor
|
||||
static func parse(_ input: String) throws -> MonsterViewModel {
|
||||
guard let data = input.data(using: .utf8) else {
|
||||
throw TetraCubeParseError.unsupportedEncoding
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let monsterDTO = try decoder.decode(MonsterDTO.self, from: data)
|
||||
return MonsterImportHelper.import5ESBMonster(monsterDTO)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Type
|
||||
|
||||
enum TetraCubeParseError: LocalizedError {
|
||||
case unsupportedEncoding
|
||||
case invalidFormat(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedEncoding:
|
||||
return "TetraCube import requires UTF-8 JSON"
|
||||
case .invalidFormat(let msg):
|
||||
return "Invalid TetraCube format: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
|
||||
public class AbilityViewModel: NSObject, ObservableObject, Identifiable, NSSecureCoding {
|
||||
public static var supportsSecureCoding = true
|
||||
public static let supportsSecureCoding = true
|
||||
|
||||
public func encode(with coder: NSCoder) {
|
||||
coder.encode(self.name, forKey: "name")
|
||||
|
||||
15
iOS/MonsterCards/Models/Collection+CoreDataClass.swift
Normal file
15
iOS/MonsterCards/Models/Collection+CoreDataClass.swift
Normal file
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Collection+CoreDataClass.swift
|
||||
// MonsterCards
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Collection)
|
||||
public class Collection: NSManagedObject {
|
||||
// TODO: Add convenience initializer convenience init(context: NSManagedObjectContext, name: String, details: String)
|
||||
// TODO: Add helper method to add a monster to this collection (creating CollectionMonster join entity)
|
||||
// TODO: Add helper method to remove a monster from this collection
|
||||
// TODO: Add sortedMonsters property to return monsters ordered by ordinal
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//
|
||||
// CollectionMonster+CoreDataClass.swift
|
||||
// MonsterCards
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(CollectionMonster)
|
||||
public class CollectionMonster: NSManagedObject {
|
||||
// TODO: Add convenience initializer convenience init(context: NSManagedObjectContext, collection: Collection, monsterId: String, ordinal: Int64)
|
||||
// TODO: Add helper property or fetch method to resolve monsterId to a Monster entity
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
|
||||
// TODO: split this into separate Model and ViewModel classes later.
|
||||
public class LanguageViewModel : NSObject, ObservableObject, Comparable, Identifiable, NSSecureCoding {
|
||||
public static var supportsSecureCoding = true
|
||||
public static let supportsSecureCoding = true
|
||||
|
||||
public func encode(with coder: NSCoder) {
|
||||
coder.encode(self.name, forKey: "name")
|
||||
|
||||
53
iOS/MonsterCards/Models/SearchResultItem.swift
Normal file
53
iOS/MonsterCards/Models/SearchResultItem.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// SearchResultItem.swift
|
||||
// MonsterCards
|
||||
//
|
||||
// Ported from Android SearchResultItem.java.
|
||||
// Disambiguation type for search-result items across collections and dashboards.
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Discriminant for a SearchResultItem's concrete entity type.
|
||||
public enum SearchResultItemType: String, Codable, CaseIterable {
|
||||
case monster
|
||||
case collection
|
||||
}
|
||||
|
||||
/// Lightweight non-persisted wrapper around either a Monster or a Collection,
|
||||
/// with enough metadata to distinguish cross-collection search results.
|
||||
public struct SearchResultItem: Identifiable, Hashable {
|
||||
|
||||
public let id: String // objectID-based unique key.
|
||||
public let type: SearchResultItemType
|
||||
public let monster: Monster?
|
||||
public let collection: Collection?
|
||||
|
||||
// MARK: - Constructors mirroring Android
|
||||
|
||||
/// Wraps a Monster — sets `type = .monster`.
|
||||
public init(monster: Monster) {
|
||||
self.id = monster.objectID.uriRepresentation().lastPathComponent
|
||||
self.type = .monster
|
||||
self.monster = monster
|
||||
self.collection = nil
|
||||
}
|
||||
|
||||
/// Wraps a Collection — sets `type = .collection`.
|
||||
public init(collection: Collection) {
|
||||
self.id = collection.objectID.uriRepresentation().lastPathComponent
|
||||
self.type = .collection
|
||||
self.monster = nil
|
||||
self.collection = collection
|
||||
}
|
||||
|
||||
// MARK: - Identifiable / Hashable
|
||||
|
||||
public static func == (lhs: SearchResultItem, rhs: SearchResultItem) -> Bool {
|
||||
lhs.id == rhs.id && lhs.type == rhs.type
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
hasher.combine(type)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,23 @@ class SkillViewModel: ObservableObject, Identifiable {
|
||||
self.advantage = advantage
|
||||
}
|
||||
|
||||
static func knownSkillForName(_ name: String) -> AbilityScore? {
|
||||
switch name.lowercased() {
|
||||
case "athletics":
|
||||
return .strength
|
||||
case "acrobatics", "sleight of hand", "stealth":
|
||||
return .dexterity
|
||||
case "arcana", "history", "investigation", "nature", "religion":
|
||||
return .intelligence
|
||||
case "animal handling", "insight", "medicine", "perception", "survival":
|
||||
return .wisdom
|
||||
case "deception", "intimidation", "performance", "persuasion":
|
||||
return .charisma
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func modifier(forMonster: MonsterViewModel) -> Int {
|
||||
let proficiencyBonus = Double(forMonster.proficiencyBonus)
|
||||
let abilityScoreModifier = Double(forMonster.abilityModifierForAbilityScore(abilityScore))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Collection" representedClassName="Collection" syncable="YES" codeGenerationType="category">
|
||||
<attribute name="description" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="details" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="sortOrder" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="monsters" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="CollectionMonster" inverseName="collection" inverseEntity="CollectionMonster"/>
|
||||
@@ -10,7 +10,7 @@
|
||||
<attribute name="collectionId" attributeType="String" defaultValueString="00000000-0000-0000-0000-000000000000"/>
|
||||
<attribute name="monsterId" attributeType="String" defaultValueString="00000000-0000-0000-0000-000000000000"/>
|
||||
<attribute name="ordinal" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<relationship name="collection" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Collection" inverseName="monsters" inverseEntity="Collection"/>
|
||||
<relationship name="collection" optional="YES" deletionRule="Nullify" destinationEntity="Collection" inverseName="monsters" inverseEntity="Collection"/>
|
||||
</entity>
|
||||
<entity name="Monster" representedClassName="Monster" syncable="YES" codeGenerationType="category">
|
||||
<attribute name="abilities" optional="YES" attributeType="Transformable" valueTransformerName="AbilityViewModelValueTransformer" customClassName="[AbilityViewModel]"/>
|
||||
@@ -84,7 +84,7 @@
|
||||
<attribute name="advantage" attributeType="String" defaultValueString="none"/>
|
||||
<attribute name="name" attributeType="String" defaultValueString=""/>
|
||||
<attribute name="proficiency" attributeType="String" defaultValueString="none"/>
|
||||
<relationship name="monster" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Monster" inverseName="skills" inverseEntity="Monster"/>
|
||||
<relationship name="monster" optional="YES" deletionRule="Nullify" destinationEntity="Monster" inverseName="skills" inverseEntity="Monster"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Monster" positionX="-63" positionY="-18" width="128" height="1154"/>
|
||||
|
||||
@@ -10,7 +10,8 @@ import CoreData
|
||||
struct PersistenceController {
|
||||
static let shared = PersistenceController()
|
||||
|
||||
static var preview: PersistenceController = {
|
||||
@MainActor
|
||||
static let preview: PersistenceController = {
|
||||
let result = PersistenceController(inMemory: true)
|
||||
let viewContext = result.container.viewContext
|
||||
let monsters: [Monster] = [
|
||||
@@ -21,10 +22,9 @@ struct PersistenceController {
|
||||
do {
|
||||
try viewContext.save()
|
||||
} catch {
|
||||
// Replace this implementation with code to handle the error appropriately.
|
||||
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
|
||||
// TODO: Replace this implementation with code to handle the error appropriately.
|
||||
let nsError = error as NSError
|
||||
// fatalError("TOMHICKS_Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
print("Unresolved error \(nsError), \(nsError.userInfo)")
|
||||
}
|
||||
return result
|
||||
}()
|
||||
@@ -39,6 +39,7 @@ struct PersistenceController {
|
||||
container.viewContext.automaticallyMergesChangesFromParent = true
|
||||
container.loadPersistentStores(completionHandler: { (storeDescription, error) in
|
||||
if let error = error as NSError? {
|
||||
// TODO: Handle persistent store load failure gracefully instead of fatalError
|
||||
fatalError("Unresolved error \(error), \(error.userInfo)")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,16 +6,70 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import CoreData
|
||||
|
||||
struct Collections: View {
|
||||
// @State var allCollections: [MonsterCollection] = []
|
||||
@Environment(\.managedObjectContext) private var viewContext
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \Collection.sortOrder, ascending: true)],
|
||||
animation: .default
|
||||
)
|
||||
private var collections: FetchedResults<Collection>
|
||||
|
||||
// TODO: Add state for showing 'Create Collection' dialog or sheet
|
||||
// TODO: Add state for search/filter within collections
|
||||
|
||||
var body: some View {
|
||||
Text("Collections")
|
||||
NavigationStack {
|
||||
List {
|
||||
if collections.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Collections",
|
||||
systemImage: "folder.badge.plus",
|
||||
description: Text("Tap '+' to create your first monster collection.")
|
||||
)
|
||||
} else {
|
||||
ForEach(collections) { collection in
|
||||
NavigationLink {
|
||||
// TODO: Implement CollectionDetailView to show monsters in this collection
|
||||
Text(collection.name ?? "Unnamed Collection")
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(collection.name ?? "Unnamed Collection")
|
||||
.font(.headline)
|
||||
if let details = collection.details, !details.isEmpty {
|
||||
Text(details)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteCollections)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Collections")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button(action: addCollection) {
|
||||
Label("Add Collection", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Collections_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Collections().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
private func addCollection() {
|
||||
// TODO: Implement collection creation logic (e.g. presenting sheet or creating new Collection managed object)
|
||||
}
|
||||
|
||||
private func deleteCollections(offsets: IndexSet) {
|
||||
// TODO: Implement collection deletion logic with context saving
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Collections()
|
||||
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
|
||||
}
|
||||
|
||||
@@ -3,41 +3,60 @@
|
||||
// MonsterPreview
|
||||
//
|
||||
// Created by Tom Hicks on 4/7/21.
|
||||
// Last modified by Tom Hicks on 9/23/26.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
|
||||
class PreviewViewController: UIViewController, QLPreviewingController {
|
||||
@MainActor
|
||||
final class PreviewViewController: UIViewController, @preconcurrency QLPreviewingController {
|
||||
|
||||
// MARK: - QLPreviewingController
|
||||
|
||||
func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) {
|
||||
|
||||
Task { @MainActor in
|
||||
let document = MonsterDocument(fileURL: url)
|
||||
document.open(completionHandler: {[weak self](_) in
|
||||
self?.presentMonsterViewController(for: document)
|
||||
|
||||
// Asynchronously open the document on the MainActor
|
||||
let isOpen = await document.open()
|
||||
|
||||
guard isOpen else {
|
||||
handler(CocoaError(.fileReadCorruptFile))
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the model and display
|
||||
let dto = document.monsterDTO ?? MonsterDTO()
|
||||
self.presentMonsterViewController(for: dto)
|
||||
|
||||
// Notify QuickLook that preview preparation succeeded
|
||||
handler(nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func presentMonsterViewController(for document: MonsterDocument) {
|
||||
let monsterViewModel = MonsterImportHelper.import5ESBMonster(document.monsterDTO ?? MonsterDTO())
|
||||
let monsterViewController = UIHostingController(rootView: MonsterDetailView(viewModel: monsterViewModel))
|
||||
monsterViewController.loadViewIfNeeded()
|
||||
monsterViewController.view.layoutIfNeeded()
|
||||
addChild(monsterViewController)
|
||||
view.addSubview(monsterViewController.view)
|
||||
monsterViewController.didMove(toParent: self)
|
||||
// MARK: - Private Presentation
|
||||
|
||||
private func presentMonsterViewController(for dto: MonsterDTO) {
|
||||
let monsterViewModel = MonsterImportHelper.import5ESBMonster(dto)
|
||||
let hostingController = UIHostingController(rootView: MonsterDetailView(viewModel: monsterViewModel))
|
||||
|
||||
// Add as child view controller
|
||||
addChild(hostingController)
|
||||
view.addSubview(hostingController.view)
|
||||
|
||||
// Configure layout constraints
|
||||
let subview = hostingController.view!
|
||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
if let monsterView = monsterViewController.view {
|
||||
monsterView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
monsterView.leftAnchor.constraint(equalTo: view.leftAnchor),
|
||||
monsterView.rightAnchor.constraint(equalTo: view.rightAnchor),
|
||||
monsterView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
monsterView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
subview.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
subview.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
hostingController.didMove(toParent: self)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user