From e3b61963493f61a49f84d7f314c3162230920a0c Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Wed, 23 Sep 2026 19:47:53 -0700 Subject: [PATCH] Fixes the xcode build and adds stubs for missing classes. --- iOS/MonsterCards.xcodeproj/project.pbxproj | 56 ++++++++++- .../xcshareddata/swiftpm/Package.resolved | 57 +++++------ .../Helpers/MonsterImportHelper.swift | 23 ----- .../ImportExport/BinderExporter.swift | 89 ++++++++--------- .../ImportExport/BinderImportModels.swift | 22 +---- .../ImportExport/BinderImporter.swift | 99 +++++++------------ .../ImportExport/EntityImporter.swift | 28 +++--- .../TetraCubeMonsterImporter.swift | 53 ++++++++++ .../Models/AbilityViewModel.swift | 2 +- .../Models/Collection+CoreDataClass.swift | 15 +++ .../CollectionMonster+CoreDataClass.swift | 13 +++ .../Models/LanguageViewModel.swift | 2 +- .../Models/SearchResultItem.swift | 53 ++++++++++ iOS/MonsterCards/Models/SkillViewModel.swift | 17 ++++ .../MonsterCards.xcdatamodel/contents | 6 +- iOS/MonsterCards/Persistence.swift | 9 +- iOS/MonsterCards/Views/Collections.swift | 66 +++++++++++-- .../PreviewViewController.swift | 75 ++++++++------ 18 files changed, 445 insertions(+), 240 deletions(-) create mode 100644 iOS/MonsterCards/ImportExport/TetraCubeMonsterImporter.swift create mode 100644 iOS/MonsterCards/Models/Collection+CoreDataClass.swift create mode 100644 iOS/MonsterCards/Models/CollectionMonster+CoreDataClass.swift create mode 100644 iOS/MonsterCards/Models/SearchResultItem.swift diff --git a/iOS/MonsterCards.xcodeproj/project.pbxproj b/iOS/MonsterCards.xcodeproj/project.pbxproj index 9d8ee54..03380ef 100644 --- a/iOS/MonsterCards.xcodeproj/project.pbxproj +++ b/iOS/MonsterCards.xcodeproj/project.pbxproj @@ -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 = ""; }; + E20209D425D8DD9600EFE736 /* Collection+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+CoreDataClass.swift"; sourceTree = ""; }; + E20209D525D8DD9600EFE737 /* CollectionMonster+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionMonster+CoreDataClass.swift"; sourceTree = ""; }; E20209E725D8DEC100EFE733 /* AbilityScore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbilityScore.swift; sourceTree = ""; }; E20209F225D8E04300EFE733 /* ProficiencyType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProficiencyType.swift; sourceTree = ""; }; E20209F325D8E04300EFE733 /* AdvantageType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdvantageType.swift; sourceTree = ""; }; @@ -217,6 +226,11 @@ E2CB0DE0260887ED00142591 /* StringViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringViewModel.swift; sourceTree = ""; }; E2CB0DE526088CE400142591 /* EditStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditStrings.swift; sourceTree = ""; }; E2D473FC25B532C900CB36D7 /* Color+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+Hex.swift"; sourceTree = ""; }; + E2FE0001261FE0000083EAC5 /* EntityImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityImporter.swift; sourceTree = ""; }; + E2FE0002261FE0000083EAC5 /* BinderImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinderImporter.swift; sourceTree = ""; }; + E2FE0003261FE0000083EAC5 /* BinderExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinderExporter.swift; sourceTree = ""; }; + E2FE0004261FE0000083EAC5 /* BinderImportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinderImportModels.swift; sourceTree = ""; }; + E2FE0005261FE0000083EAC5 /* TetraCubeMonsterImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TetraCubeMonsterImporter.swift; sourceTree = ""; }; /* 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 = ""; }; + E2FE0000261FE0000083EAC5 /* ImportExport */ = { + isa = PBXGroup; + children = ( + E2FE0001261FE0000083EAC5 /* EntityImporter.swift */, + E2FE0002261FE0000083EAC5 /* BinderImporter.swift */, + E2FE0003261FE0000083EAC5 /* BinderExporter.swift */, + E2FE0004261FE0000083EAC5 /* BinderImportModels.swift */, + E2FE0005261FE0000083EAC5 /* TetraCubeMonsterImporter.swift */, + ); + path = ImportExport; + sourceTree = ""; + }; /* 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; diff --git a/iOS/MonsterCards.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/MonsterCards.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4394878..e9271a8 100644 --- a/iOS/MonsterCards.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/MonsterCards.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,32 +1,33 @@ { + "originHash" : "427e2f78970f33b38feca26c7986bebb5f56cf55a388a29b6bade958aaa6607e", "pins" : [ { - "identity" : "markdownui", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/MarkdownUI", - "state" : { - "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", - "version" : "2.4.1" - } - }, - { - "identity" : "networkimage", - "kind" : "remoteSourceControl", - "location" : "https://github.com/gonzalezreal/NetworkImage", - "state" : { - "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", - "version" : "6.0.1" - } - }, - { - "identity" : "swift-cmark", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-cmark", - "state" : { - "revision" : "924936d0427cb25a61169739a7660230bffa6ea6", - "version" : "0.8.0" - } - } - ], - "version" : 2 + "identity" : "markdownui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/MarkdownUI", + "state" : { + "revision" : "5f613358148239d0292c0cef674a3c2314737f9e", + "version" : "2.4.1" + } + }, + { + "identity" : "networkimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/gonzalezreal/NetworkImage", + "state" : { + "revision" : "2849f5323265386e200484b0d0f896e73c3411b9", + "version" : "6.0.1" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark", + "state" : { + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" + } + } + ], + "version" : 3 } diff --git a/iOS/MonsterCards/Helpers/MonsterImportHelper.swift b/iOS/MonsterCards/Helpers/MonsterImportHelper.swift index 3d81240..42be564 100644 --- a/iOS/MonsterCards/Helpers/MonsterImportHelper.swift +++ b/iOS/MonsterCards/Helpers/MonsterImportHelper.swift @@ -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 - } -} diff --git a/iOS/MonsterCards/ImportExport/BinderExporter.swift b/iOS/MonsterCards/ImportExport/BinderExporter.swift index 061545d..aa713f5 100644 --- a/iOS/MonsterCards/ImportExport/BinderExporter.swift +++ b/iOS/MonsterCards/ImportExport/BinderExporter.swift @@ -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 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 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 collections, List 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) ?? "{}" } diff --git a/iOS/MonsterCards/ImportExport/BinderImportModels.swift b/iOS/MonsterCards/ImportExport/BinderImportModels.swift index 3670745..316b51c 100644 --- a/iOS/MonsterCards/ImportExport/BinderImportModels.swift +++ b/iOS/MonsterCards/ImportExport/BinderImportModels.swift @@ -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.) } diff --git a/iOS/MonsterCards/ImportExport/BinderImporter.swift b/iOS/MonsterCards/ImportExport/BinderImporter.swift index cfaeede..d8448d4 100644 --- a/iOS/MonsterCards/ImportExport/BinderImporter.swift +++ b/iOS/MonsterCards/ImportExport/BinderImporter.swift @@ -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 " 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[..` 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 } } diff --git a/iOS/MonsterCards/ImportExport/TetraCubeMonsterImporter.swift b/iOS/MonsterCards/ImportExport/TetraCubeMonsterImporter.swift new file mode 100644 index 0000000..bf28c97 --- /dev/null +++ b/iOS/MonsterCards/ImportExport/TetraCubeMonsterImporter.swift @@ -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)" + } + } +} diff --git a/iOS/MonsterCards/Models/AbilityViewModel.swift b/iOS/MonsterCards/Models/AbilityViewModel.swift index 474ed13..1342f81 100644 --- a/iOS/MonsterCards/Models/AbilityViewModel.swift +++ b/iOS/MonsterCards/Models/AbilityViewModel.swift @@ -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") diff --git a/iOS/MonsterCards/Models/Collection+CoreDataClass.swift b/iOS/MonsterCards/Models/Collection+CoreDataClass.swift new file mode 100644 index 0000000..ab29c65 --- /dev/null +++ b/iOS/MonsterCards/Models/Collection+CoreDataClass.swift @@ -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 +} diff --git a/iOS/MonsterCards/Models/CollectionMonster+CoreDataClass.swift b/iOS/MonsterCards/Models/CollectionMonster+CoreDataClass.swift new file mode 100644 index 0000000..3a1c2f7 --- /dev/null +++ b/iOS/MonsterCards/Models/CollectionMonster+CoreDataClass.swift @@ -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 +} diff --git a/iOS/MonsterCards/Models/LanguageViewModel.swift b/iOS/MonsterCards/Models/LanguageViewModel.swift index 65bbccd..53a78e1 100644 --- a/iOS/MonsterCards/Models/LanguageViewModel.swift +++ b/iOS/MonsterCards/Models/LanguageViewModel.swift @@ -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") diff --git a/iOS/MonsterCards/Models/SearchResultItem.swift b/iOS/MonsterCards/Models/SearchResultItem.swift new file mode 100644 index 0000000..bd5a84c --- /dev/null +++ b/iOS/MonsterCards/Models/SearchResultItem.swift @@ -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) + } +} diff --git a/iOS/MonsterCards/Models/SkillViewModel.swift b/iOS/MonsterCards/Models/SkillViewModel.swift index 9153ce4..0f7d778 100644 --- a/iOS/MonsterCards/Models/SkillViewModel.swift +++ b/iOS/MonsterCards/Models/SkillViewModel.swift @@ -21,6 +21,23 @@ class SkillViewModel: ObservableObject, Identifiable { self.proficiency = proficiency 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) diff --git a/iOS/MonsterCards/MonsterCards.xcdatamodeld/MonsterCards.xcdatamodel/contents b/iOS/MonsterCards/MonsterCards.xcdatamodeld/MonsterCards.xcdatamodel/contents index 5551597..93b3359 100644 --- a/iOS/MonsterCards/MonsterCards.xcdatamodeld/MonsterCards.xcdatamodel/contents +++ b/iOS/MonsterCards/MonsterCards.xcdatamodeld/MonsterCards.xcdatamodel/contents @@ -1,7 +1,7 @@ - + @@ -10,7 +10,7 @@ - + @@ -84,7 +84,7 @@ - + diff --git a/iOS/MonsterCards/Persistence.swift b/iOS/MonsterCards/Persistence.swift index 3130ab0..63f08e3 100644 --- a/iOS/MonsterCards/Persistence.swift +++ b/iOS/MonsterCards/Persistence.swift @@ -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)") } }) diff --git a/iOS/MonsterCards/Views/Collections.swift b/iOS/MonsterCards/Views/Collections.swift index d8c9094..d83abcd 100644 --- a/iOS/MonsterCards/Views/Collections.swift +++ b/iOS/MonsterCards/Views/Collections.swift @@ -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 + + // 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") + } + } + } + } + } + + 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 } } -struct Collections_Previews: PreviewProvider { - static var previews: some View { - Collections().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) - } +#Preview { + Collections() + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) } diff --git a/iOS/MonsterPreview/PreviewViewController.swift b/iOS/MonsterPreview/PreviewViewController.swift index 618c794..e086a10 100644 --- a/iOS/MonsterPreview/PreviewViewController.swift +++ b/iOS/MonsterPreview/PreviewViewController.swift @@ -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 { - - func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { - - let document = MonsterDocument(fileURL: url) - document.open(completionHandler: {[weak self](_) in - self?.presentMonsterViewController(for: document) - handler(nil) - }) - } +@MainActor +final class PreviewViewController: UIViewController, @preconcurrency QLPreviewingController { - 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) - - 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), - ]) + // MARK: - QLPreviewingController + + func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { + Task { @MainActor in + let document = MonsterDocument(fileURL: url) + + // 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) } } - + + // 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 + + NSLayoutConstraint.activate([ + 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) + } }