├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── App ├── App │ ├── App │ │ ├── AppInvocation.swift │ │ ├── AppState.swift │ │ ├── ColumnNavigationView.swift │ │ ├── ColumnNavigationViewState.swift │ │ ├── ConstructApp.swift │ │ ├── CrashReporter.swift │ │ ├── EntityChangeObserver.swift │ │ ├── Environment.swift │ │ ├── TabNavigationView.swift │ │ ├── TabNavigationViewState.swift │ │ └── WelcomeView.swift │ ├── Base.lproj │ │ └── LaunchScreen.storyboard │ ├── Campaign │ │ ├── CampaignBrowser.swift │ │ └── View │ │ │ ├── CampaignBrowseView.swift │ │ │ ├── CampaignBrowseViewState.swift │ │ │ └── CampaignBrowserContainerView.swift │ ├── Combatant │ │ ├── CombatantFeature.swift │ │ ├── CombatantResourcesView.swift │ │ ├── CombatantResourcesViewState.swift │ │ ├── CombatantTagEditView.swift │ │ ├── CombatantTagEditViewState.swift │ │ ├── CombatantTagPopover.swift │ │ ├── CombatantTagsView.swift │ │ ├── CombatantTagsViewState.swift │ │ ├── CombatantTrackerEditView.swift │ │ ├── EffectDurationEditView.swift │ │ └── InlineCombatantTagsView.swift │ ├── Compendium │ │ ├── CompendiumImportFeature.swift │ │ └── View │ │ │ ├── CompendiumContainerView.swift │ │ │ ├── CompendiumFilterSheet.swift │ │ │ ├── CompendiumIndexState.swift │ │ │ ├── CompendiumIndexView.swift │ │ │ ├── CompendiumItemDetailView.swift │ │ │ ├── CompendiumQuery.swift │ │ │ ├── CreatureEditView │ │ │ ├── CreatureEditView.swift │ │ │ ├── CreatureEditViewState.swift │ │ │ ├── NamedStatBlockContentItemEditView.swift │ │ │ └── NamedStatBlockContentItemEditViewState.swift │ │ │ └── ItemDetailView │ │ │ ├── CompendiumEntryDetailViewState.swift │ │ │ ├── CompendiumItemGroupDetailView.swift │ │ │ ├── CompendiumItemGroupEditView.swift │ │ │ └── StatBlockView.swift │ ├── Construct.entitlements │ ├── DiceRoller │ │ ├── FloatingDiceRollerView.swift │ │ └── FloatingDiceRollerViewState.swift │ ├── Encouter │ │ ├── AddCombatantCompendiumView.swift │ │ ├── AddCombatantDetailView.swift │ │ ├── AddCombatantState.swift │ │ ├── AddCombatantView.swift │ │ ├── Combatant+SwiftUI.swift │ │ ├── CombatantDetailView.swift │ │ ├── CombatantDetailViewState.swift │ │ ├── CombatantResourceTrackerView.swift │ │ ├── CombatantRow.swift │ │ ├── EncounterDetailView.swift │ │ ├── EncounterDetailView │ │ │ ├── GenerateCombatantTraitsView.swift │ │ │ ├── GenerateCombatantTraitsViewState.swift │ │ │ └── RunningEncounterActionBar.swift │ │ ├── EncounterDetailViewState.swift │ │ ├── EncounterDifficultyView.swift │ │ ├── EncounterFeature.swift │ │ ├── EncounterSettingsView.swift │ │ ├── HealthDialog.swift │ │ ├── HealthFractionView.swift │ │ ├── InitiativeDialog.swift │ │ ├── RunningEncounterEventRow.swift │ │ ├── RunningEncounterLogView.swift │ │ └── RunningEncounterLogViewState.swift │ ├── Fixtures │ │ └── SampleEncounter.swift │ ├── Info.plist │ ├── Models │ │ └── DiceExtensions.swift │ ├── Preview Content │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── Reference │ │ ├── AddCombatantReferenceItemView.swift │ │ ├── ReferenceContext.swift │ │ ├── ReferenceItemView.swift │ │ ├── ReferenceItemViewState.swift │ │ ├── ReferenceView.swift │ │ ├── ReferenceViewPreference.swift │ │ └── ReferenceViewState.swift │ ├── Resources │ │ └── roll.ahap │ ├── Settings │ │ ├── Fixtures │ │ │ ├── ogl.md │ │ │ └── software_licenses.md │ │ └── SettingsView.swift │ ├── Sourcery │ │ └── NavigationNode.generated.swift │ └── UI │ │ ├── ActivityButton.swift │ │ ├── Checkbox.swift │ │ ├── ClearableTextField.swift │ │ ├── DocumentPicker.swift │ │ ├── EqualSize.swift │ │ ├── NumberEntryPopover.swift │ │ ├── NumberEntryView.swift │ │ ├── NumberEntryView │ │ └── NumberPadView.swift │ │ ├── SafariView.swift │ │ ├── SearchField.swift │ │ ├── SectionContainer.swift │ │ ├── SheetNavigationContainer.swift │ │ ├── SimpleList.swift │ │ ├── StateDrivenNavigationView.swift │ │ ├── Style.swift │ │ ├── TabbedDocumentView.swift │ │ └── WebView.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── 100.png │ │ ├── 1024.png │ │ ├── 114.png │ │ ├── 120.png │ │ ├── 144.png │ │ ├── 152.png │ │ ├── 167.png │ │ ├── 180.png │ │ ├── 20.png │ │ ├── 29.png │ │ ├── 40.png │ │ ├── 50.png │ │ ├── 57.png │ │ ├── 58.png │ │ ├── 60.png │ │ ├── 72.png │ │ ├── 76.png │ │ ├── 80.png │ │ ├── 87.png │ │ └── Contents.json │ ├── Contents.json │ ├── icon.imageset │ │ ├── Contents.json │ │ ├── dark.pdf │ │ └── light.pdf │ └── tabbar_d20.imageset │ │ ├── Contents.json │ │ ├── d20@2x.png │ │ └── d20@3x.png ├── Construct.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── IDEWorkspaceChecks.plist │ │ │ └── swiftpm │ │ │ └── Package.resolved │ └── xcshareddata │ │ ├── xcbaselines │ │ └── E9F9EB472319694100A70D11.xcbaseline │ │ │ ├── 0D4D54AF-341D-47E1-81C9-C1008C699A19.plist │ │ │ └── Info.plist │ │ └── xcschemes │ │ ├── Construct.xcscheme │ │ ├── DiceRollerAppClip.xcscheme │ │ └── UnitTests.xcscheme ├── DiceRollerAppClip │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── ContentView.swift │ ├── DiceRollerAppClip.entitlements │ ├── DiceRollerAppClipApp.swift │ ├── Info.plist │ └── Preview Content │ │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Package.swift └── UnitTests │ ├── AlignmentTest.swift │ ├── AppStoreScreenshotTests.swift │ ├── CampaignBrowserTest.swift │ ├── CompendiumEntryTest.swift │ ├── CompendiumImporterTest.swift │ ├── CreatureActionParserTest.swift │ ├── CreatureEditViewStateTest.swift │ ├── DDBCharacterDataSourceReaderTest.swift │ ├── DDBCharacterSheetURLParserTest.swift │ ├── DataSourceReaderParsersTest.swift │ ├── DiceExpressionParserTest.swift │ ├── DiceExpressionTest.swift │ ├── DndBeyondExternalCompendiumTest.swift │ ├── EncounterDetailTest.swift │ ├── EncounterDifficultyTest.swift │ ├── FileDataSourceTest.swift │ ├── Fixtures.swift │ ├── Fixtures │ ├── compendium.xml │ ├── ddb_bass.json │ ├── ddb_ishmadon.json │ ├── ddb_misty.json │ ├── ddb_riverine.json │ ├── ddb_sarovin.json │ ├── ddb_thrall.json │ └── ii_mm.json │ ├── GenerateCombatantTraitsViewTest.swift │ ├── Helpers.swift │ ├── ImprovedInitiativeDataSourceReaderTest.swift │ ├── Info.plist │ ├── NumberPadTest.swift │ ├── Open5eMonsterDataSourceReaderTest.swift │ ├── ParseableCreatureActionTest.swift │ ├── ParseableCreatureFeatureTest.swift │ ├── ParserCombinatorTest.swift │ ├── RolledDiceExpressionTest.swift │ ├── RunningEncounterTest.swift │ ├── StatBlockCombatantResourcesTest.swift │ ├── XMLCompendiumDataSourceReaderTest.swift │ └── __Snapshots__ │ ├── AppStoreScreenshotTests │ ├── test_iPad_screenshot1.iPadPro129_3rd_gen.png │ ├── test_iPad_screenshot1.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot2.iPadPro129_3rd_gen.png │ ├── test_iPad_screenshot2.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot3.iPadPro129_3rd_gen.png │ ├── test_iPad_screenshot3.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot4.iPadPro129_3rd_gen.png │ ├── test_iPad_screenshot4.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot5.iPadPro129_3rd_gen.png │ ├── test_iPad_screenshot5.iPadPro129_4th_gen.png │ ├── test_iPhone_screenshot1.iPhone55.png │ ├── test_iPhone_screenshot1.iPhone65.png │ ├── test_iPhone_screenshot2.iPhone55.png │ ├── test_iPhone_screenshot2.iPhone65.png │ ├── test_iPhone_screenshot3.iPhone55.png │ ├── test_iPhone_screenshot3.iPhone65.png │ ├── test_iPhone_screenshot4.iPhone55.png │ ├── test_iPhone_screenshot4.iPhone65.png │ ├── test_iPhone_screenshot5.iPhone55.png │ ├── test_iPhone_screenshot5.iPhone65.png │ ├── test_iPhone_screenshot6.iPhone55.png │ └── test_iPhone_screenshot6.iPhone65.png │ ├── CreatureActionParserTest │ └── testAllMonsterActions.1.txt │ ├── Open5eMonsterDataSourceReaderTest │ └── test.1.txt │ └── XMLCompendiumDataSourceReaderTest │ └── test.1.txt ├── Gemfile ├── Gemfile.lock ├── LICENSE.md ├── Package.resolved ├── Package.swift ├── README.md ├── SourceryTemplates ├── KeyValueStoreEntityDecoding.stencil ├── NavigationNode.stencil ├── ParseableGameModels.stencil └── XMLDocumentElement.stencil ├── Sources ├── ActionResolutionFeature │ ├── ActionDescriptionView.swift │ ├── ActionDescriptionViewState.swift │ ├── ActionResolutionView.swift │ ├── ActionResolutionViewState.swift │ ├── AnimatedRollView.swift │ ├── DiceActionView.swift │ └── DiceActionView │ │ ├── DiceAction.swift │ │ └── DiceActionViewState.swift ├── Compendium │ ├── Compendium.swift │ ├── CompendiumMetadata.swift │ ├── External │ │ └── DndBeyondExternalCompendium.swift │ ├── Fixtures │ │ ├── monsters.json │ │ └── spells.json │ ├── SourceryOutput │ │ └── XMLDocumentElement.generated.swift │ └── Sources │ │ ├── CompendiumDataSource.swift │ │ ├── CompendiumDataSourceReader.swift │ │ ├── CompendiumImporter.swift │ │ ├── DataSources │ │ ├── FileDataSource.swift │ │ ├── Open5eAPIDataSource.swift │ │ └── URLDataSource.swift │ │ ├── DefaultContent.swift │ │ ├── Environment.swift │ │ ├── ExternalCompendium.swift │ │ └── Readers │ │ ├── D&D Beyond │ │ ├── DDBCharacterSheetURLParser.swift │ │ └── DDBModels.swift │ │ ├── DDBCharacterDataSourceReader.swift │ │ ├── Improved Initiative │ │ └── ImprovedInitiativeModels.swift │ │ ├── ImprovedInitiativeDataSourceReader.swift │ │ ├── Open5e │ │ └── O5eModels.swift │ │ ├── Open5eDataSourceReader.swift │ │ ├── SharedLiterals.swift │ │ └── XMLCompendiumDataSourceReader.swift ├── DatabaseInitTool │ └── main.swift ├── Dice │ ├── DiceExpression.swift │ ├── DiceExpressionParser.swift │ └── RolledDiceExpression.swift ├── DiceRollerFeature │ ├── DiceCalculatorView.swift │ ├── DiceExtensions.swift │ ├── DiceLogFeedView.swift │ ├── DiceLogPublisher.swift │ ├── DiceRollerView.swift │ └── DiceRollerViewState.swift ├── DiceRollerInvocation │ └── DiceRollerInvocation.swift ├── GameModels │ ├── CampaignNode.swift │ ├── Character.swift │ ├── CombatantTag.swift │ ├── CompendiumEntry.swift │ ├── CompendiumImportJob.swift │ ├── CompendiumItem.swift │ ├── CompendiumItemGroup.swift │ ├── CompendiumItemKey.swift │ ├── CompendiumItemReference.swift │ ├── CompendiumItemType.swift │ ├── CompendiumParseableVisitor.swift │ ├── CompendiumRealm.swift │ ├── CompendiumSourceDocument.swift │ ├── Creature.swift │ ├── CreatureActionParser.swift │ ├── Domain │ │ ├── AdHocCombatant.swift │ │ ├── Combatant.swift │ │ ├── CompendiumCombatant.swift │ │ └── Encounter.swift │ ├── Duration.swift │ ├── EncounterDifficulty.swift │ ├── LimitedUse.swift │ ├── ModelsParseableVisitor.swift │ ├── Monster.swift │ ├── ParseableCreatureAction.swift │ ├── ParseableCreatureFeature.swift │ ├── ParseableMonsterType.swift │ ├── ParseableSpellDescription.swift │ ├── Preferences.swift │ ├── ProficiencyBonus.swift │ ├── RunningEncounter.swift │ ├── SourceryOutput │ │ └── ParseableGameModels.generated.swift │ ├── Spell.swift │ ├── StatBlock.swift │ ├── StatBlockCombatantResources.swift │ └── TextAnnotation.swift ├── Helpers │ ├── AnyCodingKey.swift │ ├── Apply.swift │ ├── ArrayBuilder.swift │ ├── Async.swift │ ├── AsyncMapErrorSequence.swift │ ├── AsyncReduce.swift │ ├── CancellableBag.swift │ ├── CrashReporter.swift │ ├── CurrentValue.swift │ ├── DecodableDefault.swift │ ├── Either.swift │ ├── Environment.swift │ ├── EquatablePropertyWrappers.swift │ ├── Extensions.swift │ ├── Fraction.swift │ ├── HTTPClient.swift │ ├── Located.swift │ ├── Map.swift │ ├── Memoize.swift │ ├── Migrated.swift │ ├── ModifierFormatter.swift │ ├── Navigation.swift │ ├── PagingData.swift │ ├── Parseable.swift │ ├── ParserCombinator.swift │ ├── Retain.swift │ ├── Sort.swift │ ├── With.swift │ └── Zip.swift ├── MechMuse │ ├── CreatureActionDescription.swift │ ├── GeneratedCombatantTraits.swift │ ├── MechMuse.swift │ └── PromptConvertible.swift ├── Open5eAPI │ ├── Models.swift │ └── Open5eAPIClient.swift ├── OpenAIClient │ ├── JSONSchema.swift │ ├── Models.swift │ ├── OpenAIClient.swift │ └── Streaming.swift ├── Persistence │ ├── CompendiumKeyValueEntities.swift │ ├── Database.swift │ ├── DatabaseCompendium.swift │ ├── Entity.swift │ ├── Environment.swift │ ├── GRDB │ │ └── Migrations.swift │ ├── GameModelsKeyValueEntities.swift │ ├── KeyValueStore.swift │ ├── ParseableKeyValueRecordManager.swift │ └── SourceryOutput │ │ └── KeyValueStoreEntityDecoding.generated.swift ├── PersistenceTestSupport │ ├── InitialDatabase.swift │ └── Resources │ │ └── initial.sqlite └── SharedViews │ ├── AnimatingSymbol.swift │ ├── AutoSizingSheet.swift │ ├── EqualWidthLayout.swift │ ├── FlipTransition.swift │ ├── FlowLayout.swift │ ├── PopoverHost.swift │ ├── PropagateSize.swift │ ├── RoundedButton.swift │ ├── RoundedButtonToolbar.swift │ └── SimpleButton.swift ├── Tests ├── CompendiumTests │ └── Open5eAPIDataSourceTest.swift ├── GameModelsTests │ ├── CompendiumItemReferenceTest.swift │ └── StatBlockTest.swift ├── HelpersTests │ ├── AsyncReduceTest.swift │ ├── FractionTest.swift │ ├── MapTest.swift │ ├── PagingDataTest.swift │ └── RetainTest.swift ├── MechMuseTests │ ├── CreatureActionDescriptionTest.swift │ ├── EncounterCombatantsTraitsTest.swift │ └── MechMuseTest.swift ├── OpenAIClientTests │ └── OpenAIClientTest.swift └── PersistenceTests │ ├── DatabaseTest.swift │ ├── KeyValueStoreEntityTest.swift │ └── KeyValueStoreTest.swift ├── assets ├── logo.png ├── screenshot1.png ├── screenshot2.png ├── screenshot3.png └── screenshot4.png ├── fastlane ├── Appfile ├── Fastfile ├── Pluginfile ├── Preview.html ├── README.md └── screenshots │ └── en-US │ ├── ipadPro129_test_iPad_screenshot1.iPadPro129_3rd_gen.png │ ├── ipadPro129_test_iPad_screenshot2.iPadPro129_3rd_gen.png │ ├── ipadPro129_test_iPad_screenshot3.iPadPro129_3rd_gen.png │ ├── ipadPro129_test_iPad_screenshot4.iPadPro129_3rd_gen.png │ ├── ipadPro129_test_iPad_screenshot5.iPadPro129_3rd_gen.png │ ├── test_iPad_screenshot1.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot2.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot3.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot4.iPadPro129_4th_gen.png │ ├── test_iPad_screenshot5.iPadPro129_4th_gen.png │ ├── test_iPhone_screenshot1.iPhone55.png │ ├── test_iPhone_screenshot1.iPhone65.png │ ├── test_iPhone_screenshot2.iPhone55.png │ ├── test_iPhone_screenshot2.iPhone65.png │ ├── test_iPhone_screenshot3.iPhone55.png │ ├── test_iPhone_screenshot3.iPhone65.png │ ├── test_iPhone_screenshot4.iPhone55.png │ ├── test_iPhone_screenshot4.iPhone65.png │ ├── test_iPhone_screenshot5.iPhone55.png │ ├── test_iPhone_screenshot5.iPhone65.png │ ├── test_iPhone_screenshot6.iPhone55.png │ └── test_iPhone_screenshot6.iPhone65.png └── sourcery-gen.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot4.2.png filter=lfs diff=lfs merge=lfs -text 2 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot5.1.png filter=lfs diff=lfs merge=lfs -text 3 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot5.2.png filter=lfs diff=lfs merge=lfs -text 4 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot1.1.png filter=lfs diff=lfs merge=lfs -text 5 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot5.1.png filter=lfs diff=lfs merge=lfs -text 6 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot5.2.png filter=lfs diff=lfs merge=lfs -text 7 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot3.1.png filter=lfs diff=lfs merge=lfs -text 8 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot3.2.png filter=lfs diff=lfs merge=lfs -text 9 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot6.1.png filter=lfs diff=lfs merge=lfs -text 10 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot2.2.png filter=lfs diff=lfs merge=lfs -text 11 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot4.2.png filter=lfs diff=lfs merge=lfs -text 12 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot2.1.png filter=lfs diff=lfs merge=lfs -text 13 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot1.1.png filter=lfs diff=lfs merge=lfs -text 14 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot4.1.png filter=lfs diff=lfs merge=lfs -text 15 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot2.2.png filter=lfs diff=lfs merge=lfs -text 16 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot1.2.png filter=lfs diff=lfs merge=lfs -text 17 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot4.1.png filter=lfs diff=lfs merge=lfs -text 18 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPhone_screenshot6.2.png filter=lfs diff=lfs merge=lfs -text 19 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot1.2.png filter=lfs diff=lfs merge=lfs -text 20 | UnitTests/__Snapshots__/AppStoreScreenshotTests/test_iPad_screenshot2.1.png filter=lfs diff=lfs merge=lfs -text 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "Construct CI" 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: macOS-12 9 | strategy: 10 | matrix: 11 | destination: ["platform=iOS Simulator,OS=16.1,name=iPhone 13 mini"] # needs to be a @3x device for the snapshots 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: LFS pull 15 | run: git lfs pull 16 | - name: Select Xcode 14.1 17 | run: sudo xcode-select -s /Applications/Xcode_14.1.app 18 | - name: iOS - ${{ matrix.destination }} 19 | run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "App/Construct.xcodeproj" -scheme "Construct" -destination "${{ matrix.destination }}" -derivedDataPath ./build -clonedSourcePackagesDirPath ~/Library/Developer/Xcode/DerivedData/Construct clean test | xcpretty 20 | - name: Archive test artifacts 21 | uses: actions/upload-artifact@v3 22 | if: always() 23 | with: 24 | name: test-results 25 | path: | 26 | build/Logs/Test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # CocoaPods 2 | # 3 | # We recommend against adding the Pods directory to your .gitignore. However 4 | # you should judge for yourself, the pros and cons are mentioned at: 5 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control? 6 | # 7 | # Pods/ 8 | .DS_Store 9 | build/ 10 | *.pbxuser 11 | !default.pbxuser 12 | *.mode1v3 13 | !default.mode1v3 14 | *.mode2v3 15 | !default.mode2v3 16 | *.perspectivev3 17 | !default.perspectivev3 18 | !default.xcworkspace 19 | xcuserdata 20 | profile 21 | *.moved-aside 22 | DerivedData 23 | .idea/ 24 | *.xcscmblueprint 25 | .build 26 | Pods 27 | fastlane/report.xml 28 | Construct.app.dSYM.zip 29 | Construct.ipa 30 | -------------------------------------------------------------------------------- /App/App/App/AppInvocation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Invocations.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 23/08/2022. 6 | // Copyright © 2022 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import DiceRollerInvocation 11 | import URLRouting 12 | 13 | enum AppInvocation { 14 | case diceRoller(DiceRollerInvocation) 15 | } 16 | 17 | let appInvocationRouter = OneOf { 18 | Route(.case(AppInvocation.diceRoller)) { 19 | Host("roll.construct5e.app") 20 | diceRollerInvocationRouter 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /App/App/App/ColumnNavigationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColumnNavigationView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 28/09/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import ComposableArchitecture 11 | import Introspect 12 | 13 | struct ColumnNavigationView: View { 14 | let store: Store 15 | 16 | var body: some View { 17 | return ZStack { 18 | HStack(spacing: 0) { 19 | CampaignBrowserContainerView(store: store.scope(state: { $0.campaignBrowse }, action: { .campaignBrowse($0) })) 20 | .frame(width: 360) 21 | 22 | Divider().ignoresSafeArea() 23 | 24 | ReferenceView(store: store.scope(state: { $0.referenceView }, action: { .referenceView($0) })) 25 | .zIndex(-1) 26 | } 27 | .environment(\.appNavigation, .column) 28 | 29 | FloatingDiceRollerContainerView(store: store.scope( 30 | state: { $0.diceCalculator }, 31 | action: { .diceCalculator($0) } 32 | )) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /App/App/App/CrashReporter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CrashReporter.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 22/09/2022. 6 | // Copyright © 2022 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import AppCenterCrashes 11 | import GameModels 12 | import Helpers 13 | 14 | extension CrashReporter { 15 | static let appCenter = CrashReporter( 16 | registerUserPermission: { permission in 17 | switch permission { 18 | case .dontSend: 19 | Crashes.notify(with: .dontSend) 20 | case .send: 21 | Crashes.notify(with: .send) 22 | case .sendAlways: 23 | Crashes.notify(with: .always) 24 | } 25 | }, 26 | trackError: { report in 27 | Crashes.trackException( 28 | ExceptionModel( 29 | // NSError.domain gives the fully qualified name of the Swift error type 30 | withType: (report.error as NSError).domain, 31 | exceptionMessage: String(describing: report.error), 32 | stackTrace: Array(Thread.callStackSymbols[2...]) 33 | ), 34 | properties: report.properties, 35 | attachments: report.attachments.map { name, text in 36 | ErrorAttachmentLog(filename: name, attachmentText: text) 37 | } 38 | ) 39 | } 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /App/App/App/TabNavigationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TabNavigationView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 28/09/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | import DiceRollerFeature 13 | 14 | struct TabNavigationView: View { 15 | @EnvironmentObject var env: Environment 16 | var store: Store 17 | 18 | var body: some View { 19 | WithViewStore(store, removeDuplicates: { $0.selectedTab == $1.selectedTab }) { viewStore in 20 | TabView( 21 | selection: viewStore.binding( 22 | get: { $0.selectedTab }, 23 | send: { TabNavigationViewAction.selectedTab($0) } 24 | ) 25 | ) { 26 | CampaignBrowserContainerView(store: store.scope(state: { $0.campaignBrowser }, action: { .campaignBrowser($0) })) 27 | .tabItem { 28 | Image(systemName: "shield") 29 | Text("Adventure") 30 | } 31 | .tag(TabNavigationViewState.Tabs.campaign) 32 | 33 | CompendiumContainerView(store: store.scope(state: { $0.compendium }, action: { .compendium($0) })) 34 | .tabItem { 35 | Image(systemName: "book") 36 | Text("Compendium") 37 | } 38 | .tag(TabNavigationViewState.Tabs.compendium) 39 | 40 | DiceRollerView(store: self.store.scope(state: { $0.diceRoller }, action: { .diceRoller($0) })) 41 | .tabItem { 42 | Image("tabbar_d20") 43 | Text("Dice") 44 | } 45 | .tag(TabNavigationViewState.Tabs.diceRoller) 46 | } 47 | .environment(\.appNavigation, .tab) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /App/App/Campaign/CampaignBrowser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CampaignBrowser.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 10/10/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Persistence 11 | import GameModels 12 | 13 | class CampaignBrowser { 14 | /// The number of existing nodes (excluding root node) when the app is first opened 15 | static let initialSpecialNodeCount = 1 16 | 17 | let store: KeyValueStore 18 | 19 | init(store: KeyValueStore) { 20 | self.store = store 21 | } 22 | 23 | func nodes(in node: CampaignNode) throws -> [CampaignNode] { 24 | return try store.fetchAll(node.keyPrefixForFetchingDirectChildren) 25 | } 26 | 27 | func put(_ node: CampaignNode) throws { 28 | try store.put(node) 29 | } 30 | 31 | func remove(_ node: CampaignNode, recursive: Bool = true) throws { 32 | var nodes = [(node, false)] 33 | while let (next, didAddChildren) = nodes.popLast() { 34 | if !didAddChildren { 35 | let children = try self.nodes(in: next) 36 | nodes.append((next, true)) 37 | nodes.append(contentsOf: children.map { ($0, false) }) 38 | } else { 39 | if let contents = next.contents { 40 | _ = try store.remove(contents.key) 41 | } 42 | _ = try store.remove(next.key) 43 | } 44 | } 45 | } 46 | 47 | func move(_ node: CampaignNode, to destination: CampaignNode) throws { 48 | var newNode = node 49 | newNode.parentKeyPrefix = destination.keyPrefixForChildren.rawValue 50 | 51 | try store.put(newNode) 52 | try store.remove(node.key) 53 | } 54 | 55 | /// Returns the total number of nodes 56 | func nodeCount() throws -> Int { 57 | return try store.count(CampaignNode.keyPrefix) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /App/App/Campaign/View/CampaignBrowserContainerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CampaignBrowserContainerView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 11/10/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | 13 | struct CampaignBrowserContainerView: View { 14 | @EnvironmentObject var env: Environment 15 | var store: Store 16 | 17 | var body: some View { 18 | NavigationStack { 19 | CampaignBrowseView(store: store) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /App/App/Combatant/CombatantResourcesViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombatantResourcesViewState.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 03/02/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ComposableArchitecture 11 | import Helpers 12 | import GameModels 13 | 14 | struct CombatantResourcesViewState: NavigationStackItemState, Equatable { 15 | var combatant: Combatant 16 | var editState: CombatantTrackerEditViewState? 17 | 18 | var navigationStackItemStateId: String { 19 | "\(combatant.id.rawValue.uuidString):CombatantResourcesViewState" 20 | } 21 | 22 | var navigationTitle: String { "Limited resources" } 23 | 24 | static let reducer: AnyReducer = AnyReducer.combine( 25 | CombatantTrackerEditViewState.reducer.optional().pullback(state: \.editState, action: /CombatantResourcesViewAction.editState), 26 | AnyReducer { state, action, env in 27 | switch action { 28 | case .combatant: break // bubble-up 29 | case .setEditState(let s): state.editState = s 30 | case .editState(.onDoneTap): 31 | guard let res = state.editState?.resource else { return .none } 32 | state.editState = nil 33 | 34 | return .send(.combatant(.addResource(res))) 35 | case .editState: break // handled below 36 | } 37 | return .none 38 | } 39 | ) 40 | } 41 | 42 | enum CombatantResourcesViewAction: Equatable { 43 | case combatant(CombatantAction) 44 | case setEditState(CombatantTrackerEditViewState?) 45 | case editState(CombatantTrackerEditViewAction) 46 | 47 | var editStateAction: CombatantTrackerEditViewAction? { 48 | guard case .editState(let a) = self else { return nil } 49 | return a 50 | } 51 | } 52 | 53 | extension CombatantResourcesViewState { 54 | static let nullInstance = CombatantResourcesViewState(combatant: Combatant.nullInstance, editState: nil) 55 | } 56 | -------------------------------------------------------------------------------- /App/App/Combatant/CombatantTagPopover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CombatantTagPopover.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 15/11/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import SharedViews 12 | import GameModels 13 | 14 | struct CombatantTagPopover: View, Popover { 15 | var popoverId: AnyHashable { "CombatantTagPopover" } 16 | 17 | let running: RunningEncounter? 18 | let combatant: Combatant 19 | let tag: CombatantTag 20 | let onEditTap: () -> Void 21 | 22 | var body: some View { 23 | VStack { 24 | HStack { 25 | Text(tag.definition.name).bold() + Text(" (\(tag.definition.category.title))").italic() 26 | Spacer() 27 | Button(action: { 28 | self.onEditTap() 29 | }) { 30 | Text("Edit") 31 | } 32 | } 33 | Divider() 34 | 35 | VStack { 36 | tag.note.map { 37 | Text("“\($0)”") 38 | .padding(20) 39 | .frame(minHeight: 120) 40 | } 41 | 42 | durationDescription.map { 43 | Text(isTagActive ? "Expires " : "Expired ") + Text($0) 44 | } 45 | } 46 | .frame(minHeight: 120) 47 | } 48 | } 49 | 50 | var durationDescription: String? { 51 | guard let running = running, let currentRound = running.turn?.round, let turn = running.tagExpiresAt(tag, combatant), let turnCombatant = running.current.combatants[id: turn.combatantId] else { return nil } 52 | 53 | let roundString: String 54 | switch turn.round - currentRound { 55 | case ...(-2): roundString = "\(abs(turn.round - currentRound)) rounds ago" 56 | case -1: roundString = "last round" 57 | case 0: roundString = "this round" 58 | case 1: roundString = "next round" 59 | case 2...: roundString = "\(turn.round - currentRound) rounds from now" 60 | default: roundString = "round \(turn.round)" 61 | } 62 | 63 | return "\(turnCombatant.discriminatedName)'s turn, \(roundString)" 64 | } 65 | 66 | var isTagActive: Bool { 67 | guard let running = running else { return true } 68 | return running.isTagValid(tag, combatant) 69 | } 70 | 71 | func makeBody() -> AnyView { 72 | AnyView(self) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /App/App/Compendium/View/CompendiumQuery.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompendiumQuery.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 02/01/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Helpers 11 | import GameModels 12 | import Compendium 13 | 14 | extension CompendiumIndexState { 15 | 16 | struct Query: Equatable { 17 | var text: String? 18 | var filters: CompendiumFilters? 19 | var order: Order 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /App/App/Compendium/View/ItemDetailView/CompendiumItemGroupDetailView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompendiumItemGroupDetailView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 05/01/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import GameModels 12 | 13 | struct CompendiumItemGroupDetailView: View { 14 | @EnvironmentObject var env: Environment 15 | let group: CompendiumItemGroup 16 | 17 | var body: some View { 18 | VStack { 19 | SectionContainer(title: "Members") { 20 | if group.members.isEmpty { 21 | Text("This party has no members") 22 | } else { 23 | SimpleList(data: group.members, id: \.itemKey) { member in 24 | Text(member.itemTitle) 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | 32 | extension AddCombatantState: Identifiable { 33 | var id: String { "" } 34 | } 35 | -------------------------------------------------------------------------------- /App/App/Construct.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.associated-domains 6 | 7 | applinks:roll.construct5e.app 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /App/App/DiceRoller/FloatingDiceRollerViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingDiceRollerViewState.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 11/03/2021. 6 | // Copyright © 2021 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import ComposableArchitecture 11 | import DiceRollerFeature 12 | 13 | struct FloatingDiceRollerViewState: Equatable { 14 | var hidden: Bool = false 15 | var content: Content = .calculator 16 | var diceCalculator: DiceCalculatorState 17 | var diceLog = DiceLog() 18 | 19 | var canCollapse: Bool { 20 | diceCalculator.mode != .rollingExpression 21 | } 22 | 23 | enum Content: Equatable { 24 | case calculator 25 | case log 26 | } 27 | } 28 | 29 | enum FloatingDiceRollerViewAction: Equatable { 30 | case diceCalculator(DiceCalculatorAction) 31 | case hide 32 | case content(FloatingDiceRollerViewState.Content) 33 | case show 34 | case collapse 35 | case expand 36 | 37 | case onProcessRollForDiceLog(DiceLogEntry.Result, RollDescription) 38 | } 39 | 40 | extension FloatingDiceRollerViewState { 41 | static let reducer: AnyReducer = AnyReducer.combine( 42 | DiceCalculatorState.reducer.pullback(state: \.diceCalculator, action: /FloatingDiceRollerViewAction.diceCalculator, environment: { $0 }), 43 | AnyReducer { state, action, env in 44 | switch action { 45 | case .diceCalculator: break // handled above 46 | case .hide: 47 | state.hidden = true 48 | case .content(let c): 49 | state.content = c 50 | case .show: 51 | state.hidden = false 52 | state.diceCalculator.mode = .editingExpression 53 | case .collapse: 54 | state.diceCalculator.mode = .rollingExpression 55 | case .expand: 56 | state.diceCalculator.mode = .editingExpression 57 | case .onProcessRollForDiceLog(let result, let roll): 58 | state.diceLog.receive(result, for: roll) 59 | } 60 | return .none 61 | } 62 | ) 63 | } 64 | -------------------------------------------------------------------------------- /App/App/Encouter/Combatant+SwiftUI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Combatant+SwiftUI.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 27/05/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import GameModels 12 | 13 | public extension Combatant { 14 | func discriminatedNameText(discriminatorColor: Color = Color(UIColor.secondaryLabel)) -> Text { 15 | Self.discriminatedNameText(name: name, discriminator: discriminator, discriminatorColor: discriminatorColor) 16 | } 17 | 18 | static func discriminatedNameText( 19 | name: String, 20 | discriminator: Int?, 21 | discriminatorColor: Color = Color(UIColor.secondaryLabel) 22 | ) -> Text { 23 | let n = Text(name) 24 | let d = discriminator.map { 25 | Text(" \($0)").foregroundColor(discriminatorColor) 26 | } ?? Text("") 27 | 28 | return n + d 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /App/App/Encouter/HealthFractionView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HealthFractionView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 08/01/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import GameModels 12 | 13 | struct HealthFractionView: View { 14 | var hp: Hp 15 | @State var anim: Double = -1 16 | 17 | var body: some View { 18 | VStack(spacing: 0) { 19 | Text("-").modifier(EffectiveModifier(anim: $anim, number: Double(hp.effective))).animation(.easeOut(duration: 0.66), value: hp.effective) 20 | Divider() 21 | Text("\(hp.maximum)").foregroundColor(Color(UIColor.secondaryLabel)) 22 | } 23 | .background(Rectangle().cornerRadius(4).foregroundColor(colorForDirection.opacity(0.33)).animation(.easeInOut(duration: 0.33), value: hp.effective)) 24 | .font(.footnote) 25 | .frame(width: 25) 26 | .accessibilityElement(children: .ignore) 27 | .accessibility(label: Text(hp.accessibilityText)) 28 | } 29 | 30 | var colorForDirection: Color { 31 | guard anim >= 0 else { return Color.clear } 32 | 33 | let ed = Double(hp.effective) 34 | if anim < ed { 35 | return Color(UIColor.systemGreen) 36 | } else if anim > ed { 37 | return Color(UIColor.systemRed) 38 | } else { 39 | return Color.clear 40 | } 41 | } 42 | 43 | struct EffectiveModifier: AnimatableModifier { 44 | @Binding var anim: Double 45 | var number: Double 46 | 47 | var animatableData: Double { 48 | get { number } 49 | set { 50 | let a = $anim 51 | DispatchQueue.main.async { a.wrappedValue = newValue } 52 | 53 | number = newValue 54 | } 55 | } 56 | 57 | func body(content: Content) -> some View { 58 | return Text("\(Int(round(number)))").fontWeight(.medium).animation(nil) 59 | } 60 | 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /App/App/Encouter/InitiativeDialog.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InitiativePopover.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 26/08/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import SharedViews 12 | import GameModels 13 | 14 | struct InitiativePopover: Popover { 15 | var popoverId: AnyHashable { "InitiativePopover" } 16 | 17 | let action: (InitiativeSettings) -> Void 18 | func makeBody() -> AnyView { 19 | return AnyView(InitiativePopoverView(action: action)) 20 | } 21 | } 22 | 23 | struct InitiativePopoverView: View { 24 | let action: (InitiativeSettings) -> Void 25 | @State var settings: InitiativeSettings = .default 26 | 27 | var body: some View { 28 | VStack { 29 | Toggle(isOn: $settings.group) { 30 | Text("Group creatures") 31 | } 32 | 33 | Toggle(isOn: $settings.rollForPlayerCharacters) { 34 | Text("Roll for player characters") 35 | } 36 | Toggle(isOn: $settings.overwrite) { 37 | Text("Overwrite existing initiatives") 38 | } 39 | Divider() 40 | Button(action: { self.action(self.settings) }) { 41 | Text("Roll") 42 | } 43 | }.padding([.leading, .trailing], 4) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /App/App/Encouter/RunningEncounterLogView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RunningEncounterLogView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 14/01/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | 13 | struct RunningEncounterLogView: View { 14 | @SwiftUI.Environment(\.sheetPresentationMode) var sheetPresentationMode: SheetPresentationMode? 15 | 16 | var store: Store 17 | @ObservedObject var viewStore: ViewStore 18 | 19 | init(store: Store) { 20 | self.store = store 21 | self.viewStore = ViewStore(store) 22 | } 23 | 24 | var body: some View { 25 | List { 26 | Section { 27 | ForEach(viewStore.state.events, id: \.id) { event in 28 | RunningEncounterEventRow(encounter: self.viewStore.state.encounter.current, event: event, context: self.viewStore.state.context) 29 | } 30 | 31 | HStack { 32 | Image(systemName: "shield.fill").foregroundColor(Color(UIColor.systemGray)) 33 | Text("Start of encounter").italic() 34 | } 35 | } 36 | } 37 | .listStyle(InsetGroupedListStyle()) 38 | .navigationBarTitle(Text(viewStore.state.navigationTitle), displayMode: .inline) 39 | .navigationBarItems(trailing: Group { 40 | if self.sheetPresentationMode != nil { 41 | Button(action: { 42 | self.sheetPresentationMode?.dismiss() 43 | }) { 44 | Text("Done").bold() 45 | } 46 | } 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /App/App/Encouter/RunningEncounterLogViewState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RunningEncounterLogViewState.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 28/05/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Helpers 11 | import GameModels 12 | 13 | struct RunningEncounterLogViewState: Equatable { 14 | var encounter: RunningEncounter 15 | var context: Combatant? 16 | 17 | var events: [RunningEncounterEvent] { 18 | if let c = context { 19 | return encounter.log.reversed().filter { $0.involves(c) } 20 | } else { 21 | return encounter.log.reversed() 22 | } 23 | } 24 | } 25 | 26 | extension RunningEncounterLogViewState: NavigationStackItemState { 27 | var navigationStackItemStateId: String { 28 | "RunningEncounterLogViewState" 29 | } 30 | 31 | var navigationTitle: String { "Running Encounter Log" } 32 | } 33 | 34 | extension RunningEncounterLogViewState { 35 | static let nullInstance = RunningEncounterLogViewState(encounter: RunningEncounter.nullInstance, context: nil) 36 | } 37 | -------------------------------------------------------------------------------- /App/App/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ITSAppUsesNonExemptEncryption 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | 2.10.0 21 | CFBundleVersion 22 | 138 23 | LSRequiresIPhoneOS 24 | 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | UILaunchStoryboardName 31 | LaunchScreen 32 | UIRequiredDeviceCapabilities 33 | 34 | armv7 35 | 36 | UISupportedInterfaceOrientations 37 | 38 | UIInterfaceOrientationPortrait 39 | 40 | UISupportedInterfaceOrientations~ipad 41 | 42 | UIInterfaceOrientationPortrait 43 | UIInterfaceOrientationPortraitUpsideDown 44 | UIInterfaceOrientationLandscapeLeft 45 | UIInterfaceOrientationLandscapeRight 46 | 47 | UIApplicationSceneManifest 48 | 49 | UIApplicationSupportsMultipleScenes 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /App/App/Models/DiceExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiceExtensions.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 19/08/2022. 6 | // Copyright © 2022 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import DiceRollerFeature 11 | import GameModels 12 | 13 | extension RollDescription { 14 | static func abilityCheck(_ modifier: Int, ability: Ability, skill: Skill? = nil, combatant: Combatant? = nil, environment: DiceRollerEnvironment) -> Self { 15 | .abilityCheck(modifier, ability: ability, skill: skill, creatureName: combatant?.discriminatedName, environment: environment) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /App/App/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /App/App/Reference/AddCombatantReferenceItemView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddCombatantReferenceItemView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 26/02/2021. 6 | // Copyright © 2021 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | 13 | struct AddCombatantReferenceItemView: View { 14 | let store: Store 15 | 16 | var body: some View { 17 | AddCombatantView( 18 | store: store.scope(state: { $0.addCombatantState }, action: { .addCombatant($0) }), 19 | externalNavigation: true, 20 | showEncounterDifficulty: false, 21 | onSelection: { action, _ in 22 | ViewStore(store).send(.onSelection(action)) 23 | } 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /App/App/Reference/ReferenceView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReferenceView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 24/10/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | import Helpers 13 | 14 | struct ReferenceView: View { 15 | 16 | static let maxItems = 8 17 | 18 | let store: Store 19 | 20 | var body: some View { 21 | WithViewStore(store, removeDuplicates: { $0.localStateForDeduplication == $1.localStateForDeduplication }) { viewStore in 22 | TabbedDocumentView( 23 | items: tabItems(viewStore), 24 | content: { item in 25 | IfLetStore(store.scope(state: replayNonNil({ $0.items[id: item.id]?.state }), action: { .item(item.id, $0) }), then: ReferenceItemView.init) 26 | }, 27 | selection: viewStore.binding(get: { $0.selectedItemId }, send: { .selectItem($0) }), 28 | onAdd: { 29 | viewStore.send(.onNewTabTapped, animation: .default) 30 | }, 31 | onDelete: { tab in 32 | viewStore.send(.removeTab(tab), animation: .default) 33 | }, 34 | onMove: { from, to in 35 | viewStore.send(.moveTab(from, to)) 36 | } 37 | ) 38 | } 39 | .ignoresSafeArea(.keyboard, edges: .bottom) 40 | } 41 | 42 | func tabItems(_ viewStore: ViewStore) -> [TabbedDocumentViewContentItem] { 43 | viewStore.items.suffix(Self.maxItems).map { item in 44 | TabbedDocumentViewContentItem( 45 | id: item.id, 46 | label: Label(item.title, systemImage: "doc") 47 | ) 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /App/App/Reference/ReferenceViewPreference.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReferenceViewPreference.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 07/12/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | 13 | struct ReferenceViewItemRequest: Equatable { 14 | let id: TabbedDocumentViewContentItem.Id 15 | 16 | private(set) var state: ReferenceItemViewState 17 | private(set) var stateGeneration = UUID() // when this changes, the item should update to use the current requested state 18 | 19 | private(set) var focusRequest = UUID() // when this changes, the item should gain focus again 20 | 21 | /** 22 | If true, the request is not tracked after the item has been created. 23 | Requesting focus or updating the state will not work. 24 | If the request is no longer active, the item will not be removed. 25 | */ 26 | let oneOff: Bool 27 | 28 | mutating func requestFocus() { 29 | focusRequest = UUID() 30 | } 31 | 32 | mutating func updateState(_ state: ReferenceItemViewState) { 33 | self.state = state 34 | stateGeneration = UUID() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /App/App/UI/Checkbox.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Checkbox.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 24/10/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct Checkbox: View { 13 | let selected: Bool 14 | var body: some View { 15 | Image(systemName: selected ? "checkmark.circle" : "circle") 16 | .font(Font.title3.weight(.light)) 17 | .foregroundColor(selected ? Color.accentColor : Color(UIColor.systemGray2)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /App/App/UI/ClearableTextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClearableTextField.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 03/12/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import SharedViews 12 | 13 | struct ClearableTextField: View { 14 | let title: String 15 | @Binding var text: String 16 | let onCommit: () -> Void 17 | @State var editing = false 18 | 19 | init(_ title: String, text: Binding, onCommit: @escaping () -> Void = {}) { 20 | self.title = title 21 | _text = text 22 | self.onCommit = onCommit 23 | } 24 | 25 | var body: some View { 26 | HStack { 27 | TextField(title, text: $text, onEditingChanged: { editing in 28 | self.editing = editing 29 | }, onCommit: onCommit) 30 | 31 | if !text.isEmpty && editing { 32 | SimpleButton(action: { 33 | self.text = "" 34 | }) { 35 | Image(systemName: "xmark.circle.fill").foregroundColor(Color(UIColor.systemGray)) 36 | } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /App/App/UI/DocumentPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DocumentPicker.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 21/10/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import UniformTypeIdentifiers 12 | 13 | struct DocumentPicker: UIViewControllerRepresentable { 14 | 15 | typealias UIViewControllerType = UIDocumentPickerViewController 16 | 17 | let didPick: ([URL]) -> Void 18 | 19 | func makeCoordinator() -> Delegate { 20 | return Delegate(didPick: didPick) 21 | } 22 | 23 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIDocumentPickerViewController { 24 | let vc = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.content], asCopy: true) 25 | vc.delegate = context.coordinator 26 | return vc 27 | } 28 | 29 | func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: UIViewControllerRepresentableContext) { 30 | context.coordinator.didPick = didPick 31 | } 32 | 33 | final class Delegate: NSObject, UIDocumentPickerDelegate { 34 | var didPick: ([URL]) -> Void 35 | 36 | init(didPick: @escaping ([URL]) -> Void) { 37 | self.didPick = didPick 38 | } 39 | 40 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { 41 | self.didPick(urls) 42 | } 43 | 44 | func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { 45 | 46 | } 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /App/App/UI/NumberEntryPopover.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NumberEntryPopover.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 27/05/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import ComposableArchitecture 12 | import SharedViews 13 | 14 | // A popover that allows for number entry, either by hand or by simulated dice rolls 15 | struct NumberEntryPopover: Popover, View { 16 | 17 | var popoverId: AnyHashable { "NumberEntryPopover" } 18 | var store: Store 19 | let onOutcomeSelected: (Int) -> Void 20 | 21 | init(store: Store, onOutcomeSelected: @escaping (Int) -> Void) { 22 | self.store = store 23 | self.onOutcomeSelected = onOutcomeSelected 24 | } 25 | 26 | var body: some View { 27 | WithViewStore(store, observe: State.init) { viewStore in 28 | VStack { 29 | NumberEntryView(store: self.store) 30 | Divider() 31 | Button(action: { 32 | self.onOutcomeSelected(viewStore.state.outcome ?? 0) 33 | }) { 34 | Text("Use") 35 | }.disabled(viewStore.state.outcome == nil) 36 | } 37 | } 38 | } 39 | 40 | func makeBody() -> AnyView { 41 | return AnyView(self) 42 | } 43 | 44 | struct State: Equatable { 45 | let outcome: Int? 46 | 47 | init(_ state: NumberEntryViewState) { 48 | self.outcome = state.value 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /App/App/UI/SafariView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 25/02/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import SafariServices 12 | import ComposableArchitecture 13 | import Helpers 14 | 15 | struct SafariView: UIViewControllerRepresentable { 16 | let url: URL 17 | 18 | init(url: URL) { 19 | self.url = url 20 | } 21 | 22 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> SFSafariViewController { 23 | return SFSafariViewController(url: url) 24 | } 25 | 26 | func updateUIViewController(_ uiViewController: SFSafariViewController, context: UIViewControllerRepresentableContext) { 27 | 28 | } 29 | } 30 | 31 | struct SafariViewState: Hashable, Codable, Identifiable { 32 | let url: URL 33 | 34 | var id: URL { url } 35 | 36 | static let nullInstance = SafariViewState(url: URL(fileURLWithPath: "/")) 37 | } 38 | 39 | extension SafariViewState: NavigationStackItemState { 40 | var navigationStackItemStateId: String { 41 | "safariView:\(url.absoluteString)" 42 | } 43 | 44 | var navigationTitle: String { 45 | "Safari" 46 | } 47 | } 48 | 49 | extension SafariView { 50 | init(store: Store) { 51 | self.init(url: ViewStore(store).url) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /App/App/UI/SearchField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchField.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 23/09/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import SharedViews 12 | 13 | struct SearchField: View where Accessory: View { 14 | 15 | @Binding var text: String 16 | @State var focus = false 17 | 18 | var accessory: Accessory 19 | 20 | var body: some View { 21 | HStack { 22 | Image(systemName: "magnifyingglass").foregroundColor(Color(UIColor.systemGray)) 23 | TextField("Search...", text: $text, onEditingChanged: { began in 24 | self.focus = began 25 | }) 26 | if !text.isEmpty && focus { 27 | SimpleButton(action: { 28 | self.text = "" 29 | }) { 30 | Image(systemName: "xmark.circle.fill").foregroundColor(Color(UIColor.systemGray)) 31 | } 32 | } else { 33 | accessory 34 | } 35 | } 36 | } 37 | } 38 | 39 | struct BorderedSearchField: View where Accessory: View { 40 | 41 | let searchField: SearchField 42 | 43 | init(text: Binding, accessory: Accessory) { 44 | self.searchField = SearchField(text: text, accessory: accessory) 45 | } 46 | 47 | var body: some View { 48 | searchField 49 | .padding(8) 50 | .background(Color(UIColor.systemGray3).cornerRadius(4)) 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /App/App/UI/SheetNavigationContainer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SheetNavigationContainer.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 23/02/2020. 6 | // Copyright © 2020 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import Introspect 12 | 13 | struct SheetNavigationContainer: View where Content: View { 14 | @SwiftUI.Environment(\.presentationMode) var presentationMode: Binding 15 | 16 | var isModalInPresentation = false 17 | let content: () -> Content 18 | 19 | var body: some View { 20 | NavigationStack { 21 | content() 22 | } 23 | .environment(\.sheetPresentationMode, SheetPresentationMode { 24 | self.presentationMode.wrappedValue.dismiss() 25 | }) 26 | .introspectViewController { vc in 27 | guard presentationMode.wrappedValue.isPresented else { return } 28 | 29 | assert(!presentationMode.wrappedValue.isPresented || (vc.parent == nil && vc.presentingViewController != nil)) 30 | if isModalInPresentation { 31 | vc.isModalInPresentation = true 32 | } 33 | } 34 | } 35 | 36 | } 37 | 38 | struct SheetPresentationModeEnvironmentKey: EnvironmentKey { 39 | static var defaultValue: SheetPresentationMode? 40 | } 41 | 42 | extension EnvironmentValues { 43 | var sheetPresentationMode: SheetPresentationMode? { 44 | get { self[SheetPresentationModeEnvironmentKey.self] } 45 | set { self[SheetPresentationModeEnvironmentKey.self] = newValue } 46 | } 47 | } 48 | 49 | struct SheetPresentationMode { 50 | let dismiss: () -> Void 51 | } 52 | -------------------------------------------------------------------------------- /App/App/UI/SimpleList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SimpleList.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 19/10/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct SimpleList: View where Elements: RandomAccessCollection, Content: View, ID: Hashable { 13 | 14 | var data: Elements 15 | var id: KeyPath 16 | var content: (Elements.Element) -> Content 17 | 18 | var body: some View { 19 | VStack(spacing: 0) { 20 | ForEach(data, id: id) { e in 21 | self.content(e) 22 | .padding(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) 23 | .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) 24 | .overlay(Group { 25 | if !self.isLast(e) { 26 | Divider() 27 | .frame(maxHeight: .infinity, alignment: .bottom) 28 | } 29 | }) 30 | } 31 | } 32 | } 33 | 34 | func isLast(_ e: Elements.Element) -> Bool { 35 | data.last?[keyPath: id] == e[keyPath: id] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /App/App/UI/StateDrivenNavigationView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StateDrivenNavigationView.swift 3 | // Construct 4 | // 5 | // Created by Thomas Visser on 04/11/2019. 6 | // Copyright © 2019 Thomas Visser. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | func NavigationRowButton