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; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 58; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
E20209D325D8DD9600EFE733 /* Skill+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */; }; 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 */; }; E20209E825D8DEC100EFE733 /* AbilityScore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209E725D8DEC100EFE733 /* AbilityScore.swift */; };
E20209F425D8E04300EFE733 /* ProficiencyType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209F225D8E04300EFE733 /* ProficiencyType.swift */; }; E20209F425D8E04300EFE733 /* ProficiencyType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209F225D8E04300EFE733 /* ProficiencyType.swift */; };
E20209F525D8E04300EFE733 /* AdvantageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20209F325D8E04300EFE733 /* AdvantageType.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 */; }; E2CB0DE1260887ED00142591 /* StringViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CB0DE0260887ED00142591 /* StringViewModel.swift */; };
E2CB0DE626088CE400142591 /* EditStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2CB0DE526088CE400142591 /* EditStrings.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 */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -142,6 +149,8 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Skill+CoreDataClass.swift"; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -316,6 +330,7 @@
E20CF008261FF37700B55D72 /* MonsterCards.entitlements */, E20CF008261FF37700B55D72 /* MonsterCards.entitlements */,
E2570FBC25B1AC550055B23B /* Assets.xcassets */, E2570FBC25B1AC550055B23B /* Assets.xcassets */,
E2D473FB25B5328800CB36D7 /* Helpers */, E2D473FB25B5328800CB36D7 /* Helpers */,
E2FE0000261FE0000083EAC5 /* ImportExport */,
E2570FC625B1AC550055B23B /* Info.plist */, E2570FC625B1AC550055B23B /* Info.plist */,
E257101225B1B2790055B23B /* Models */, E257101225B1B2790055B23B /* Models */,
E2570FC325B1AC550055B23B /* MonsterCards.xcdatamodeld */, E2570FC325B1AC550055B23B /* MonsterCards.xcdatamodeld */,
@@ -406,6 +421,8 @@
E216E46C261FDE5600FD9262 /* MonsterViewModel+CoreData.swift */, E216E46C261FDE5600FD9262 /* MonsterViewModel+CoreData.swift */,
E219247F261989F700C84E12 /* SavingThrowDTO.swift */, E219247F261989F700C84E12 /* SavingThrowDTO.swift */,
E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */, E20209D225D8DD9600EFE733 /* Skill+CoreDataClass.swift */,
E20209D425D8DD9600EFE736 /* Collection+CoreDataClass.swift */,
E20209D525D8DD9600EFE737 /* CollectionMonster+CoreDataClass.swift */,
E219248426198A1200C84E12 /* SkillDTO.swift */, E219248426198A1200C84E12 /* SkillDTO.swift */,
E20209F925D8E19100EFE733 /* SkillViewModel.swift */, E20209F925D8E19100EFE733 /* SkillViewModel.swift */,
E216E471261FDF3200FD9262 /* SkillViewModel+CoreData.swift */, E216E471261FDF3200FD9262 /* SkillViewModel+CoreData.swift */,
@@ -425,6 +442,18 @@
path = Helpers; path = Helpers;
sourceTree = "<group>"; 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 */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -650,6 +679,11 @@
E2570FF525B1ADEB0055B23B /* Dashboard.swift in Sources */, E2570FF525B1ADEB0055B23B /* Dashboard.swift in Sources */,
E2CB0DB826081A2F00142591 /* MCAbilityScorePicker.swift in Sources */, E2CB0DB826081A2F00142591 /* MCAbilityScorePicker.swift in Sources */,
E219249926198E0D00C84E12 /* MonsterImportHelper.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 */, E2CB0DC026086E3C00142591 /* ChallengeRating.swift in Sources */,
E257100425B1AF4A0055B23B /* SearchBar.swift in Sources */, E257100425B1AF4A0055B23B /* SearchBar.swift in Sources */,
E20209F525D8E04300EFE733 /* AdvantageType.swift in Sources */, E20209F525D8E04300EFE733 /* AdvantageType.swift in Sources */,
@@ -675,6 +709,8 @@
E2192480261989F700C84E12 /* SavingThrowDTO.swift in Sources */, E2192480261989F700C84E12 /* SavingThrowDTO.swift in Sources */,
E216B7B7260C5A9800FB205F /* ChallengeRatingViewModel.swift in Sources */, E216B7B7260C5A9800FB205F /* ChallengeRatingViewModel.swift in Sources */,
E20209D325D8DD9600EFE733 /* Skill+CoreDataClass.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 */, E24ACE652607F55D009BF703 /* EditSavingThrows.swift in Sources */,
E2BD702C25B3A8D70058ED69 /* MCTextField.swift in Sources */, E2BD702C25B3A8D70058ED69 /* MCTextField.swift in Sources */,
E216B7BC260C691400FB205F /* EditChallengeRating.swift in Sources */, E216B7BC260C691400FB205F /* EditChallengeRating.swift in Sources */,
@@ -738,6 +774,7 @@
E216E48A261FE76F00FD9262 /* Debug */ = { E216E48A261FE76F00FD9262 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = J793L9LQJ2; DEVELOPMENT_TEAM = J793L9LQJ2;
@@ -760,6 +797,7 @@
E216E48B261FE76F00FD9262 /* Release */ = { E216E48B261FE76F00FD9262 /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = J793L9LQJ2; DEVELOPMENT_TEAM = J793L9LQJ2;
@@ -782,6 +820,7 @@
E2570FDD25B1AC550055B23B /* Debug */ = { E2570FDD25B1AC550055B23B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -830,7 +869,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@@ -843,6 +882,7 @@
E2570FDE25B1AC550055B23B /* Release */ = { E2570FDE25B1AC550055B23B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_SEARCH_USER_PATHS = NO; ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
@@ -885,7 +925,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -898,6 +938,7 @@
E2570FE025B1AC550055B23B /* Debug */ = { E2570FE025B1AC550055B23B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -924,6 +965,7 @@
E2570FE125B1AC550055B23B /* Release */ = { E2570FE125B1AC550055B23B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@@ -950,12 +992,13 @@
E2570FE325B1AC550055B23B /* Debug */ = { E2570FE325B1AC550055B23B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = J793L9LQJ2; DEVELOPMENT_TEAM = J793L9LQJ2;
INFOPLIST_FILE = MonsterCardsTests/Info.plist; INFOPLIST_FILE = MonsterCardsTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -972,12 +1015,13 @@
E2570FE425B1AC550055B23B /* Release */ = { E2570FE425B1AC550055B23B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
BUNDLE_LOADER = "$(TEST_HOST)"; BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = J793L9LQJ2; DEVELOPMENT_TEAM = J793L9LQJ2;
INFOPLIST_FILE = MonsterCardsTests/Info.plist; INFOPLIST_FILE = MonsterCardsTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@@ -994,6 +1038,7 @@
E2570FE625B1AC550055B23B /* Debug */ = { E2570FE625B1AC550055B23B /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = J793L9LQJ2; DEVELOPMENT_TEAM = J793L9LQJ2;
@@ -1014,6 +1059,7 @@
E2570FE725B1AC550055B23B /* Release */ = { E2570FE725B1AC550055B23B /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
SWIFT_VERSION = 6.0;
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = J793L9LQJ2; DEVELOPMENT_TEAM = J793L9LQJ2;

View File

@@ -1,4 +1,5 @@
{ {
"originHash" : "427e2f78970f33b38feca26c7986bebb5f56cf55a388a29b6bade958aaa6607e",
"pins" : [ "pins" : [
{ {
"identity" : "markdownui", "identity" : "markdownui",
@@ -23,10 +24,10 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark", "location" : "https://github.com/swiftlang/swift-cmark",
"state" : { "state" : {
"revision" : "924936d0427cb25a61169739a7660230bffa6ea6", "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a",
"version" : "0.8.0" "version" : "0.9.0"
} }
} }
], ],
"version" : 2 "version" : 3
} }

View File

@@ -109,26 +109,3 @@ class MonsterImportHelper {
return monster 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 // BinderExporter.swift
// MonsterCards // MonsterCards
// //
// Port of com.majinnaibu.monstercards.exporters.BinderExporter.java // Serialises MonsterViewModel -> Binder platform JSON.
// Serialises MonsterViewModel → Binder platform JSON (pretty-printed, Gson-compatible).
// //
import Foundation import Foundation
import CoreData
// MARK: - Binder export DTOs // MARK: - Binder export DTOs
@@ -19,17 +19,16 @@ struct BinderExport {
let id: String let id: String
let name: String let name: String
let description: 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.id = id
self.name = name self.name = name
self.description = description self.description = description
// Preserve empty list explicitly (Android uses new ArrayList<>())
self.cards = cards.count > 0 ? cards : [] 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) CollectionExport(id: UUID().uuidString, name: name, description: "", cards: cards)
} }
} }
@@ -37,6 +36,7 @@ struct BinderExport {
struct MonsterExport { struct MonsterExport {
/// Build a JSON-serialisable dictionary from a `MonsterViewModel`. /// Build a JSON-serialisable dictionary from a `MonsterViewModel`.
@MainActor
static func makeDictionary(monster: MonsterViewModel, idOverride: UUID? = nil) -> [String: Any] { static func makeDictionary(monster: MonsterViewModel, idOverride: UUID? = nil) -> [String: Any] {
var d: [String: Any] = [ var d: [String: Any] = [
"$schema": "https://majinnaibu.com/schemas/monster-card.schema.json", "$schema": "https://majinnaibu.com/schemas/monster-card.schema.json",
@@ -44,13 +44,13 @@ struct MonsterExport {
// Core identity // Core identity
"id": idOverride?.uuidString ?? UUID().uuidString, "id": idOverride?.uuidString ?? UUID().uuidString,
"name": monster.name ?? "", "name": monster.name,
"size": monster.size ?? "", "size": monster.size,
"type": monster.type ?? "", "type": monster.type,
"subtype": monster.subType ?? "", "subtype": monster.subType,
"alignment": monster.alignment ?? "", "alignment": monster.alignment,
// Ability scores — keys match Android Monster.java Gson output literally. // Ability scores
"strengthScore": monster.strengthScore, "strengthScore": monster.strengthScore,
"dexterityScore": monster.dexterityScore, "dexterityScore": monster.dexterityScore,
"constitutionScore": monster.constitutionScore, "constitutionScore": monster.constitutionScore,
@@ -58,7 +58,7 @@ struct MonsterExport {
"wisdomScore": monster.wisdomScore, "wisdomScore": monster.wisdomScore,
"charismaScore": monster.charismaScore, "charismaScore": monster.charismaScore,
// Saving throw proficiencies & advantages (raw string from Android enum.toString()). // Saving throw proficiencies & advantages
"strengthSavingThrowProficiency": monster.strengthSavingThrowProficiency.rawValue, "strengthSavingThrowProficiency": monster.strengthSavingThrowProficiency.rawValue,
"strengthSavingThrowAdvantage": monster.strengthSavingThrowAdvantage.rawValue, "strengthSavingThrowAdvantage": monster.strengthSavingThrowAdvantage.rawValue,
"dexteritySavingThrowProficiency": monster.dexteritySavingThrowProficiency.rawValue, "dexteritySavingThrowProficiency": monster.dexteritySavingThrowProficiency.rawValue,
@@ -76,12 +76,12 @@ struct MonsterExport {
"armorType": monster.armorType.rawValue, "armorType": monster.armorType.rawValue,
"shieldBonus": Int32(monster.shieldBonus), "shieldBonus": Int32(monster.shieldBonus),
"naturalArmorBonus": monster.naturalArmorBonus, "naturalArmorBonus": monster.naturalArmorBonus,
"otherArmorDescription": monster.otherArmorDescription ?? "", "otherArmorDescription": monster.customArmor,
// Hit points // Hit points
"hitDice": monster.hitDice, "hitDice": monster.hitDice,
"hasCustomHP": monster.hasCustomHP, "hasCustomHP": monster.hasCustomHP,
"customHitPointsDescription": monster.customHP ?? "", "customHitPointsDescription": monster.customHP,
// Speeds // Speeds
"walkSpeed": monster.walkSpeed, "walkSpeed": monster.walkSpeed,
@@ -91,47 +91,44 @@ struct MonsterExport {
"canHover": monster.canHover, "canHover": monster.canHover,
"swimSpeed": monster.swimSpeed, "swimSpeed": monster.swimSpeed,
"hasCustomSpeed": monster.hasCustomSpeed, "hasCustomSpeed": monster.hasCustomSpeed,
"customSpeedDescription": monster.customSpeed ?? "", "customSpeedDescription": monster.customSpeed,
// Challenge rating // Challenge rating
"challengeRating": monster.challengeRating.rawValue, "challengeRating": monster.challengeRating.rawValue,
"customChallengeRatingDescription": monster.customChallengeRating ?? "", "customChallengeRatingDescription": monster.customChallengeRating,
"customProficiencyBonus": monster.customProficiencyBonus, "customProficiencyBonus": monster.customProficiencyBonus,
// Telepathy & understanding // Telepathy & understanding
"telepathyRange": monster.telepathy, "telepathyRange": monster.telepathy,
"understandsButDescription": monster.understandsBut ?? "", "understandsButDescription": monster.understandsBut,
// Lists (strings) — keys match Android Monster.java @ColumnInfo / Gson names. // Lists
"senses": monster.senses.map { ($0.name ?? "") }, "senses": monster.senses.map { $0.name },
"damageImmunities": monster.damageImmunities.map { ($0.name ?? "") }, "damageImmunities": monster.damageImmunities.map { $0.name },
"damageResistances": monster.damageResistances.map { ($0.name ?? "") }, "damageResistances": monster.damageResistances.map { $0.name },
"damageVulnerabilities": monster.damageVulnerabilities.map { ($0.name ?? "") }, "damageVulnerabilities": monster.damageVulnerabilities.map { $0.name },
"conditionImmunities": monster.conditionImmunities.map { ($0.name ?? "") }, "conditionImmunities": monster.conditionImmunities.map { $0.name },
// Skills — each is {"name": ..., "stat": "...", "proficiency": "..."} // Skills
"skills": monster.skills.map { s -> [String: Any] in "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 "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": ...} // Combat lists
"abilities": monster.abilities.map { ["name": $0.name ?? "", "desc": $0.description ?? ""] }, "abilities": monster.abilities.map { ["name": $0.name, "desc": $0.description] },
"actions": monster.actions.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 ?? ""] }, "reactions": monster.reactions.map { ["name": $0.name, "desc": $0.description] },
"legendaryActions": monster.legendaryActions.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 ?? ""] }, "lairActions": monster.lairActions.map { ["name": $0.name, "desc": $0.description] },
"regionalActions": monster.regionalActions.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 ?? ""] },
// Mythic actions — Android Monster model has this; iOS lacks it.
"mythicActions": [] as [[String: Any]], "mythicActions": [] as [[String: Any]],
// Personality / physical description — Android-only fields on Monster.java.
"playerName": "", "playerName": "",
"background": "", "background": "",
"personalityTraits": "", "personalityTraits": "",
@@ -153,20 +150,16 @@ struct MonsterExport {
} }
} }
// MARK: - BinderExporter (mirrors public API of com.majinnaibu.monstercards.exporters.BinderExporter) // MARK: - BinderExporter
struct BinderExporter { struct BinderExporter {
// MARK: -- Utility — pretty-print JSON [String:Any] → string (Gson-compatible format)
private static func toJsonString(_ obj: Any) -> String? { private static func toJsonString(_ obj: Any) -> String? {
guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) else { return nil } guard let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) else { return nil }
return String(data: data, encoding: .utf8) return String(data: data, encoding: .utf8)
} }
// MARK: -- Public API matching Android exactly @MainActor
/// `exportBinder(String collectionName, List<Monster> monsters)`
static func exportBinder(collectionName: String, monsters: [MonsterViewModel], idOverride: UUID? = nil) -> String { static func exportBinder(collectionName: String, monsters: [MonsterViewModel], idOverride: UUID? = nil) -> String {
let col = BinderExport.CollectionExport( let col = BinderExport.CollectionExport(
id: UUID().uuidString, id: UUID().uuidString,
@@ -189,10 +182,9 @@ struct BinderExporter {
return toJsonString(root) ?? "{}" return toJsonString(root) ?? "{}"
} }
/// `exportBinder(Collection collection, List<Monster> monsters)` @MainActor
static func exportBinder(collection: NSManagedObject?, monsters: [MonsterViewModel]) -> String { static func exportBinder(collection: NSManagedObject?, monsters: [MonsterViewModel]) -> String {
guard let colObj = collection else { guard let colObj = collection else {
// Empty-collection fallback (same path as Android with null).
return exportBinder(collectionName: "", monsters: monsters) return exportBinder(collectionName: "", monsters: monsters)
} }
@@ -221,14 +213,14 @@ struct BinderExporter {
return toJsonString(root) ?? "{}" return toJsonString(root) ?? "{}"
} }
/// `exportFullBackup(List<CollectionExport> collections, List<Monster> dashboardMonsters)` @MainActor
static func exportFullBackup(collections: [BinderExport.CollectionExport], dashboardMonsters: [MonsterViewModel]?) -> String { static func exportFullBackup(collections: [BinderExport.CollectionExport], dashboardMonsters: [MonsterViewModel]?) -> String {
let colDicts = collections.map { col -> [String: Any] in let colDicts = collections.map { col -> [String: Any] in
return [ return [
"id": col.id, "id": col.id,
"name": col.name, "name": col.name,
"description": col.description, "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 { guard let dashboard = dashboardMonsters, !dashboard.isEmpty else {
// Android Gson only includes the field when it's populated.
return toJsonString(base) ?? "{}" return toJsonString(base) ?? "{}"
} }

View File

@@ -3,16 +3,14 @@
// MonsterCards // MonsterCards
// //
// Data models for parsing Binder (TRPG platform) JSON format. // Data models for parsing Binder (TRPG platform) JSON format.
// Companion to Android BinderExporter / Monster classes. // Field names match the keys produced by Binder's serializer.
// Field names here must match the keys produced by Binder's Gson serializer, //
// which serializes the Android `Monster` POJO.
import Foundation import Foundation
/// Top-level Binder export file containing collections of monster cards. /// Top-level Binder export file containing collections of monster cards.
struct BinderRoot: Decodable { struct BinderRoot: Decodable {
let schemaVersion: Int? let schemaVersion: Int?
@Environment(\.codingVersion) var codingScheme
let collections: [CollectionModel] let collections: [CollectionModel]
} }
@@ -27,9 +25,7 @@ struct MonsterCard: Decodable {
// MARK: - Basic info // MARK: - Basic info
typealias CodingKeys = MonsterCardCodingKey enum CodingKeys: String, CodingKey {
enum MonsterCardCodingKey: String, CodingKey {
case name, displayName, displayType, alignment, sizeAbbrv case name, displayName, displayType, alignment, sizeAbbrv
case shieldName, baseAcFormula, baseAcVal, shieldAcNum, extraArmorAcMod case shieldName, baseAcFormula, baseAcVal, shieldAcNum, extraArmorAcMod
case hitDice, customHpText, speedText case hitDice, customHpText, speedText
@@ -39,7 +35,6 @@ struct MonsterCard: Decodable {
case specialdamage, languageLine, sensesDescription case specialdamage, languageLine, sensesDescription
case crDisplay, proficiencyBonusValue case crDisplay, proficiencyBonusValue
case cardTraits, legendaryActionsWithHeader case cardTraits, legendaryActionsWithHeader
case attributes
} }
let name: String? let name: String?
@@ -65,7 +60,7 @@ struct MonsterCard: Decodable {
/// Additional armor modifier (e.g. "+4", "-1"). /// Additional armor modifier (e.g. "+4", "-1").
let extraArmorAcMod: String? let extraArmorAcMod: String?
/// Hit dice (e.g. "38d10"). When nil, uses hitDice from attributes[0]. /// Hit dice (e.g. "38d10").
let hitDice: String? let hitDice: String?
/// Custom HP text when the monster has custom HP. /// Custom HP text when the monster has custom HP.
@@ -124,11 +119,6 @@ struct MonsterCard: Decodable {
/// Proficiency bonus numeric value. /// Proficiency bonus numeric value.
let proficiencyBonusValue: Int? 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. /// Card trait entries (abilities/actions) with section headers and grouping flags.
let cardTraits: [CardTraitEntry]? let cardTraits: [CardTraitEntry]?
@@ -146,7 +136,6 @@ struct CardAttributeBox: Decodable {
/// A proficiency-based skill entry (e.g. Perception). /// A proficiency-based skill entry (e.g. Perception).
struct ProfSkillBox: Decodable { struct ProfSkillBox: Decodable {
let name: String? let name: String?
@Environment(\.codingVersion) var codingScheme
let isExpertise: Int? // 0 or 1 stored as int; nil means false let isExpertise: Int? // 0 or 1 stored as int; nil means false
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
@@ -157,7 +146,6 @@ struct ProfSkillBox: Decodable {
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name) 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 { if let expertiseValue = (try? container.decode(Int.self, forKey: .expertise)), expertiseValue == 1 {
self.isExpertise = 1 self.isExpertise = 1
} else { } else {
@@ -177,6 +165,6 @@ struct SpecialDamageBox: Decodable {
struct CardTraitEntry: Decodable { struct CardTraitEntry: Decodable {
let displayName: String? // action/ability name let displayName: String? // action/ability name
let desc: String? // ability text 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.) let actionSection: Int? // grouping key (0=bonus actions, 1=actions, 2=reactions, etc.)
} }

View File

@@ -3,7 +3,6 @@
// MonsterCards // MonsterCards
// //
// Detects and parses Binder (TRPG platform) JSON format into a MonsterViewModel. // 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 import Foundation
@@ -20,14 +19,14 @@ struct BinderImporter: EntityImporter {
return false 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") { if let schema = root["$schema"] as? String, schema.contains("binder.schema.json") {
return true return true
} }
// Fall back: check known Binder fields from collections array (matches Android logic) // Fall back: check known Binder fields from collections array
if root["collections"] is [Any] { if let collections = root["collections"] as? [Any] {
return !CollectionsCheck.isEmpty(try? JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]]) return !collections.isEmpty
} }
return false return false
@@ -35,6 +34,7 @@ struct BinderImporter: EntityImporter {
// MARK: - parse // MARK: - parse
@MainActor
static func parse(_ raw: String) throws -> MonsterViewModel { static func parse(_ raw: String) throws -> MonsterViewModel {
guard let data = raw.data(using: .utf8) else { guard let data = raw.data(using: .utf8) else {
throw BinderParseError.unsupportedEncoding throw BinderParseError.unsupportedEncoding
@@ -57,59 +57,57 @@ struct BinderImporter: EntityImporter {
// MARK: - Mapping // 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 { private static func mapMonster(_ card: MonsterCard) -> MonsterViewModel {
let monster = MonsterViewModel() let monster = MonsterViewModel()
// MARK: Basic info (Android: name, displayType, sizeAbbrv, alignment) // MARK: Basic info
monster.name = card.displayName ?? card.name ?? "" monster.name = card.displayName ?? card.name ?? ""
monster.type = card.displayType ?? "" monster.type = card.displayType ?? ""
// sizeAbbrv maps like Android: "T" → tiny, "S" → small, etc.
monster.size = abrvToSize(card.sizeAbbrv) monster.size = abrvToSize(card.sizeAbbrv)
monster.subType = "" // Binder does not have a separate subtype field monster.subType = ""
monster.alignment = card.alignment ?? "" 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.hasShield = (card.shieldAcNum ?? 0) != 0 || ((card.baseAcFormula?.contains("shield")) ?? false)
monster.shieldBonus = card.shieldAcNum ?? (monster.hasShield ? 2 : 0) monster.shieldBonus = card.shieldAcNum ?? (monster.hasShield ? 2 : 0)
if card.baseAcFormula != nil || (card.shieldName != nil && !card.shieldName!.isEmpty) { if card.baseAcFormula != nil || (card.shieldName != nil && !card.shieldName!.isEmpty) {
monster.armorType = .other // unarmored-defense / custom approach monster.armorType = .other
monster.otherArmorDescription = card.shieldName ?? "no shield" monster.customArmor = card.shieldName ?? "no shield"
} else { } else {
monster.armorType = .none monster.armorType = .none
} }
if let armorModStr = card.extraArmorAcMod, let modVal = Int(armorModStr) { if let armorModStr = card.extraArmorAcMod, let modVal = Int(armorModStr) {
// For "no armor" style, extraArmorAcMod is added directly to the AC formula text. let suffix = (monster.customArmor.isEmpty ? "" : ", ") + "(+\(modVal))"
let suffix = (card.otherArmorDescription.isEmpty ? "" : ", ") + "(+\(modVal))" monster.customArmor += suffix
monster.otherArmorDescription += suffix
} }
// MARK: HP & hit dice (Android: hitDice → String like "38d10"; customHpText for override) // MARK: HP & hit dice
if card.customHpText != nil && !card.customHpText!.isEmpty { if let customHpText = card.customHpText, !customHpText.isEmpty {
monster.hasCustomHP = true monster.hasCustomHP = true
monster.customHP = card.customHpText! monster.customHP = customHpText
} else { } else {
// Parse hit dice string like "38d10" → hitDice = 38
if let hdStr = card.hitDice, let parts = splitHitDice(hdStr) { if let hdStr = card.hitDice, let parts = splitHitDice(hdStr) {
monster.hitDice = Int64(parts.diceCount ?? 0) monster.hitDice = Int64(parts.diceCount ?? 0)
} }
} }
// MARK: Speed (Android MonsterImportHelper.speedFromString) // MARK: Speed
if let stxt = card.speedText where !stxt.isEmpty && !stxt.contains("feet") { if let stxt = card.speedText, !stxt.isEmpty, !stxt.contains("feet") {
monster.customSpeed = stxt monster.customSpeed = stxt
monster.hasCustomSpeed = true monster.hasCustomSpeed = true
} else if card.speedText != nil { } else if let speedText = card.speedText {
monster.walkSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "walk")) monster.walkSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "walk"))
monster.burrowSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "burrow")) monster.burrowSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "burrow"))
monster.climbSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "climb")) monster.climbSpeed = Int64(extractSpeedMeters(speedText, forSpeed: "climb"))
monster.flySpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "fly")) monster.flySpeed = Int64(extractSpeedMeters(speedText, forSpeed: "fly"))
monster.swimSpeed = Int64(extractSpeedMeters(card.speedText, forSpeed: "swim")) 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 { if let attrs = card.primaryAttributes, attrs.count >= 6 {
monster.strengthScore = Int64(attrs[0].value ?? 10) monster.strengthScore = Int64(attrs[0].value ?? 10)
monster.dexterityScore = Int64(attrs[1].value ?? 10) monster.dexterityScore = Int64(attrs[1].value ?? 10)
@@ -119,7 +117,7 @@ struct BinderImporter: EntityImporter {
monster.charismaScore = Int64(attrs[5].value ?? 10) monster.charismaScore = Int64(attrs[5].value ?? 10)
} }
// MARK: Saving throws proficiency (Android Monster.proSavingThrows) // MARK: Saving throws proficiency
for throwName in (card.proSavingThrows ?? []) { for throwName in (card.proSavingThrows ?? []) {
switch throwName.lowercased() { switch throwName.lowercased() {
case "str" : monster.strengthSavingThrowProficiency = .proficient case "str" : monster.strengthSavingThrowProficiency = .proficient
@@ -132,14 +130,13 @@ struct BinderImporter: EntityImporter {
} }
} }
// MARK: Skills (Android Monster.proficiencySkills) // MARK: Skills
for skillEntry in (card.proficiencySkills ?? []) { for skillEntry in (card.proficiencySkills ?? []) {
guard let sname = skillEntry.name else { continue } guard let sname = skillEntry.name else { continue }
let prof: ProficiencyType = skillEntry.isExpertise == 1 ? .expertise : .proficient let prof: ProficiencyType = skillEntry.isExpertise == 1 ? .expertise : .proficient
if let ability = SkillViewModel.knownSkillForName(sname) { if let ability = SkillViewModel.knownSkillForName(sname) {
monster.skills.append(SkillViewModel(sname, ability, prof)) monster.skills.append(SkillViewModel(sname, ability, prof))
} else { } else {
// Fallback
monster.skills.append(SkillViewModel(sname, .dexterity, prof)) monster.skills.append(SkillViewModel(sname, .dexterity, prof))
} }
} }
@@ -151,24 +148,21 @@ struct BinderImporter: EntityImporter {
if let dv = card.damageVulnerabilities { if let dv = card.damageVulnerabilities {
monster.damageVulnerabilities = parseDelimitedString(dv) monster.damageVulnerabilities = parseDelimitedString(dv)
} }
// Condition immunities
if let ci = card.conditionImmunities { if let ci = card.conditionImmunities {
monster.conditionImmunities = parseDelimitedString(ci) monster.conditionImmunities = parseDelimitedString(ci)
} }
// MARK: Languages (Android MonsterImportHelper.parseLanguageLine) // MARK: Languages
if let langLine = card.languageLine { if let langLine = card.languageLine {
monster.languages = parseLanguages(langLine) monster.languages = parseLanguages(langLine)
} }
// MARK: Senses (Android sensesDescription → split into sense entries) // MARK: Senses
if let sensesStr = card.sensesDescription, !sensesStr.isEmpty { if let sensesStr = card.sensesDescription, !sensesStr.isEmpty {
// Each item is typically "<senseName> <distance> ft." or just a name.
monster.senses = parseDelimitedString(sensesStr) monster.senses = parseDelimitedString(sensesStr)
} }
// MARK: Challenge rating (Android Monster.crDisplay) // MARK: Challenge rating
let crText = card.crDisplay ?? "0" let crText = card.crDisplay ?? "0"
if let cr = challengeRating(for: crText) { if let cr = challengeRating(for: crText) {
monster.challengeRating = cr monster.challengeRating = cr
@@ -181,7 +175,7 @@ struct BinderImporter: EntityImporter {
monster.customProficiencyBonus = Int64(pbVal) monster.customProficiencyBonus = Int64(pbVal)
} }
// MARK: Traits / actions (from Android Monster.cardTraits grouped by actionSection) // MARK: Traits / actions
processCardTraits(card, into: monster) processCardTraits(card, into: monster)
return monster return monster
@@ -192,7 +186,6 @@ struct BinderImporter: EntityImporter {
private extension BinderImporter { private extension BinderImporter {
/// Convert a size abbreviation to the full size enum name. (Android uses the same mapping.)
static func abrvToSize(_ abbrv: String?) -> String { static func abrvToSize(_ abbrv: String?) -> String {
guard let a = abbrv else { return "" } guard let a = abbrv else { return "" }
switch a.uppercased() { 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)? { static func splitHitDice(_ hdStr: String) -> (diceCount: Int?, dieSize: Int)? {
let parts = hdStr.split(separator: "d") let parts = hdStr.split(separator: "d")
guard parts.count == 2 else { return nil } guard parts.count == 2 else { return nil }
@@ -215,7 +207,6 @@ private extension BinderImporter {
return (count, size) 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 { static func extractSpeedMeters(_ text: String?, forSpeed type: String) -> Int {
guard let t = text else { return 0 } guard let t = text else { return 0 }
let pattern = "\\b\(type)\\b" let pattern = "\\b\(type)\\b"
@@ -223,19 +214,13 @@ private extension BinderImporter {
let range = NSRange(t.startIndex..., in: t) let range = NSRange(t.startIndex..., in: t)
if let match = regex.firstMatch(in: t, options: [], range: range), if let match = regex.firstMatch(in: t, options: [], range: range),
let numRange = Range(match.range(at: 1), in: t) { let numRange = Range(match.range(at: 1), in: t) {
var extractedText = "" let extractedText = String(t[numRange])
#if swift(>=5.0)
extractedText = String(t[numRange])
#else
extractedText = (t as NSString).substring(with: numRange)
#endif
if let val = Int(extractedText) { return val } if let val = Int(extractedText) { return val }
} }
} }
return 0 return 0
} }
/// Parse comma-separated string into [StringViewModel].
static func parseDelimitedString(_ input: String) -> [StringViewModel] { static func parseDelimitedString(_ input: String) -> [StringViewModel] {
return input.components(separatedBy: ",").compactMap { return input.components(separatedBy: ",").compactMap {
let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines) 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] { static func parseLanguages(_ line: String) -> [LanguageViewModel] {
var result: [LanguageViewModel] = [] var result: [LanguageViewModel] = []
let components = line.components(separatedBy: ",") let components = line.components(separatedBy: ",")
@@ -254,12 +238,10 @@ private extension BinderImporter {
var hasSpeaks = true var hasSpeaks = true
// Check for understands-only patterns
if trimmed.lowercased().contains("understand") || trimmed.lowercased().contains("(silently)") { if trimmed.lowercased().contains("understand") || trimmed.lowercased().contains("(silently)") {
hasSpeaks = false hasSpeaks = false
} }
// Remove trailing range annotation like " (150 ft.)" → keep just the name
let parts = trimmed.split(separator: " (") let parts = trimmed.split(separator: " (")
var nameOnly = trimmed var nameOnly = trimmed
if parts.count >= 2 { if parts.count >= 2 {
@@ -272,11 +254,9 @@ private extension BinderImporter {
return result return result
} }
/// Determine ChallengeRating from text (Android Monster.crDisplay → challengeRating conversion).
static func challengeRating(for text: String?) -> ChallengeRating? { static func challengeRating(for text: String?) -> ChallengeRating? {
guard let crText = text else { return .zero } guard let crText = text else { return .zero }
// Handle common text aliases
switch crText.lowercased() { switch crText.lowercased() {
case "half": return .oneHalf case "half": return .oneHalf
case "quarter", "one quarter": return .oneQuarter case "quarter", "one quarter": return .oneQuarter
@@ -285,12 +265,10 @@ private extension BinderImporter {
default: break default: break
} }
// Try numeric parse first (e.g. "0" → zero, "3" → three)
if let val = Int(crText), let cr = ChallengeRating(rawValue: String(val)) { if let val = Int(crText), let cr = ChallengeRating(rawValue: String(val)) {
return cr return cr
} }
// Handle fractions like "1/2", "5/2"
if let rangeIdx = crText.range(of: "/") { if let rangeIdx = crText.range(of: "/") {
let numStr = crText[..<rangeIdx.lowerBound] let numStr = crText[..<rangeIdx.lowerBound]
let denStr = crText[rangeIdx.upperBound...] let denStr = crText[rangeIdx.upperBound...]
@@ -309,7 +287,7 @@ private extension BinderImporter {
return .zero return .zero
} }
/// Process cardTraits from Android Monster.exportCards (grouped by actionSection). @MainActor
static func processCardTraits(_ card: MonsterCard, into monster: MonsterViewModel) { static func processCardTraits(_ card: MonsterCard, into monster: MonsterViewModel) {
guard let traits = card.cardTraits else { return } guard let traits = card.cardTraits else { return }
@@ -317,24 +295,21 @@ private extension BinderImporter {
guard let displayName = trait.displayName, !displayName.isEmpty else { continue } guard let displayName = trait.displayName, !displayName.isEmpty else { continue }
if let isHeader = trait.isActionHeader, isHeader == 1 { if let isHeader = trait.isActionHeader, isHeader == 1 {
// Section header like "Spellcasting", "Legendary Actions" etc.
if displayName.lowercased().contains("legendary") { if displayName.lowercased().contains("legendary") {
monster.legendaryActions.append(AbilityViewModel(displayName, trait.desc ?? "")) monster.legendaryActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
} else if displayName.lowercased().contains("lair") || displayName.lowercased().contains("mythic") { } else if displayName.lowercased().contains("lair") || displayName.lowercased().contains("mythic") {
monster.lairActions.append(AbilityViewModel(displayName, trait.desc ?? "")) monster.lairActions.append(AbilityViewModel(displayName, trait.desc ?? ""))
} else { } else {
// Action section header gets added to actions as a category label.
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? "")) monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
} }
} else if let section = trait.actionSection { } else if let section = trait.actionSection {
switch section { switch section {
case 0: monster.bonusActions .append(AbilityViewModel(displayName, trait.desc ?? "")) case 0: monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
case 1: monster.actions .append(AbilityViewModel(displayName, trait.desc ?? "")) case 1: monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
case 2: monster.reactions .append(AbilityViewModel(displayName, trait.desc ?? "")) case 2: monster.reactions.append(AbilityViewModel(displayName, trait.desc ?? ""))
default: break default: break
} }
} else { } else {
// No section key — add to actions by default (standard Android behavior)
monster.actions.append(AbilityViewModel(displayName, trait.desc ?? "")) monster.actions.append(AbilityViewModel(displayName, trait.desc ?? ""))
} }
} }

View File

@@ -2,31 +2,33 @@
// EntityImporter.swift // EntityImporter.swift
// MonsterCards // 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 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. /// Each importer decides whether it can handle the input and then parses it into domain objects.
protocol EntityImporter { protocol EntityImporter {
/// Determines whether this importer can handle the given input string or payload. /// 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`. /// 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, /// Registry type that dispatches parsing to the correct importer based on format sniffing.
/// mirroring the Android `MonsterImportHelper` dispatcher logic.
enum ImporterRegistry { enum ImporterRegistry {
private static let importers: [EntityImporter] = [ @MainActor
MonsterJsonImporter(), private static var importers: [any EntityImporter.Type] {
DnDBeyondImporter(), [
Open5eImporter(), TetraCubeMonsterImporter.self,
BinderImporter(), BinderImporter.self,
] ]
}
/// Detects which importer handles the input and delegates parse to it. /// Detects which importer handles the input and delegates parse to it.
@MainActor @MainActor
@@ -40,7 +42,7 @@ enum ImporterRegistry {
let monster = try importer.parse(input) let monster = try importer.parse(input)
return monster return monster
} catch { } catch {
print("Importer \(type(of: importer)) error: \(error)") print("Importer \(importer) error: \(error)")
continue 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 import Foundation
public class AbilityViewModel: NSObject, ObservableObject, Identifiable, NSSecureCoding { public class AbilityViewModel: NSObject, ObservableObject, Identifiable, NSSecureCoding {
public static var supportsSecureCoding = true public static let supportsSecureCoding = true
public func encode(with coder: NSCoder) { public func encode(with coder: NSCoder) {
coder.encode(self.name, forKey: "name") 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. // TODO: split this into separate Model and ViewModel classes later.
public class LanguageViewModel : NSObject, ObservableObject, Comparable, Identifiable, NSSecureCoding { public class LanguageViewModel : NSObject, ObservableObject, Comparable, Identifiable, NSSecureCoding {
public static var supportsSecureCoding = true public static let supportsSecureCoding = true
public func encode(with coder: NSCoder) { public func encode(with coder: NSCoder) {
coder.encode(self.name, forKey: "name") 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 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 { func modifier(forMonster: MonsterViewModel) -> Int {
let proficiencyBonus = Double(forMonster.proficiencyBonus) let proficiencyBonus = Double(forMonster.proficiencyBonus)
let abilityScoreModifier = Double(forMonster.abilityModifierForAbilityScore(abilityScore)) let abilityScoreModifier = Double(forMonster.abilityModifierForAbilityScore(abilityScore))

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?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=""> <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"> <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="name" attributeType="String" defaultValueString=""/>
<attribute name="sortOrder" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> <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"/> <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="collectionId" attributeType="String" defaultValueString="00000000-0000-0000-0000-000000000000"/>
<attribute name="monsterId" 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"/> <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>
<entity name="Monster" representedClassName="Monster" syncable="YES" codeGenerationType="category"> <entity name="Monster" representedClassName="Monster" syncable="YES" codeGenerationType="category">
<attribute name="abilities" optional="YES" attributeType="Transformable" valueTransformerName="AbilityViewModelValueTransformer" customClassName="[AbilityViewModel]"/> <attribute name="abilities" optional="YES" attributeType="Transformable" valueTransformerName="AbilityViewModelValueTransformer" customClassName="[AbilityViewModel]"/>
@@ -84,7 +84,7 @@
<attribute name="advantage" attributeType="String" defaultValueString="none"/> <attribute name="advantage" attributeType="String" defaultValueString="none"/>
<attribute name="name" attributeType="String" defaultValueString=""/> <attribute name="name" attributeType="String" defaultValueString=""/>
<attribute name="proficiency" attributeType="String" defaultValueString="none"/> <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> </entity>
<elements> <elements>
<element name="Monster" positionX="-63" positionY="-18" width="128" height="1154"/> <element name="Monster" positionX="-63" positionY="-18" width="128" height="1154"/>

View File

@@ -10,7 +10,8 @@ import CoreData
struct PersistenceController { struct PersistenceController {
static let shared = PersistenceController() static let shared = PersistenceController()
static var preview: PersistenceController = { @MainActor
static let preview: PersistenceController = {
let result = PersistenceController(inMemory: true) let result = PersistenceController(inMemory: true)
let viewContext = result.container.viewContext let viewContext = result.container.viewContext
let monsters: [Monster] = [ let monsters: [Monster] = [
@@ -21,10 +22,9 @@ struct PersistenceController {
do { do {
try viewContext.save() try viewContext.save()
} catch { } catch {
// Replace this implementation with code to handle the error appropriately. // TODO: 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.
let nsError = error as NSError let nsError = error as NSError
// fatalError("TOMHICKS_Unresolved error \(nsError), \(nsError.userInfo)") print("Unresolved error \(nsError), \(nsError.userInfo)")
} }
return result return result
}() }()
@@ -39,6 +39,7 @@ struct PersistenceController {
container.viewContext.automaticallyMergesChangesFromParent = true container.viewContext.automaticallyMergesChangesFromParent = true
container.loadPersistentStores(completionHandler: { (storeDescription, error) in container.loadPersistentStores(completionHandler: { (storeDescription, error) in
if let error = error as NSError? { if let error = error as NSError? {
// TODO: Handle persistent store load failure gracefully instead of fatalError
fatalError("Unresolved error \(error), \(error.userInfo)") fatalError("Unresolved error \(error), \(error.userInfo)")
} }
}) })

View File

@@ -6,16 +6,70 @@
// //
import SwiftUI import SwiftUI
import CoreData
struct Collections: View { 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 { 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 { #Preview {
static var previews: some View { Collections()
Collections().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
} }

View File

@@ -3,41 +3,60 @@
// MonsterPreview // MonsterPreview
// //
// Created by Tom Hicks on 4/7/21. // Created by Tom Hicks on 4/7/21.
// Last modified by Tom Hicks on 9/23/26.
// //
import UIKit import UIKit
import QuickLook import QuickLook
import SwiftUI 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) { func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) {
Task { @MainActor in
let document = MonsterDocument(fileURL: url) 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) handler(nil)
}) }
} }
func presentMonsterViewController(for document: MonsterDocument) { // MARK: - Private Presentation
let monsterViewModel = MonsterImportHelper.import5ESBMonster(document.monsterDTO ?? MonsterDTO())
let monsterViewController = UIHostingController(rootView: MonsterDetailView(viewModel: monsterViewModel)) private func presentMonsterViewController(for dto: MonsterDTO) {
monsterViewController.loadViewIfNeeded() let monsterViewModel = MonsterImportHelper.import5ESBMonster(dto)
monsterViewController.view.layoutIfNeeded() let hostingController = UIHostingController(rootView: MonsterDetailView(viewModel: monsterViewModel))
addChild(monsterViewController)
view.addSubview(monsterViewController.view) // Add as child view controller
monsterViewController.didMove(toParent: self) 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([ NSLayoutConstraint.activate([
monsterView.leftAnchor.constraint(equalTo: view.leftAnchor), subview.leadingAnchor.constraint(equalTo: view.leadingAnchor),
monsterView.rightAnchor.constraint(equalTo: view.rightAnchor), subview.trailingAnchor.constraint(equalTo: view.trailingAnchor),
monsterView.topAnchor.constraint(equalTo: view.topAnchor), subview.topAnchor.constraint(equalTo: view.topAnchor),
monsterView.bottomAnchor.constraint(equalTo: view.bottomAnchor), subview.bottomAnchor.constraint(equalTo: view.bottomAnchor)
]) ])
}
}
hostingController.didMove(toParent: self)
}
} }