Fixes the xcode build and adds stubs for missing classes.

This commit is contained in:
2026-09-23 19:47:53 -07:00
parent baed51259e
commit e3b6196349
18 changed files with 445 additions and 240 deletions

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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) ?? "{}"
}

View File

@@ -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.)
}

View File

@@ -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 1: monster.actions .append(AbilityViewModel(displayName, trait.desc ?? ""))
case 2: monster.reactions .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 ?? ""))
}
}

View File

@@ -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
}
}

View 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)"
}
}
}

View File

@@ -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")

View 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
}

View File

@@ -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
}

View File

@@ -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")

View 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)
}
}

View File

@@ -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))

View File

@@ -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"/>

View File

@@ -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)")
}
})

View File

@@ -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")
}
}
}
}
}
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)
}

View File

@@ -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)
}
}