├── .github ├── FUNDING.yml └── workflows │ ├── beta-build.yml │ ├── release-build.yml │ └── swiftlint.yml ├── .gitignore ├── .swiftlint.yml ├── ComicWidgetExtension.entitlements ├── Gemfile ├── Gemfile.lock ├── LICENSE ├── README.md ├── Subscription.storekit ├── Widgets ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── Contents.json │ └── WidgetBackground.colorset │ │ └── Contents.json ├── Common.swift ├── Info.plist ├── NewComicWidget.swift ├── NewOrRandomComic.swift ├── RandomComicWidget.swift ├── Samples │ └── 2329_2x.png ├── Views.swift ├── WidgetOptionsIntent.intentdefinition └── XKCDYWidgetBundle.swift ├── XKCDY.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ ├── WorkspaceSettings.xcsettings │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Widgets.xcscheme │ ├── XKCDY.xcscheme │ └── XKCDYIntents.xcscheme ├── XKCDY ├── AlternateIcons.swift ├── AppDelegate.swift ├── Assets.xcassets │ ├── AppIcon.appiconset │ │ ├── 1024.png │ │ ├── 120-1.png │ │ ├── 120.png │ │ ├── 180.png │ │ ├── 40.png │ │ ├── 58.png │ │ ├── 60.png │ │ ├── 80.png │ │ ├── 87.png │ │ ├── Contents.json │ │ ├── Icon-152.png │ │ ├── Icon-167.png │ │ ├── Icon-20.png │ │ ├── Icon-29.png │ │ ├── Icon-40.png │ │ ├── Icon-41.png │ │ ├── Icon-58.png │ │ ├── Icon-76.png │ │ └── Icon-80.png │ └── Contents.json ├── Base.lproj │ └── LaunchScreen.storyboard ├── ContentView.swift ├── Icons │ ├── beret-dark.png │ ├── beret-dark@2x.png │ ├── beret-dark@3x.png │ ├── beret.png │ ├── beret@2x.png │ ├── beret@3x.png │ ├── blackhat-dark.png │ ├── blackhat-dark@2x.png │ ├── blackhat-dark@3x.png │ ├── blackhat.png │ ├── blackhat@2x.png │ ├── blackhat@3x.png │ ├── megan-dark.png │ ├── megan-dark@2x.png │ ├── megan-dark@3x.png │ ├── megan.png │ ├── megan@2x.png │ └── megan@3x.png ├── Info.plist ├── Intents.intentdefinition ├── Models │ ├── Comic.swift │ └── TimeComicFrame.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── SceneDelegate.swift ├── Support │ ├── Fortunes.swift │ ├── IAPHelper.swift │ ├── Notifications.swift │ ├── Store.swift │ ├── TimeTracker.swift │ ├── UserSettings.swift │ ├── XKCDTimeClient.swift │ └── XKCDYClient.swift ├── Views │ ├── ActivityIndicator.swift │ ├── AppIconPicker.swift │ ├── ColorPickerRow.swift │ ├── ComicBadge.swift │ ├── ComicDetailsSheet.swift │ ├── ComicGridItem.swift │ ├── ComicPager.swift │ ├── ComicPagerOverlay.swift │ ├── ComicsGrid.swift │ ├── FloatingButtons.swift │ ├── FloatingNavBar.swift │ ├── FortuneLoader.swift │ ├── Modifiers.swift │ ├── ProgressBar.swift │ ├── SafariView.swift │ ├── SegmentedPicker.swift │ ├── SettingsSheet.swift │ ├── SharableComicView.swift │ ├── ShareSheet.swift │ ├── SpecialComicViewer.swift │ ├── TintColorPicker.swift │ ├── UncontrolledWebView.swift │ └── ZoomableImage.swift ├── XKCDY.entitlements └── XKCDY.xcdatamodeld │ └── DCKX.xcdatamodel │ └── contents ├── XKCDYIntents ├── GetComicIntentHandler.swift ├── GetLatestComicIntentHandler.swift ├── Info.plist ├── IntentHandler.swift ├── XKCDYIntentsDebug.entitlements └── XKCDYIntentsRelease.entitlements ├── XKCDYTests ├── Info.plist └── XKCDYTests.swift ├── XKCDYUITests ├── Info.plist └── XKCDYUITests.swift └── fastlane ├── Appfile ├── Fastfile ├── Matchfile └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: codetheweb 4 | custom: 'https://www.buymeacoffee.com/maxisom' 5 | -------------------------------------------------------------------------------- /.github/workflows/beta-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload beta 2 | on: 3 | push: 4 | branches: 5 | - master 6 | 7 | jobs: 8 | build_and_upload: 9 | name: Build & upload 10 | runs-on: macos-latest 11 | 12 | steps: 13 | - uses: maxim-lobanov/setup-xcode@v1 14 | with: 15 | xcode-version: latest-stable 16 | 17 | - uses: actions/checkout@v2 18 | 19 | - uses: maierj/fastlane-action@master 20 | name: Fastlane 21 | with: 22 | lane: 'beta' 23 | env: 24 | FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} 25 | MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} 26 | MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} 27 | FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} 28 | FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} 29 | MATCH_KEYCHAIN_NAME: ci-keychain 30 | MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} 31 | -------------------------------------------------------------------------------- /.github/workflows/release-build.yml: -------------------------------------------------------------------------------- 1 | name: Build and upload release 2 | on: 3 | release: 4 | types: 5 | - created 6 | 7 | jobs: 8 | build_and_upload: 9 | name: Build & upload 10 | runs-on: macos-12 11 | 12 | steps: 13 | - uses: maxim-lobanov/setup-xcode@v1 14 | with: 15 | xcode-version: latest-stable 16 | 17 | - uses: actions/checkout@v2 18 | 19 | - uses: maierj/fastlane-action@master 20 | name: Fastlane 21 | with: 22 | lane: 'release' 23 | env: 24 | FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} 25 | MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} 26 | MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }} 27 | FASTLANE_PASSWORD: ${{ secrets.FASTLANE_PASSWORD }} 28 | FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} 29 | MATCH_KEYCHAIN_NAME: ci-keychain 30 | MATCH_KEYCHAIN_PASSWORD: ${{ secrets.MATCH_KEYCHAIN_PASSWORD }} 31 | -------------------------------------------------------------------------------- /.github/workflows/swiftlint.yml: -------------------------------------------------------------------------------- 1 | name: SwiftLint 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '.github/workflows/swiftlint.yml' 7 | - '.swiftlint.yml' 8 | - '**/*.swift' 9 | 10 | jobs: 11 | SwiftLint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Run SwiftLint 16 | uses: norio-nomura/action-swiftlint@3.1.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/cocoapods,swift 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=cocoapods,swift 4 | 5 | ### CocoaPods ### 6 | ## CocoaPods GitIgnore Template 7 | 8 | # CocoaPods - Only use to conserve bandwidth / Save time on Pushing 9 | # - Also handy if you have a large number of dependant pods 10 | # - AS PER https://guides.cocoapods.org/using/using-cocoapods.html NEVER IGNORE THE LOCK FILE 11 | Pods/ 12 | 13 | ### Swift ### 14 | # Xcode 15 | # 16 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 17 | 18 | ## User settings 19 | xcuserdata/ 20 | 21 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 22 | *.xcscmblueprint 23 | *.xccheckout 24 | 25 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 26 | build/ 27 | DerivedData/ 28 | *.moved-aside 29 | *.pbxuser 30 | !default.pbxuser 31 | *.mode1v3 32 | !default.mode1v3 33 | *.mode2v3 34 | !default.mode2v3 35 | *.perspectivev3 36 | !default.perspectivev3 37 | 38 | ## Obj-C/Swift specific 39 | *.hmap 40 | 41 | ## App packaging 42 | *.ipa 43 | *.dSYM.zip 44 | *.dSYM 45 | 46 | ## Playgrounds 47 | timeline.xctimeline 48 | playground.xcworkspace 49 | 50 | # Swift Package Manager 51 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 52 | # Packages/ 53 | # Package.pins 54 | # Package.resolved 55 | # *.xcodeproj 56 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 57 | # hence it is not needed unless you have added a package configuration file to your project 58 | # .swiftpm 59 | 60 | .build/ 61 | 62 | # CocoaPods 63 | # We recommend against adding the Pods directory to your .gitignore. However 64 | # you should judge for yourself, the pros and cons are mentioned at: 65 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 66 | # Pods/ 67 | # Add this line if you want to avoid checking in source code from the Xcode workspace 68 | # *.xcworkspace 69 | 70 | # Carthage 71 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 72 | # Carthage/Checkouts 73 | 74 | Carthage/Build/ 75 | 76 | # Accio dependency management 77 | Dependencies/ 78 | .accio/ 79 | 80 | # fastlane 81 | # It is recommended to not store the screenshots in the git repo. 82 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 83 | # For more information about the recommended setup visit: 84 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 85 | 86 | fastlane/report.xml 87 | fastlane/Preview.html 88 | fastlane/screenshots/**/*.png 89 | fastlane/test_output 90 | 91 | # Code Injection 92 | # After new code Injection tools there's a generated folder /iOSInjectionProject 93 | # https://github.com/johnno1962/injectionforxcode 94 | 95 | iOSInjectionProject/ 96 | 97 | # End of https://www.toptal.com/developers/gitignore/api/cocoapods,swift 98 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - multiple_closures_with_trailing_closure 3 | - line_length 4 | - identifier_name 5 | - force_try 6 | excluded: 7 | - Carthage 8 | - Pods -------------------------------------------------------------------------------- /ComicWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.maxisom.XKCDY 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane", "2.205.0" 4 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.5) 5 | rexml 6 | addressable (2.8.0) 7 | public_suffix (>= 2.0.2, < 5.0) 8 | artifactory (3.0.15) 9 | atomos (0.1.3) 10 | aws-eventstream (1.2.0) 11 | aws-partitions (1.543.0) 12 | aws-sdk-core (3.125.0) 13 | aws-eventstream (~> 1, >= 1.0.2) 14 | aws-partitions (~> 1, >= 1.525.0) 15 | aws-sigv4 (~> 1.1) 16 | jmespath (~> 1.0) 17 | aws-sdk-kms (1.53.0) 18 | aws-sdk-core (~> 3, >= 3.125.0) 19 | aws-sigv4 (~> 1.1) 20 | aws-sdk-s3 (1.110.0) 21 | aws-sdk-core (~> 3, >= 3.125.0) 22 | aws-sdk-kms (~> 1) 23 | aws-sigv4 (~> 1.4) 24 | aws-sigv4 (1.4.0) 25 | aws-eventstream (~> 1, >= 1.0.2) 26 | babosa (1.0.4) 27 | claide (1.0.3) 28 | colored (1.2) 29 | colored2 (3.1.2) 30 | commander (4.6.0) 31 | highline (~> 2.0.0) 32 | declarative (0.0.20) 33 | digest-crc (0.6.4) 34 | rake (>= 12.0.0, < 14.0.0) 35 | domain_name (0.5.20190701) 36 | unf (>= 0.0.5, < 1.0.0) 37 | dotenv (2.7.6) 38 | emoji_regex (3.2.3) 39 | excon (0.89.0) 40 | faraday (1.8.0) 41 | faraday-em_http (~> 1.0) 42 | faraday-em_synchrony (~> 1.0) 43 | faraday-excon (~> 1.1) 44 | faraday-httpclient (~> 1.0.1) 45 | faraday-net_http (~> 1.0) 46 | faraday-net_http_persistent (~> 1.1) 47 | faraday-patron (~> 1.0) 48 | faraday-rack (~> 1.0) 49 | multipart-post (>= 1.2, < 3) 50 | ruby2_keywords (>= 0.0.4) 51 | faraday-cookie_jar (0.0.7) 52 | faraday (>= 0.8.0) 53 | http-cookie (~> 1.0.0) 54 | faraday-em_http (1.0.0) 55 | faraday-em_synchrony (1.0.0) 56 | faraday-excon (1.1.0) 57 | faraday-httpclient (1.0.1) 58 | faraday-net_http (1.0.1) 59 | faraday-net_http_persistent (1.2.0) 60 | faraday-patron (1.0.0) 61 | faraday-rack (1.0.0) 62 | faraday_middleware (1.2.0) 63 | faraday (~> 1.0) 64 | fastimage (2.2.6) 65 | fastlane (2.199.0) 66 | CFPropertyList (>= 2.3, < 4.0.0) 67 | addressable (>= 2.8, < 3.0.0) 68 | artifactory (~> 3.0) 69 | aws-sdk-s3 (~> 1.0) 70 | babosa (>= 1.0.3, < 2.0.0) 71 | bundler (>= 1.12.0, < 3.0.0) 72 | colored 73 | commander (~> 4.6) 74 | dotenv (>= 2.1.1, < 3.0.0) 75 | emoji_regex (>= 0.1, < 4.0) 76 | excon (>= 0.71.0, < 1.0.0) 77 | faraday (~> 1.0) 78 | faraday-cookie_jar (~> 0.0.6) 79 | faraday_middleware (~> 1.0) 80 | fastimage (>= 2.1.0, < 3.0.0) 81 | gh_inspector (>= 1.1.2, < 2.0.0) 82 | google-apis-androidpublisher_v3 (~> 0.3) 83 | google-apis-playcustomapp_v1 (~> 0.1) 84 | google-cloud-storage (~> 1.31) 85 | highline (~> 2.0) 86 | json (< 3.0.0) 87 | jwt (>= 2.1.0, < 3) 88 | mini_magick (>= 4.9.4, < 5.0.0) 89 | multipart-post (~> 2.0.0) 90 | naturally (~> 2.2) 91 | optparse (~> 0.1.1) 92 | plist (>= 3.1.0, < 4.0.0) 93 | rubyzip (>= 2.0.0, < 3.0.0) 94 | security (= 0.1.3) 95 | simctl (~> 1.6.3) 96 | terminal-notifier (>= 2.0.0, < 3.0.0) 97 | terminal-table (>= 1.4.5, < 2.0.0) 98 | tty-screen (>= 0.6.3, < 1.0.0) 99 | tty-spinner (>= 0.8.0, < 1.0.0) 100 | word_wrap (~> 1.0.0) 101 | xcodeproj (>= 1.13.0, < 2.0.0) 102 | xcpretty (~> 0.3.0) 103 | xcpretty-travis-formatter (>= 0.0.3) 104 | gh_inspector (1.1.3) 105 | google-apis-androidpublisher_v3 (0.14.0) 106 | google-apis-core (>= 0.4, < 2.a) 107 | google-apis-core (0.4.1) 108 | addressable (~> 2.5, >= 2.5.1) 109 | googleauth (>= 0.16.2, < 2.a) 110 | httpclient (>= 2.8.1, < 3.a) 111 | mini_mime (~> 1.0) 112 | representable (~> 3.0) 113 | retriable (>= 2.0, < 4.a) 114 | rexml 115 | webrick 116 | google-apis-iamcredentials_v1 (0.9.0) 117 | google-apis-core (>= 0.4, < 2.a) 118 | google-apis-playcustomapp_v1 (0.6.0) 119 | google-apis-core (>= 0.4, < 2.a) 120 | google-apis-storage_v1 (0.10.0) 121 | google-apis-core (>= 0.4, < 2.a) 122 | google-cloud-core (1.6.0) 123 | google-cloud-env (~> 1.0) 124 | google-cloud-errors (~> 1.0) 125 | google-cloud-env (1.5.0) 126 | faraday (>= 0.17.3, < 2.0) 127 | google-cloud-errors (1.2.0) 128 | google-cloud-storage (1.35.0) 129 | addressable (~> 2.8) 130 | digest-crc (~> 0.4) 131 | google-apis-iamcredentials_v1 (~> 0.1) 132 | google-apis-storage_v1 (~> 0.1) 133 | google-cloud-core (~> 1.6) 134 | googleauth (>= 0.16.2, < 2.a) 135 | mini_mime (~> 1.0) 136 | googleauth (1.1.0) 137 | faraday (>= 0.17.3, < 2.0) 138 | jwt (>= 1.4, < 3.0) 139 | memoist (~> 0.16) 140 | multi_json (~> 1.11) 141 | os (>= 0.9, < 2.0) 142 | signet (>= 0.16, < 2.a) 143 | highline (2.0.3) 144 | http-cookie (1.0.4) 145 | domain_name (~> 0.5) 146 | httpclient (2.8.3) 147 | jmespath (1.4.0) 148 | json (2.6.1) 149 | jwt (2.3.0) 150 | memoist (0.16.2) 151 | mini_magick (4.11.0) 152 | mini_mime (1.1.2) 153 | multi_json (1.15.0) 154 | multipart-post (2.0.0) 155 | nanaimo (0.3.0) 156 | naturally (2.2.1) 157 | optparse (0.1.1) 158 | os (1.1.4) 159 | plist (3.6.0) 160 | public_suffix (4.0.6) 161 | rake (13.0.6) 162 | representable (3.1.1) 163 | declarative (< 0.1.0) 164 | trailblazer-option (>= 0.1.1, < 0.2.0) 165 | uber (< 0.2.0) 166 | retriable (3.1.2) 167 | rexml (3.2.5) 168 | rouge (2.0.7) 169 | ruby2_keywords (0.0.5) 170 | rubyzip (2.3.2) 171 | security (0.1.3) 172 | signet (0.16.0) 173 | addressable (~> 2.8) 174 | faraday (>= 0.17.3, < 2.0) 175 | jwt (>= 1.5, < 3.0) 176 | multi_json (~> 1.10) 177 | simctl (1.6.8) 178 | CFPropertyList 179 | naturally 180 | terminal-notifier (2.0.0) 181 | terminal-table (1.8.0) 182 | unicode-display_width (~> 1.1, >= 1.1.1) 183 | trailblazer-option (0.1.2) 184 | tty-cursor (0.7.1) 185 | tty-screen (0.8.1) 186 | tty-spinner (0.9.3) 187 | tty-cursor (~> 0.7) 188 | uber (0.1.0) 189 | unf (0.1.4) 190 | unf_ext 191 | unf_ext (0.0.8) 192 | unicode-display_width (1.8.0) 193 | webrick (1.7.0) 194 | word_wrap (1.0.0) 195 | xcodeproj (1.21.0) 196 | CFPropertyList (>= 2.3.3, < 4.0) 197 | atomos (~> 0.1.3) 198 | claide (>= 1.0.2, < 2.0) 199 | colored2 (~> 3.1) 200 | nanaimo (~> 0.3.0) 201 | rexml (~> 3.2.4) 202 | xcpretty (0.3.0) 203 | rouge (~> 2.0.7) 204 | xcpretty-travis-formatter (1.0.1) 205 | xcpretty (~> 0.2, >= 0.0.7) 206 | 207 | PLATFORMS 208 | ruby 209 | 210 | DEPENDENCIES 211 | fastlane 212 | 213 | BUNDLED WITH 214 | 2.1.4 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XKCDY / app 2 | 3 | This is the source code of the [XKCDY](https://xkcdy.com) app. 4 | 5 | I built this app primarily as a way to learn Swift and SwiftUI. Because I was learning as I built this, there's probably a few weird architecture / design decisions. PRs are welcome. 😃 6 | 7 | ### Packages & Technologies 8 | 9 | - [Alamofire](https://github.com/Alamofire/Alamofire) (HTTP requests) 10 | - [ASCollectionView](https://github.com/apptekstudios/ASCollectionView) (incredible SwiftUI library for grid layouts) 11 | - [SwiftyStoreKit](https://github.com/bizz84/SwiftyStoreKit) 12 | - [SwiftUIPager](https://github.com/fermoya/SwiftUIPager) 13 | - [Kingfisher](https://github.com/onevcat/Kingfisher) (images) 14 | - [Realm](https://github.com/realm/realm-cocoa) (database) 15 | - [Fastlane](https://fastlane.tools/) (automated builds) 16 | -------------------------------------------------------------------------------- /Subscription.storekit: -------------------------------------------------------------------------------- 1 | { 2 | "products" : [ 3 | 4 | ], 5 | "settings" : { 6 | 7 | }, 8 | "subscriptionGroups" : [ 9 | { 10 | "id" : "E75502DE", 11 | "localizations" : [ 12 | 13 | ], 14 | "name" : "com.maxisom.XKCDY.subgroup", 15 | "subscriptions" : [ 16 | { 17 | "adHocOffers" : [ 18 | 19 | ], 20 | "displayPrice" : "2.99", 21 | "familyShareable" : true, 22 | "groupNumber" : 1, 23 | "internalID" : "C675357C", 24 | "introductoryOffer" : null, 25 | "localizations" : [ 26 | { 27 | "description" : "", 28 | "displayName" : "", 29 | "locale" : "en_US" 30 | } 31 | ], 32 | "productID" : "com.maxisom.XKCDY.pro", 33 | "recurringSubscriptionPeriod" : "P1M", 34 | "referenceName" : "XKCDY Pro", 35 | "subscriptionGroupID" : "E75502DE", 36 | "type" : "RecurringSubscription" 37 | } 38 | ] 39 | } 40 | ], 41 | "version" : { 42 | "major" : 1, 43 | "minor" : 0 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Widgets/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Widgets/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | } 93 | ], 94 | "info" : { 95 | "author" : "xcode", 96 | "version" : 1 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Widgets/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Widgets/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Widgets/Common.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Common.swift 3 | // Widgets 4 | // 5 | // Created by Max Isom on 9/20/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | import RealmSwift 12 | 13 | struct ComicEntry: TimelineEntry { 14 | var date: Date 15 | let comic: Comic 16 | let uiImage: UIImage 17 | let family: WidgetFamily 18 | let shouldBlur: Bool 19 | 20 | var relevance: TimelineEntryRelevance? { 21 | TimelineEntryRelevance(score: comic.isRead ? 0 : 70) 22 | } 23 | } 24 | 25 | struct Helpers { 26 | static func configureRealm() { 27 | // Use shared Realm directory 28 | let realmFileURL = FileManager.default 29 | .containerURL(forSecurityApplicationGroupIdentifier: "group.com.maxisom.XKCDY")! 30 | .appendingPathComponent("default.realm") 31 | Realm.Configuration.defaultConfiguration.fileURL = realmFileURL 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Widgets/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Widgets 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 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionPointIdentifier 26 | com.apple.widgetkit-extension 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Widgets/NewComicWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewComicWidget.swift 3 | // NewComicWidget 4 | // 5 | // Created by Max Isom on 8/26/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | import SwiftUI 11 | import RealmSwift 12 | import class Kingfisher.KingfisherManager 13 | import protocol Kingfisher.Resource 14 | 15 | struct LatestComicWidgetProvider: IntentTimelineProvider { 16 | typealias Entry = ComicEntry 17 | typealias Intent = ViewLatestComicIntent 18 | 19 | func placeholder(in context: Context) -> ComicEntry { 20 | ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: context.family, shouldBlur: false) 21 | } 22 | 23 | func getEntry(family: WidgetFamily, configuration: ViewLatestComicIntent, completion: @escaping (ComicEntry) -> Void) { 24 | var comic: Comic = .getTallSample() 25 | 26 | Helpers.configureRealm() 27 | 28 | let realm = try! Realm() 29 | 30 | if let comics = realm.object(ofType: Comics.self, forPrimaryKey: 0) { 31 | if let savedComic = comics.comics.sorted(byKeyPath: "id").last { 32 | comic = savedComic.freeze() 33 | } 34 | } 35 | 36 | KingfisherManager.shared.retrieveImage(with: comic.getBestImageURL()!) { result in 37 | var image = UIImage() 38 | 39 | switch result { 40 | case .success(let imageResult): 41 | image = imageResult.image 42 | case .failure: 43 | return 44 | } 45 | 46 | let entry = ComicEntry(date: Date(), comic: comic, uiImage: image, family: family, shouldBlur: configuration.shouldBlurUnread == .some(1) && !comic.isRead) 47 | 48 | completion(entry) 49 | } 50 | } 51 | 52 | func getSnapshot(for configuration: ViewLatestComicIntent, in context: Context, completion: @escaping (ComicEntry) -> Void) { 53 | getEntry(family: context.family, configuration: configuration) { 54 | completion($0) 55 | } 56 | } 57 | 58 | func getTimeline(for configuration: ViewLatestComicIntent, in context: Context, completion: @escaping (Timeline) -> Void) { 59 | Helpers.configureRealm() 60 | 61 | Store(isLive: false).partialRefetchComics { _ in 62 | getEntry(family: context.family, configuration: configuration) { entry in 63 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: Date())! 64 | 65 | let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) 66 | 67 | completion(timeline) 68 | } 69 | } 70 | } 71 | } 72 | 73 | struct NewComicWidget: Widget { 74 | let kind: String = "NewComicWidget" 75 | 76 | var body: some WidgetConfiguration { 77 | IntentConfiguration(kind: kind, intent: ViewLatestComicIntent.self, provider: LatestComicWidgetProvider()) { entry in 78 | Group { 79 | if entry.family == .systemSmall { 80 | SmallComicWidgetView(entry: entry) 81 | } else { 82 | LargeComicWidgetView(entry: entry) 83 | } 84 | }.frame(maxWidth: .infinity, maxHeight: .infinity) 85 | } 86 | .configurationDisplayName("Latest XKCD Comic") 87 | .description("Displays the latest XKCD comic.") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Widgets/NewOrRandomComic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewOrRandomComic.swift 3 | // Widgets 4 | // 5 | // Created by Max Isom on 10/1/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import WidgetKit 10 | import SwiftUI 11 | import RealmSwift 12 | import class Kingfisher.KingfisherManager 13 | import protocol Kingfisher.Resource 14 | 15 | struct NewOrRandomComicWidgetProvider: IntentTimelineProvider { 16 | typealias Entry = ComicEntry 17 | typealias Intent = ViewLatestComicIntent 18 | 19 | private func getRandomComic() -> Comic? { 20 | Helpers.configureRealm() 21 | 22 | let realm = try! Realm() 23 | 24 | let comics = realm.object(ofType: Comics.self, forPrimaryKey: 0) 25 | 26 | return comics?.comics.randomElement() 27 | } 28 | 29 | func placeholder(in context: Context) -> ComicEntry { 30 | ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: context.family, shouldBlur: false) 31 | } 32 | 33 | func getEntry(family: WidgetFamily, configuration: ViewLatestComicIntent, completion: @escaping (ComicEntry) -> Void) { 34 | var comic: Comic = .getTallSample() 35 | 36 | Helpers.configureRealm() 37 | 38 | let realm = try! Realm() 39 | 40 | if let comics = realm.object(ofType: Comics.self, forPrimaryKey: 0) { 41 | if let savedComic = comics.comics.sorted(byKeyPath: "id").last { 42 | comic = savedComic.freeze() 43 | } 44 | } 45 | 46 | KingfisherManager.shared.retrieveImage(with: comic.getBestImageURL()!) { result in 47 | var image = UIImage() 48 | 49 | switch result { 50 | case .success(let imageResult): 51 | image = imageResult.image 52 | case .failure: 53 | return 54 | } 55 | 56 | let entry = ComicEntry(date: Date(), comic: comic, uiImage: image, family: family, shouldBlur: configuration.shouldBlurUnread == .some(1) && !comic.isRead) 57 | 58 | completion(entry) 59 | } 60 | } 61 | 62 | func getSnapshot(for configuration: ViewLatestComicIntent, in context: Context, completion: @escaping (ComicEntry) -> Void) { 63 | getEntry(family: context.family, configuration: configuration) { 64 | completion($0) 65 | } 66 | } 67 | 68 | func getRandomTimeline(context: Context, completion: @escaping (Timeline) -> Void) { 69 | var entries: [Entry] = [] 70 | 71 | let imageCacheGroup = DispatchGroup() 72 | 73 | let currentDate = Date() 74 | 75 | for hourOffset in 0 ..< 1 { 76 | let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! 77 | 78 | if let randomComic = self.getRandomComic() { 79 | imageCacheGroup.enter() 80 | 81 | // Cache image 82 | KingfisherManager.shared.retrieveImage(with: randomComic.getBestImageURL()!) { result in 83 | guard case .success(let imageResult) = result else { 84 | imageCacheGroup.leave() 85 | return 86 | } 87 | 88 | let entry = ComicEntry(date: entryDate, comic: randomComic, uiImage: imageResult.image, family: context.family, shouldBlur: false) 89 | 90 | entries.append(entry) 91 | 92 | imageCacheGroup.leave() 93 | } 94 | } 95 | } 96 | 97 | imageCacheGroup.notify(queue: .main) { 98 | completion(Timeline(entries: entries, policy: .atEnd)) 99 | } 100 | } 101 | 102 | func getTimeline(for configuration: ViewLatestComicIntent, in context: Context, completion: @escaping (Timeline) -> Void) { 103 | Helpers.configureRealm() 104 | 105 | Store(isLive: false).partialRefetchComics { _ in 106 | let realm = try! Realm() 107 | 108 | if !(realm.object(ofType: Comics.self, forPrimaryKey: 0)?.comics.sorted(byKeyPath: "id").last?.isRead ?? true) { 109 | // Latest comic is unread, show it 110 | getEntry(family: context.family, configuration: configuration) { entry in 111 | let refreshDate = Calendar.current.date(byAdding: .minute, value: 5, to: Date())! 112 | 113 | let timeline = Timeline(entries: [entry], policy: .after(refreshDate)) 114 | 115 | completion(timeline) 116 | } 117 | } else { 118 | // Show random comics 119 | getRandomTimeline(context: context) { timeline in 120 | completion(timeline) 121 | } 122 | } 123 | } 124 | } 125 | } 126 | 127 | struct NewOrRandomComicWidget: Widget { 128 | let kind: String = "NewOrRandomComicWidget" 129 | 130 | var body: some WidgetConfiguration { 131 | IntentConfiguration(kind: kind, intent: ViewLatestComicIntent.self, provider: NewOrRandomComicWidgetProvider()) { entry in 132 | Group { 133 | if entry.family == .systemSmall { 134 | SmallComicWidgetView(entry: entry) 135 | } else { 136 | LargeComicWidgetView(entry: entry) 137 | } 138 | }.frame(maxWidth: .infinity, maxHeight: .infinity) 139 | } 140 | .configurationDisplayName("Latest or random comic") 141 | .description("Displays the latest comic if unread, otherwise displays a random comic.") 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Widgets/RandomComicWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RandomComicWidget.swift 3 | // Widgets 4 | // 5 | // Created by Max Isom on 9/20/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | import RealmSwift 12 | import class Kingfisher.KingfisherManager 13 | 14 | struct RandomComicWidgetProvider: TimelineProvider { 15 | typealias Entry = ComicEntry 16 | 17 | private func getRandomComic() -> Comic? { 18 | Helpers.configureRealm() 19 | 20 | let realm = try! Realm() 21 | 22 | let comics = realm.object(ofType: Comics.self, forPrimaryKey: 0) 23 | 24 | return comics?.comics.randomElement() 25 | } 26 | 27 | func placeholder(in context: Context) -> ComicEntry { 28 | ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: context.family, shouldBlur: false) 29 | } 30 | 31 | func getSnapshot(in context: Context, completion: @escaping (ComicEntry) -> Void) { 32 | let comic = self.getRandomComic() ?? .getTallSample() 33 | 34 | KingfisherManager.shared.retrieveImage(with: comic.getBestImageURL()!) { result in 35 | guard case .success(let imageResult) = result else { return } 36 | 37 | let entry = ComicEntry(date: Date(), comic: comic, uiImage: imageResult.image, family: context.family, shouldBlur: false) 38 | 39 | completion(entry) 40 | } 41 | } 42 | 43 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 44 | var entries: [Entry] = [] 45 | 46 | let imageCacheGroup = DispatchGroup() 47 | 48 | let currentDate = Date() 49 | 50 | for hourOffset in 0 ..< 5 { 51 | let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! 52 | 53 | if let randomComic = self.getRandomComic() { 54 | imageCacheGroup.enter() 55 | 56 | // Cache image 57 | KingfisherManager.shared.retrieveImage(with: randomComic.getBestImageURL()!) { result in 58 | guard case .success(let imageResult) = result else { 59 | imageCacheGroup.leave() 60 | return 61 | } 62 | 63 | let entry = ComicEntry(date: entryDate, comic: randomComic, uiImage: imageResult.image, family: context.family, shouldBlur: false) 64 | 65 | entries.append(entry) 66 | 67 | imageCacheGroup.leave() 68 | } 69 | } 70 | } 71 | 72 | imageCacheGroup.notify(queue: .main) { 73 | completion(Timeline(entries: entries, policy: .atEnd)) 74 | } 75 | } 76 | } 77 | 78 | struct RandomComicWidget: Widget { 79 | let kind = "RandomComicWidget" 80 | 81 | var body: some WidgetConfiguration { 82 | StaticConfiguration(kind: kind, provider: RandomComicWidgetProvider()) { entry in 83 | Group { 84 | if entry.family == .systemSmall { 85 | SmallComicWidgetView(entry: entry) 86 | } else { 87 | LargeComicWidgetView(entry: entry) 88 | } 89 | }.frame(maxWidth: .infinity, maxHeight: .infinity) 90 | } 91 | .configurationDisplayName("Random comic") 92 | .description("Displays a random comic.") 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Widgets/Samples/2329_2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/Widgets/Samples/2329_2x.png -------------------------------------------------------------------------------- /Widgets/Views.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Views.swift 3 | // Widgets 4 | // 5 | // Created by Max Isom on 9/20/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | 12 | struct ComicBadgeHeader: View { 13 | var comic: Comic 14 | 15 | var body: some View { 16 | Text("#\(String(comic.id))") 17 | .font(.headline) 18 | .foregroundColor(.white) 19 | .padding(.horizontal, 7) 20 | .padding(.vertical, 5) 21 | .background(comic.isRead ? Color.gray : Color.green) 22 | .cornerRadius(8) 23 | .fixedSize(horizontal: true, vertical: false) 24 | } 25 | } 26 | 27 | struct LargeComicWidgetView: View { 28 | var entry: LatestComicWidgetProvider.Entry 29 | 30 | func getRatioOfComic() -> Float { 31 | if let x2Ratio = entry.comic.imgs?.x2?.ratio { 32 | return x2Ratio 33 | } 34 | 35 | if let x1Ratio = entry.comic.imgs?.x1?.ratio { 36 | return x1Ratio 37 | } 38 | 39 | return 0 40 | } 41 | 42 | var body: some View { 43 | ZStack { 44 | GeometryReader { geom in 45 | Image(uiImage: entry.uiImage).resizable().aspectRatio(contentMode: .fill).scaleEffect(1.05).blur(radius: entry.shouldBlur ? 5 : 0) 46 | 47 | Rectangle().fill(LinearGradient(gradient: Gradient(colors: [Color.black.opacity(0.8), Color.black.opacity(0.5), Color.white.opacity(0)]), startPoint: .top, endPoint: .bottom)).frame(width: geom.size.width, height: geom.size.height / 2) 48 | 49 | HStack { 50 | ComicBadgeHeader(comic: entry.comic) 51 | 52 | Text(entry.comic.safeTitle).font(.headline).lineLimit(1).foregroundColor(.white) 53 | } 54 | .padding() 55 | } 56 | } 57 | .widgetURL(URL(string: "xkcdy://comics/\(entry.comic.id)")) 58 | } 59 | } 60 | 61 | struct SmallComicWidgetView: View { 62 | var entry: LatestComicWidgetProvider.Entry 63 | 64 | var body: some View { 65 | VStack(alignment: .leading) { 66 | ComicBadgeHeader(comic: entry.comic) 67 | 68 | Text(verbatim: entry.comic.safeTitle).font(.headline).lineLimit(1).padding(.bottom, 1) 69 | 70 | Text(verbatim: entry.comic.alt).font(.caption) 71 | 72 | Spacer(minLength: 0) 73 | } 74 | .padding() 75 | .widgetURL(URL(string: "xkcdy://comics/\(entry.comic.id)")) 76 | } 77 | } 78 | 79 | struct ComicWidget_Previews: PreviewProvider { 80 | static var previews: some View { 81 | Group { 82 | SmallComicWidgetView(entry: ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: .systemSmall, shouldBlur: false)) 83 | .previewContext(WidgetPreviewContext(family: .systemSmall)) 84 | 85 | LargeComicWidgetView(entry: ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: .systemMedium, shouldBlur: false)) 86 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 87 | 88 | LargeComicWidgetView(entry: ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: .systemMedium, shouldBlur: true)) 89 | .previewContext(WidgetPreviewContext(family: .systemMedium)) 90 | 91 | LargeComicWidgetView(entry: ComicEntry(date: Date(), comic: .getTallSample(), uiImage: UIImage(named: "2329_2x.png")!, family: .systemLarge, shouldBlur: false)) 92 | .previewContext(WidgetPreviewContext(family: .systemLarge)) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Widgets/WidgetOptionsIntent.intentdefinition: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | INEnums 6 | 7 | INIntentDefinitionModelVersion 8 | 1.2 9 | INIntentDefinitionNamespace 10 | am7pU3 11 | INIntentDefinitionSystemVersion 12 | 20A5374i 13 | INIntentDefinitionToolsBuildVersion 14 | 12A7208 15 | INIntentDefinitionToolsVersion 16 | 12.0 17 | INIntents 18 | 19 | 20 | INIntentCategory 21 | information 22 | INIntentDescription 23 | View the latest comic 24 | INIntentDescriptionID 25 | rwl4pM 26 | INIntentEligibleForWidgets 27 | 28 | INIntentIneligibleForSuggestions 29 | 30 | INIntentInput 31 | shouldBlurUnread 32 | INIntentLastParameterTag 33 | 2 34 | INIntentName 35 | ViewLatestComic 36 | INIntentParameters 37 | 38 | 39 | INIntentParameterConfigurable 40 | 41 | INIntentParameterDisplayName 42 | Blur latest comic if unread 43 | INIntentParameterDisplayNameID 44 | fdTQ4r 45 | INIntentParameterDisplayPriority 46 | 1 47 | INIntentParameterMetadata 48 | 49 | INIntentParameterMetadataFalseDisplayName 50 | false 51 | INIntentParameterMetadataFalseDisplayNameID 52 | 9zE4OM 53 | INIntentParameterMetadataTrueDisplayName 54 | true 55 | INIntentParameterMetadataTrueDisplayNameID 56 | f89I1q 57 | 58 | INIntentParameterName 59 | shouldBlurUnread 60 | INIntentParameterPromptDialogs 61 | 62 | 63 | INIntentParameterPromptDialogCustom 64 | 65 | INIntentParameterPromptDialogType 66 | Configuration 67 | 68 | 69 | INIntentParameterPromptDialogCustom 70 | 71 | INIntentParameterPromptDialogType 72 | Primary 73 | 74 | 75 | INIntentParameterTag 76 | 2 77 | INIntentParameterType 78 | Boolean 79 | 80 | 81 | INIntentResponse 82 | 83 | INIntentResponseCodes 84 | 85 | 86 | INIntentResponseCodeName 87 | success 88 | INIntentResponseCodeSuccess 89 | 90 | 91 | 92 | INIntentResponseCodeName 93 | failure 94 | 95 | 96 | 97 | INIntentTitle 98 | View Latest Comic 99 | INIntentTitleID 100 | Se7Edg 101 | INIntentType 102 | Custom 103 | INIntentVerb 104 | View 105 | 106 | 107 | INTypes 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /Widgets/XKCDYWidgetBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XKCDYWidgetBundle.swift 3 | // Widgets 4 | // 5 | // Created by Max Isom on 9/20/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WidgetKit 11 | 12 | @main 13 | struct XKCDYWidgetBundle: WidgetBundle { 14 | @WidgetBundleBuilder 15 | var body: some Widget { 16 | NewOrRandomComicWidget() 17 | RandomComicWidget() 18 | NewComicWidget() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | DisableBuildSystemDeprecationDiagnostic 6 | 7 | PreviewsEnabled 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "alamofire", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/Alamofire/Alamofire.git", 7 | "state" : { 8 | "revision" : "becd9a729a37bdbef5bc39dc3c702b99f9e3d046", 9 | "version" : "5.2.2" 10 | } 11 | }, 12 | { 13 | "identity" : "ascollectionview", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apptekstudios/ASCollectionView", 16 | "state" : { 17 | "revision" : "4288744ba484c1062c109c0f28d72b629d321d55", 18 | "version" : "2.1.1" 19 | } 20 | }, 21 | { 22 | "identity" : "differencekit", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/ra1028/DifferenceKit", 25 | "state" : { 26 | "revision" : "14c66681e12a38b81045f44c6c29724a0d4b0e72", 27 | "version" : "1.1.5" 28 | } 29 | }, 30 | { 31 | "identity" : "kingfisher", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/onevcat/Kingfisher.git", 34 | "state" : { 35 | "revision" : "0c02c46cfdc0656ce74fd0963a75e5000a0b7f23", 36 | "version" : "7.1.2" 37 | } 38 | }, 39 | { 40 | "identity" : "realm-cocoa", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/realm/realm-cocoa", 43 | "state" : { 44 | "revision" : "fd279e0a94b46c5bd65b17e36817a4365c7a5482", 45 | "version" : "10.28.2" 46 | } 47 | }, 48 | { 49 | "identity" : "realm-core", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/realm/realm-core", 52 | "state" : { 53 | "revision" : "55a48c287b5e3a8ca129c257ec7e3b92bcb2a05f", 54 | "version" : "12.3.0" 55 | } 56 | }, 57 | { 58 | "identity" : "swiftuipager", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/fermoya/SwiftUIPager.git", 61 | "state" : { 62 | "revision" : "3c0485ffc369f2138477ec508cafc5fffd39f2bf", 63 | "version" : "2.3.2" 64 | } 65 | }, 66 | { 67 | "identity" : "swiftystorekit", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/bizz84/SwiftyStoreKit.git", 70 | "state" : { 71 | "revision" : "9974acf07b31b6f5bf363d5118dc1bd4fcac6122", 72 | "version" : "0.16.1" 73 | } 74 | }, 75 | { 76 | "identity" : "tpinappreceipt", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/tikhop/TPInAppReceipt.git", 79 | "state" : { 80 | "revision" : "61591a73792280d73baade63f2429b87e0659125", 81 | "version" : "2.6.2" 82 | } 83 | } 84 | ], 85 | "version" : 2 86 | } 87 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/xcshareddata/xcschemes/Widgets.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 60 | 63 | 69 | 70 | 71 | 72 | 78 | 79 | 80 | 81 | 85 | 86 | 90 | 91 | 95 | 96 | 97 | 98 | 106 | 108 | 114 | 115 | 116 | 117 | 119 | 120 | 123 | 124 | 125 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/xcshareddata/xcschemes/XKCDY.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /XKCDY.xcodeproj/xcshareddata/xcschemes/XKCDYIntents.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 60 | 62 | 68 | 69 | 70 | 71 | 79 | 81 | 87 | 88 | 89 | 90 | 92 | 93 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /XKCDY/AlternateIcons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AlternateIcons.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/28/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import UIKit 11 | 12 | class AlternateIcons: ObservableObject { 13 | var iconNames: [String] = [] 14 | @Published var currentIndex = 0 15 | 16 | init() { 17 | self.getAlternateIcons() 18 | 19 | if let currentIcon = UIApplication.shared.alternateIconName { 20 | self.currentIndex = iconNames.firstIndex(of: currentIcon) ?? 0 21 | } 22 | } 23 | 24 | func getAlternateIcons() { 25 | if let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], let alternateIcons = icons["CFBundleAlternateIcons"] as? [String: Any] { 26 | for (_, value ) in alternateIcons { 27 | guard let iconList = value as? [String: Any] else {return} 28 | guard let iconFiles = iconList["CFBundleIconFiles"] as? [String] else {return} 29 | 30 | guard let icon = iconFiles.first else {return} 31 | 32 | iconNames.append(icon) 33 | } 34 | } 35 | 36 | iconNames = iconNames.sorted() 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /XKCDY/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // DCKX 4 | // 5 | // Created by Max Isom on 4/13/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import BackgroundTasks 11 | import RealmSwift 12 | import SwiftyStoreKit 13 | import class Kingfisher.ImagePrefetcher 14 | 15 | @UIApplicationMain 16 | class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.maxisom.XKCDY.comicFetcher", using: nil) { task in 20 | // swiftlint:disable:next force_cast 21 | self.handleAppRefreshTask(task: task as! BGAppRefreshTask) 22 | } 23 | 24 | // Add transaction observer 25 | SwiftyStoreKit.completeTransactions(atomically: true) { _ in 26 | do { 27 | try IAPHelper.checkForPurchaseAndUpdate() 28 | } catch {} 29 | } 30 | 31 | // Cache product 32 | SwiftyStoreKit.retrieveProductsInfo([XKCDYPro], completion: {_ in }) 33 | 34 | // Look for receipt and update server state 35 | do { 36 | try IAPHelper.checkForPurchaseAndUpdate() 37 | } catch {} 38 | 39 | // Set Realm file location 40 | let realmFileURL = FileManager.default 41 | .containerURL(forSecurityApplicationGroupIdentifier: "group.com.maxisom.XKCDY")! 42 | .appendingPathComponent("default.realm") 43 | Realm.Configuration.defaultConfiguration.fileURL = realmFileURL 44 | 45 | // Disable at-rest encryption so background refresh works 46 | let realm = try! Realm() 47 | 48 | // Get our Realm file's parent directory 49 | let folderPath = realm.configuration.fileURL!.deletingLastPathComponent().path 50 | 51 | // Disable file protection for this directory 52 | try! FileManager.default.setAttributes([FileAttributeKey(rawValue: FileAttributeKey.protectionKey.rawValue): FileProtectionType.none], ofItemAtPath: folderPath) 53 | 54 | // Register for push notifications if enabled 55 | Notifications.registerIfEnabled() 56 | 57 | // Set delegate for handling notification opens 58 | let center = UNUserNotificationCenter.current() 59 | center.delegate = self 60 | 61 | return true 62 | } 63 | 64 | // MARK: UISceneSession Lifecycle 65 | 66 | func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { 67 | // Called when a new scene session is being created. 68 | // Use this method to select a configuration to create the new scene with. 69 | return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 70 | } 71 | 72 | func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { 73 | // Called when the user discards a scene session. 74 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 75 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 76 | } 77 | 78 | func scheduleBackgroundRefresh() { 79 | let task = BGAppRefreshTaskRequest(identifier: "com.maxisom.XKCDY.comicFetcher") 80 | task.earliestBeginDate = Date(timeIntervalSinceNow: 60) 81 | 82 | do { 83 | try BGTaskScheduler.shared.submit(task) 84 | } catch { 85 | print("Unable to sumit task: \(error.localizedDescription)") 86 | } 87 | } 88 | 89 | func handleAppRefreshTask(task: BGAppRefreshTask) { 90 | task.expirationHandler = { 91 | task.setTaskCompleted(success: false) 92 | } 93 | 94 | let store = Store(isLive: false) 95 | 96 | store.partialRefetchComics { result in 97 | switch result { 98 | case .success(let comicIds): 99 | // Cache images 100 | let realm = try! Realm() 101 | var urls: [URL] = [] 102 | 103 | for id in comicIds { 104 | if let comic = realm.object(ofType: Comic.self, forPrimaryKey: id) { 105 | if let url = comic.getBestImageURL() { 106 | urls.append(url) 107 | } 108 | } 109 | 110 | } 111 | 112 | ImagePrefetcher(urls: urls).start() 113 | 114 | task.setTaskCompleted(success: true) 115 | case .failure: 116 | task.setTaskCompleted(success: false) 117 | } 118 | } 119 | 120 | scheduleBackgroundRefresh() 121 | } 122 | 123 | func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { 124 | Notifications.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken) 125 | } 126 | 127 | func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { 128 | print("Failed to register for notifications: \(error.localizedDescription)") 129 | } 130 | 131 | func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { 132 | if let scene = UIApplication.shared.connectedScenes.first { 133 | if let sceneDelegate = scene.delegate { 134 | (sceneDelegate as? NotificationResponseHandler)?.handleNotificationResponse(response: response) 135 | } 136 | } 137 | 138 | completionHandler() 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/1024.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/120-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/120-1.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/120.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/180.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/40.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/58.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/60.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/80.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/87.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/87.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "40.png", 5 | "idiom" : "iphone", 6 | "scale" : "2x", 7 | "size" : "20x20" 8 | }, 9 | { 10 | "filename" : "60.png", 11 | "idiom" : "iphone", 12 | "scale" : "3x", 13 | "size" : "20x20" 14 | }, 15 | { 16 | "filename" : "58.png", 17 | "idiom" : "iphone", 18 | "scale" : "2x", 19 | "size" : "29x29" 20 | }, 21 | { 22 | "filename" : "87.png", 23 | "idiom" : "iphone", 24 | "scale" : "3x", 25 | "size" : "29x29" 26 | }, 27 | { 28 | "filename" : "80.png", 29 | "idiom" : "iphone", 30 | "scale" : "2x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "filename" : "120.png", 35 | "idiom" : "iphone", 36 | "scale" : "3x", 37 | "size" : "40x40" 38 | }, 39 | { 40 | "filename" : "120-1.png", 41 | "idiom" : "iphone", 42 | "scale" : "2x", 43 | "size" : "60x60" 44 | }, 45 | { 46 | "filename" : "180.png", 47 | "idiom" : "iphone", 48 | "scale" : "3x", 49 | "size" : "60x60" 50 | }, 51 | { 52 | "filename" : "Icon-20.png", 53 | "idiom" : "ipad", 54 | "scale" : "1x", 55 | "size" : "20x20" 56 | }, 57 | { 58 | "filename" : "Icon-40.png", 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "20x20" 62 | }, 63 | { 64 | "filename" : "Icon-29.png", 65 | "idiom" : "ipad", 66 | "scale" : "1x", 67 | "size" : "29x29" 68 | }, 69 | { 70 | "filename" : "Icon-58.png", 71 | "idiom" : "ipad", 72 | "scale" : "2x", 73 | "size" : "29x29" 74 | }, 75 | { 76 | "filename" : "Icon-41.png", 77 | "idiom" : "ipad", 78 | "scale" : "1x", 79 | "size" : "40x40" 80 | }, 81 | { 82 | "filename" : "Icon-80.png", 83 | "idiom" : "ipad", 84 | "scale" : "2x", 85 | "size" : "40x40" 86 | }, 87 | { 88 | "filename" : "Icon-76.png", 89 | "idiom" : "ipad", 90 | "scale" : "1x", 91 | "size" : "76x76" 92 | }, 93 | { 94 | "filename" : "Icon-152.png", 95 | "idiom" : "ipad", 96 | "scale" : "2x", 97 | "size" : "76x76" 98 | }, 99 | { 100 | "filename" : "Icon-167.png", 101 | "idiom" : "ipad", 102 | "scale" : "2x", 103 | "size" : "83.5x83.5" 104 | }, 105 | { 106 | "filename" : "1024.png", 107 | "idiom" : "ios-marketing", 108 | "scale" : "1x", 109 | "size" : "1024x1024" 110 | } 111 | ], 112 | "info" : { 113 | "author" : "xcode", 114 | "version" : 1 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-152.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-167.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-167.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-20.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-29.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-40.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-41.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-58.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-58.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-76.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-80.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Assets.xcassets/AppIcon.appiconset/Icon-80.png -------------------------------------------------------------------------------- /XKCDY/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /XKCDY/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /XKCDY/ContentView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // DCKX 4 | // 5 | // Created by Max Isom on 4/13/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import RealmSwift 11 | import ASCollectionView 12 | 13 | class HeaderSizeModel: ObservableObject { 14 | @Published var collectionView: UICollectionView? { 15 | didSet { 16 | updateRefreshControlOffset() 17 | } 18 | } 19 | @Published var headerSize: CGSize = .zero { 20 | didSet { 21 | updateRefreshControlOffset() 22 | } 23 | } 24 | 25 | func updateRefreshControlOffset() { 26 | collectionView?.verticalScrollIndicatorInsets.top = headerSize.height 27 | collectionView?.refreshControl?.bounds.origin.y = -(headerSize.height / 2) 28 | } 29 | } 30 | 31 | struct ContentView: View { 32 | @ObservedObject var headerSizeModel = HeaderSizeModel() 33 | @State private var isSearching = false 34 | @EnvironmentObject private var store: Store 35 | @State private var scrollDirection: ScrollDirection = .up 36 | @State private var showProAlert = false 37 | @Environment(\.colorScheme) private var colorScheme 38 | @ObservedObject private var userSettings = UserSettings() 39 | private var foregroundPublisher = NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) 40 | 41 | func hidePager() { 42 | store.showPager = false 43 | } 44 | 45 | func handleComicOpen() { 46 | store.showPager = true 47 | } 48 | 49 | func handleShowProAlert() { 50 | let FIVE_MINUTES_IN_MS = 5 * 60 * 1000 51 | 52 | if !userSettings.showedProAlert && !userSettings.isSubscribedToPro && userSettings.timeSpentInApp > FIVE_MINUTES_IN_MS { 53 | self.showProAlert = true 54 | userSettings.showedProAlert = true 55 | } 56 | } 57 | 58 | func handleProDetailsOpen() { 59 | self.store.showSettings = true 60 | } 61 | 62 | func handleShuffleButtonPress() { 63 | self.store.shuffle { 64 | handleComicOpen() 65 | } 66 | } 67 | 68 | var body: some View { 69 | ZStack { 70 | if self.store.filteredComics.count > 0 { 71 | ComicsGridView(onComicOpen: self.handleComicOpen, hideCurrentComic: self.store.showPager, scrollDirection: self.$scrollDirection, collectionView: self.$headerSizeModel.collectionView).edgesIgnoringSafeArea(.bottom) 72 | } else if self.store.searchText == "" && self.store.filteredComics.count == 0 { 73 | if self.store.selectedPage == .favorites { 74 | Text("Go make some new favorites!").font(Font.body.bold()).foregroundColor(.secondary) 75 | } else if self.store.selectedPage == .unread { 76 | Text("You're all caught up!").font(Font.body.bold()).foregroundColor(.secondary) 77 | } 78 | } 79 | 80 | if self.store.searchText != "" && self.store.filteredComics.count == 0 { 81 | Text("No results were found.").font(Font.body.bold()).foregroundColor(.secondary) 82 | } 83 | 84 | VStack { 85 | FloatingButtons(isSearching: self.$isSearching, onShuffle: self.handleShuffleButtonPress) 86 | .padding() 87 | .opacity(self.scrollDirection == .up || self.store.searchText != "" ? 1 : 0) 88 | .animation(.default) 89 | .captureSize(in: self.$headerSizeModel.headerSize) 90 | .sheet(isPresented: self.$store.showSettings) { 91 | SettingsSheet(onDismiss: { 92 | self.store.showSettings = false 93 | }) 94 | } 95 | 96 | Spacer() 97 | 98 | FloatingNavBarView() 99 | .animation(.spring()) 100 | } 101 | 102 | if self.store.showPager { 103 | ComicPager(onHide: self.hidePager).onAppear(perform: handleShowProAlert) 104 | } 105 | 106 | if self.store.filteredComics.count == 0 && self.store.searchText == "" && self.store.selectedPage == .all { 107 | FortuneLoader() 108 | .frame(maxWidth: .infinity, maxHeight: .infinity) 109 | .background(self.colorScheme == .dark ? Color.black : Color.white) 110 | } 111 | } 112 | .alert(isPresented: self.$showProAlert) { 113 | Alert(title: Text("Enjoying XKCDY?"), message: Text("Consider upgrading to XKCDY Pro for a few perks and to help support development!"), primaryButton: .default(Text("More Details"), action: self.handleProDetailsOpen), secondaryButton: .default(Text("Don't show this again"))) 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /XKCDY/Icons/beret-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/beret-dark.png -------------------------------------------------------------------------------- /XKCDY/Icons/beret-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/beret-dark@2x.png -------------------------------------------------------------------------------- /XKCDY/Icons/beret-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/beret-dark@3x.png -------------------------------------------------------------------------------- /XKCDY/Icons/beret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/beret.png -------------------------------------------------------------------------------- /XKCDY/Icons/beret@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/beret@2x.png -------------------------------------------------------------------------------- /XKCDY/Icons/beret@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/beret@3x.png -------------------------------------------------------------------------------- /XKCDY/Icons/blackhat-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/blackhat-dark.png -------------------------------------------------------------------------------- /XKCDY/Icons/blackhat-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/blackhat-dark@2x.png -------------------------------------------------------------------------------- /XKCDY/Icons/blackhat-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/blackhat-dark@3x.png -------------------------------------------------------------------------------- /XKCDY/Icons/blackhat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/blackhat.png -------------------------------------------------------------------------------- /XKCDY/Icons/blackhat@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/blackhat@2x.png -------------------------------------------------------------------------------- /XKCDY/Icons/blackhat@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/blackhat@3x.png -------------------------------------------------------------------------------- /XKCDY/Icons/megan-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/megan-dark.png -------------------------------------------------------------------------------- /XKCDY/Icons/megan-dark@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/megan-dark@2x.png -------------------------------------------------------------------------------- /XKCDY/Icons/megan-dark@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/megan-dark@3x.png -------------------------------------------------------------------------------- /XKCDY/Icons/megan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/megan.png -------------------------------------------------------------------------------- /XKCDY/Icons/megan@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/megan@2x.png -------------------------------------------------------------------------------- /XKCDY/Icons/megan@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/XKCDY/app/ee31b0670af2f7c33388deaa340bd86e3b7af067/XKCDY/Icons/megan@3x.png -------------------------------------------------------------------------------- /XKCDY/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BGTaskSchedulerPermittedIdentifiers 6 | 7 | com.maxisom.XKCDY.comicFetcher 8 | 9 | CFBundleDevelopmentRegion 10 | $(DEVELOPMENT_LANGUAGE) 11 | CFBundleExecutable 12 | $(EXECUTABLE_NAME) 13 | CFBundleIcons 14 | 15 | CFBundleAlternateIcons 16 | 17 | beret 18 | 19 | CFBundleIconFiles 20 | 21 | beret 22 | 23 | 24 | beret-dark 25 | 26 | CFBundleIconFiles 27 | 28 | beret-dark 29 | 30 | 31 | blackhat 32 | 33 | CFBundleIconFiles 34 | 35 | blackhat 36 | 37 | 38 | blackhat-dark 39 | 40 | CFBundleIconFiles 41 | 42 | blackhat-dark 43 | 44 | 45 | megan 46 | 47 | CFBundleIconFiles 48 | 49 | megan 50 | 51 | 52 | megan-dark 53 | 54 | CFBundleIconFiles 55 | 56 | megan-dark 57 | 58 | 59 | 60 | CFBundlePrimaryIcon 61 | 62 | CFBundleIconFiles 63 | 64 | beret 65 | 66 | UIPrerenderedIcon 67 | 68 | 69 | 70 | CFBundleIcons~ipad 71 | 72 | CFBundleAlternateIcons 73 | 74 | beret 75 | 76 | CFBundleIconFiles 77 | 78 | beret 79 | 80 | 81 | beret-dark 82 | 83 | CFBundleIconFiles 84 | 85 | beret-dark 86 | 87 | 88 | blackhat 89 | 90 | CFBundleIconFiles 91 | 92 | blackhat 93 | 94 | 95 | blackhat-dark 96 | 97 | CFBundleIconFiles 98 | 99 | blackhat-dark 100 | 101 | 102 | megan 103 | 104 | CFBundleIconFiles 105 | 106 | megan 107 | 108 | 109 | megan-dark 110 | 111 | CFBundleIconFiles 112 | 113 | megan-dark 114 | 115 | 116 | 117 | CFBundlePrimaryIcon 118 | 119 | CFBundleIconFiles 120 | 121 | beret 122 | 123 | UIPrerenderedIcon 124 | 125 | 126 | 127 | CFBundleIdentifier 128 | $(PRODUCT_BUNDLE_IDENTIFIER) 129 | CFBundleInfoDictionaryVersion 130 | 6.0 131 | CFBundleName 132 | $(PRODUCT_NAME) 133 | CFBundlePackageType 134 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 135 | CFBundleShortVersionString 136 | $(MARKETING_VERSION) 137 | CFBundleURLTypes 138 | 139 | 140 | CFBundleTypeRole 141 | Viewer 142 | CFBundleURLIconFile 143 | 144 | CFBundleURLName 145 | com.maxisom.xkcdy 146 | CFBundleURLSchemes 147 | 148 | xkcdy 149 | 150 | 151 | 152 | CFBundleVersion 153 | $(CURRENT_PROJECT_VERSION) 154 | ITSAppUsesNonExemptEncryption 155 | 156 | LSRequiresIPhoneOS 157 | 158 | NSAppTransportSecurity 159 | 160 | NSAllowsArbitraryLoads 161 | 162 | 163 | NSPhotoLibraryAddUsageDescription 164 | XKCDY needs access to save photos. 165 | NSPhotoLibraryUsageDescription 166 | XKCDY needs access to save photos. 167 | NSUserActivityTypes 168 | 169 | GetComicIntent 170 | GetLatestComicIntent 171 | ViewLatestComicIntent 172 | 173 | UIApplicationSceneManifest 174 | 175 | UIApplicationSupportsMultipleScenes 176 | 177 | UISceneConfigurations 178 | 179 | UIWindowSceneSessionRoleApplication 180 | 181 | 182 | UISceneConfigurationName 183 | Default Configuration 184 | UISceneDelegateClassName 185 | $(PRODUCT_MODULE_NAME).SceneDelegate 186 | 187 | 188 | 189 | 190 | UIBackgroundModes 191 | 192 | fetch 193 | 194 | UILaunchStoryboardName 195 | LaunchScreen 196 | UIRequiredDeviceCapabilities 197 | 198 | armv7 199 | 200 | UISupportedInterfaceOrientations 201 | 202 | UIInterfaceOrientationPortrait 203 | UIInterfaceOrientationLandscapeLeft 204 | UIInterfaceOrientationLandscapeRight 205 | 206 | UISupportedInterfaceOrientations~ipad 207 | 208 | UIInterfaceOrientationPortrait 209 | UIInterfaceOrientationPortraitUpsideDown 210 | UIInterfaceOrientationLandscapeLeft 211 | UIInterfaceOrientationLandscapeRight 212 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /XKCDY/Models/Comic.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import RealmSwift 3 | import UIKit 4 | 5 | class ComicImage: Object { 6 | @Persisted var u = "" 7 | 8 | var url: URL? { 9 | get { URL(string: u) } 10 | set { u = newValue!.absoluteString } 11 | } 12 | 13 | var size: CGSize { 14 | CGSize(width: width, height: height) 15 | } 16 | 17 | @Persisted var width = 0 18 | @Persisted var height = 0 19 | @Persisted var ratio: Float = 0.0 20 | } 21 | 22 | class ComicImages: Object { 23 | @Persisted var x1: ComicImage? 24 | @Persisted var x2: ComicImage? 25 | } 26 | 27 | class Comic: Object { 28 | @Persisted(primaryKey: true) var id = 0 29 | @Persisted var publishedAt = Date() 30 | @Persisted var news = "" 31 | @Persisted var safeTitle = "" 32 | @Persisted var title = "" 33 | @Persisted var transcript = "" 34 | @Persisted var alt = "" 35 | @Persisted var sURL = "" 36 | @Persisted var eURL = "" 37 | @Persisted var iURL: String? 38 | @Persisted var imgs: ComicImages? 39 | @Persisted var isFavorite = false 40 | @Persisted var isRead = false 41 | 42 | var sourceURL: URL? { 43 | get { URL(string: sURL) } 44 | 45 | set { sURL = newValue!.absoluteString } 46 | } 47 | 48 | var explainURL: URL? { 49 | get { URL(string: eURL) } 50 | 51 | set { eURL = newValue!.absoluteString } 52 | } 53 | 54 | var interactiveUrl: URL? { 55 | get { iURL == nil ? nil : URL(string: iURL!) } 56 | 57 | set { iURL = newValue != nil ? newValue!.absoluteString : nil } 58 | } 59 | 60 | static func getSample() -> Comic { 61 | let comic = Comic() 62 | 63 | comic.id = 100 64 | comic.publishedAt = Date() 65 | comic.safeTitle = "Sample Comic" 66 | comic.title = "Sample Comic with long long long title" 67 | comic.transcript = "A very short transcript." 68 | comic.alt = "Some alt text." 69 | comic.eURL = "https://www.explainxkcd.com/wiki/index.php/2328:_Space_Basketball" 70 | comic.iURL = "https://victorz.ca/xkcd_map/#10/1.1000/0.2000" 71 | 72 | let image = ComicImage() 73 | image.height = 510 74 | image.width = 1480 75 | image.ratio = 2.90196078431373 76 | image.url = URL(string: "https://imgs.xkcd.com/comics/acceptable_risk_2x.png") 77 | 78 | let images = ComicImages() 79 | images.x2 = image 80 | 81 | comic.imgs = images 82 | 83 | return comic 84 | } 85 | 86 | static func getTallSample() -> Comic { 87 | let comic = Comic() 88 | 89 | comic.id = 2329 90 | comic.publishedAt = Date() 91 | comic.safeTitle = "Universal Rating Scale" 92 | comic.title = "Universal Rating Scale" 93 | comic.transcript = "No transcript" 94 | comic.alt = "There are plenty of finer gradations. I got 'critically endangered/extinct in the wild' on my exam, although the curve bumped it all the way up to 'venti.'" 95 | comic.eURL = "https://www.explainxkcd.com/wiki/index.php/2329:_Universal_Rating_Scale" 96 | 97 | let image = ComicImage() 98 | image.height = 1945 99 | image.width = 443 100 | image.ratio = 0.227763496143959 101 | image.url = URL(string: "https://imgs.xkcd.com/comics/universal_rating_scale_2x.png") 102 | 103 | let images = ComicImages() 104 | images.x2 = image 105 | 106 | comic.imgs = images 107 | 108 | return comic 109 | } 110 | 111 | override static func primaryKey() -> String? { 112 | return "id" 113 | } 114 | } 115 | 116 | extension Comic { 117 | // Return x1 if x2 is absurdly large 118 | func getReasonableImageURL() -> URL? { 119 | if let images = imgs { 120 | if let x2 = images.x2 { 121 | if x2.width * x2.height < 50000000 { 122 | return x2.url 123 | } 124 | } 125 | } 126 | 127 | return imgs?.x1?.url 128 | } 129 | 130 | func getBestAvailableSize() -> ComicImage? { 131 | return imgs?.x2 ?? imgs?.x1 132 | } 133 | 134 | // Always return x2 if it exists 135 | func getBestImageURL() -> URL? { 136 | return self.getBestAvailableSize()?.url 137 | } 138 | } 139 | 140 | final class Comics: Object { 141 | @objc dynamic var id = 0 142 | 143 | let comics = List() 144 | 145 | override class func primaryKey() -> String? { 146 | "id" 147 | } 148 | } 149 | 150 | final class LastFilteredComics: Object { 151 | @objc dynamic var id = 0 152 | 153 | let comics = List() 154 | 155 | override class func primaryKey() -> String? { 156 | "id" 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /XKCDY/Models/TimeComicFrame.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeComicFrame.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 9/16/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | class TimeComicFrame: Object { 13 | @objc dynamic var id = "" 14 | @objc dynamic var date = Date() 15 | @objc dynamic var url = "" 16 | @objc dynamic var frameNumber = 0 17 | 18 | func getURL() -> URL? { 19 | URL(string: url) 20 | } 21 | 22 | override static func primaryKey() -> String? { 23 | return "id" 24 | } 25 | 26 | override static func indexedProperties() -> [String] { 27 | return ["id", "frameNumber"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /XKCDY/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /XKCDY/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SceneDelegate.swift 3 | // DCKX 4 | // 5 | // Created by Max Isom on 4/13/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import RealmSwift 12 | import Combine 13 | import WidgetKit 14 | 15 | class AnyGestureRecognizer: UIGestureRecognizer { 16 | override func touchesBegan(_ touches: Set, with event: UIEvent) { 17 | // To prevent keyboard hide and show when switching from one textfield to another 18 | if let textField = touches.first?.view, textField is UITextField { 19 | state = .failed 20 | } else { 21 | state = .began 22 | } 23 | } 24 | 25 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 26 | state = .ended 27 | } 28 | 29 | override func touchesCancelled(_ touches: Set, with event: UIEvent) { 30 | state = .cancelled 31 | } 32 | } 33 | 34 | extension SceneDelegate: UIGestureRecognizerDelegate { 35 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { 36 | return true 37 | } 38 | } 39 | 40 | let timeTracker = TimeTracker() 41 | 42 | protocol NotificationResponseHandler: UIWindowSceneDelegate { 43 | func handleNotificationResponse(response: UNNotificationResponse) 44 | } 45 | 46 | class SceneDelegate: UIResponder, UIWindowSceneDelegate, NotificationResponseHandler { 47 | var window: UIWindow? 48 | var store = Store(isLive: true) 49 | var notificationSubscriptions: [AnyCancellable] = [] 50 | var hasBecameActive = false 51 | var isLatestComicRead: Bool? 52 | 53 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 54 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 55 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 56 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 57 | 58 | // Create the SwiftUI view that provides the window contents. 59 | // let contentView = ContentView() 60 | // 61 | // // Use a UIHostingController as window root view controller. 62 | // if let windowScene = scene as? UIWindowScene { 63 | // let window = UIWindow(windowScene: windowScene) 64 | // window.rootViewController = UIHostingController(rootView: contentView) 65 | // self.window = window 66 | // window.makeKeyAndVisible() 67 | // } 68 | 69 | if let windowScene = scene as? UIWindowScene { 70 | let window = UIWindow(windowScene: windowScene) 71 | 72 | let realm = try! Realm() 73 | var comics = realm.object(ofType: Comics.self, forPrimaryKey: 0) 74 | if comics == nil { 75 | comics = try! realm.write { realm.create(Comics.self, value: []) } 76 | } 77 | 78 | let controller = UIHostingController(rootView: ContentView().environmentObject(comics!.comics).environmentObject(store)) 79 | 80 | window.rootViewController = controller 81 | self.window = window 82 | window.makeKeyAndVisible() 83 | 84 | let tapGesture = AnyGestureRecognizer(target: window, action: #selector(UIView.endEditing)) 85 | tapGesture.requiresExclusiveTouchType = false 86 | tapGesture.cancelsTouchesInView = false 87 | tapGesture.delegate = self // I don't use window as delegate to minimize possible side effects 88 | window.addGestureRecognizer(tapGesture) 89 | 90 | // Set and update tint color 91 | let userSettings = UserSettings() 92 | window.tintColor = userSettings.tintColor 93 | 94 | notificationSubscriptions.append(userSettings.objectWillChange.sink { 95 | window.tintColor = userSettings.tintColor 96 | }) 97 | 98 | // Check for initial URL 99 | self.navigate(connectionOptions.urlContexts) 100 | } 101 | } 102 | 103 | func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { 104 | self.navigate(URLContexts) 105 | } 106 | 107 | func navigate(_ URLContexts: Set?) { 108 | if let urlContext = URLContexts?.first { 109 | if urlContext.url.host == "comics" { 110 | if let id = Int(urlContext.url.path.replacingOccurrences(of: "/", with: "")) { 111 | self.showComicWith(id: id) 112 | } 113 | } 114 | } 115 | } 116 | 117 | private func showComicWith(id: Int) { 118 | let realm = try! Realm() 119 | 120 | if realm.object(ofType: Comic.self, forPrimaryKey: id) != nil { 121 | self.store.selectedPage = .all 122 | self.store.searchText = "" 123 | self.store.currentComicId = id 124 | self.store.showPager = true 125 | } 126 | } 127 | 128 | func sceneDidDisconnect(_ scene: UIScene) { 129 | // Called as the scene is being released by the system. 130 | // This occurs shortly after the scene enters the background, or when its session is discarded. 131 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 132 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead). 133 | } 134 | 135 | func sceneDidBecomeActive(_ scene: UIScene) { 136 | // Called when the scene has moved from an inactive state to an active state. 137 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 138 | DispatchQueue.global(qos: .background).async { 139 | let store = Store(isLive: false) 140 | 141 | // Only do a full refresh on first launch 142 | if self.hasBecameActive { 143 | store.partialRefetchComics { _ in 144 | self.updateIsLatestComicRead() 145 | } 146 | } else { 147 | store.refetchComics { _ in 148 | self.updateIsLatestComicRead() 149 | } 150 | } 151 | 152 | self.hasBecameActive = true 153 | } 154 | 155 | timeTracker.startTracker() 156 | } 157 | 158 | func updateIsLatestComicRead() { 159 | let realm = try! Realm() 160 | 161 | self.isLatestComicRead = realm.object(ofType: Comics.self, forPrimaryKey: 0)?.comics.sorted(byKeyPath: "id").last?.isRead ?? nil 162 | } 163 | 164 | func sceneWillResignActive(_ scene: UIScene) { 165 | // Called when the scene will move from an active state to an inactive state. 166 | // This may occur due to temporary interruptions (ex. an incoming phone call). 167 | } 168 | 169 | func sceneWillEnterForeground(_ scene: UIScene) { 170 | // Called as the scene transitions from the background to the foreground. 171 | // Use this method to undo the changes made on entering the background. 172 | } 173 | 174 | func sceneDidEnterBackground(_ scene: UIScene) { 175 | // Called as the scene transitions from the foreground to the background. 176 | // Use this method to save data, release shared resources, and store enough scene-specific state information 177 | // to restore the scene back to its current state. 178 | // swiftlint:disable:next force_cast 179 | (UIApplication.shared.delegate as! AppDelegate).scheduleBackgroundRefresh() 180 | timeTracker.stopTracker() 181 | 182 | if #available(iOS 14.0, *) { 183 | // Reload widgets showing latest comic 184 | let wasRead = self.isLatestComicRead 185 | 186 | self.updateIsLatestComicRead() 187 | 188 | if self.isLatestComicRead != wasRead { 189 | WidgetCenter.shared.getCurrentConfigurations { result in 190 | guard case .success(let widgets) = result else { return } 191 | 192 | for widget in widgets where (widget.configuration as? ViewLatestComicIntent) != nil { 193 | WidgetCenter.shared.reloadTimelines(ofKind: widget.kind) 194 | } 195 | } 196 | } 197 | } 198 | } 199 | 200 | func handleNotificationResponse(response: UNNotificationResponse) { 201 | store.partialRefetchComics { _ in 202 | if let comicId = response.notification.request.content.userInfo["comicId"] as? Int { 203 | self.showComicWith(id: comicId) 204 | } 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /XKCDY/Support/IAPHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IAPHelper.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/26/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import StoreKit 11 | import SwiftyStoreKit 12 | import TPInAppReceipt 13 | 14 | enum IAPError: Error { 15 | case restoreFailed 16 | case noPreviousValidPurchaseFound 17 | } 18 | 19 | public let XKCDYPro = "com.maxisom.XKCDY.pro" 20 | public let SKErrorCodeMapping: [SKError.Code: String] = [ 21 | .unknown: "Unknown error. Please contact support.", 22 | .clientInvalid: "Not allowed to make the payment.", 23 | .paymentCancelled: "No payment was made.", 24 | .paymentInvalid: "The purchase identifier was invalid.", 25 | .paymentNotAllowed: "The device is not allowed to make the payment.", 26 | .storeProductNotAvailable: "The product is not available in the current storefront.", 27 | .cloudServicePermissionDenied: "Access to cloud service information is not allowed.", 28 | .cloudServiceNetworkConnectionFailed: "Could not connect to the network.", 29 | .cloudServiceRevoked: "User has revoked permission to use this cloud service." 30 | ] 31 | 32 | final class IAPHelper { 33 | private static func hasActiveSubscription() throws -> Bool { 34 | if let receipt = try? InAppReceipt.localReceipt() { 35 | return receipt.hasActiveAutoRenewablePurchases 36 | } 37 | 38 | return false 39 | } 40 | 41 | private static func removeProFeatures() { 42 | Notifications.unregister() 43 | UserDefaults().removeObject(forKey: "tintColor") 44 | 45 | if UIApplication.shared.alternateIconName != nil { 46 | // Only works if we wait a few seconds after launch 47 | DispatchQueue.main.asyncAfter(deadline: .now() + 2) { 48 | UIApplication.shared.setAlternateIconName(nil) 49 | } 50 | } 51 | } 52 | 53 | static func checkForPurchaseAndUpdate() throws { 54 | let activeSubscription = try self.hasActiveSubscription() 55 | 56 | UserSettings().isSubscribedToPro = activeSubscription 57 | 58 | if !activeSubscription { 59 | self.removeProFeatures() 60 | } 61 | } 62 | 63 | static func restorePurchases(completion: @escaping (Result) -> Void) { 64 | SwiftyStoreKit.restorePurchases(atomically: true) { results in 65 | if results.restoreFailedPurchases.count > 0 { 66 | completion(.failure(.restoreFailed)) 67 | } else if results.restoredPurchases.count > 0 { 68 | do { 69 | try self.checkForPurchaseAndUpdate() 70 | 71 | if try self.hasActiveSubscription() { 72 | completion(.success(())) 73 | } else { 74 | completion(.failure(.noPreviousValidPurchaseFound)) 75 | } 76 | } catch { 77 | completion(.failure(.restoreFailed)) 78 | } 79 | } 80 | } 81 | } 82 | 83 | static func purchasePro(completion: @escaping (PurchaseResult) -> Void) { 84 | SwiftyStoreKit.purchaseProduct(XKCDYPro, atomically: true) { result in 85 | do { 86 | try self.checkForPurchaseAndUpdate() 87 | } catch {} 88 | 89 | completion(result) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /XKCDY/Support/Notifications.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notifications.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/19/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | import UIKit 9 | import Foundation 10 | import UserNotifications 11 | 12 | struct Notifications { 13 | static func requestPermissionAndRegisterForNotifications(completion: @escaping (Bool) -> Void) { 14 | UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in 15 | UNUserNotificationCenter.current().getNotificationSettings { settings in 16 | if settings.authorizationStatus == .authorized { 17 | self.register() 18 | completion(true) 19 | } else if settings.authorizationStatus == .denied { 20 | completion(false) 21 | } 22 | } 23 | } 24 | } 25 | 26 | static func openSettings() { 27 | DispatchQueue.main.async { 28 | UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil) 29 | } 30 | } 31 | 32 | static func register() { 33 | UNUserNotificationCenter.current().getNotificationSettings { settings in 34 | guard settings.authorizationStatus == .authorized else { return } 35 | 36 | DispatchQueue.main.async { 37 | print("Registering...") 38 | UIApplication.shared.registerForRemoteNotifications() 39 | } 40 | } 41 | } 42 | 43 | static func registerIfEnabled() { 44 | let settings = UserSettings() 45 | 46 | if settings.sendNotifications { 47 | self.requestPermissionAndRegisterForNotifications(completion: {granted in 48 | if !granted { 49 | settings.sendNotifications = false 50 | } 51 | }) 52 | } 53 | } 54 | 55 | static func didRegisterForRemoteNotificationsWithDeviceToken(_ deviceToken: Data) { 56 | let tokenParts = deviceToken.map { data in String(format: "%02.2hhx", data) } 57 | let token = tokenParts.joined() 58 | 59 | print("Device Token: \(token)") 60 | 61 | // Store device token 62 | UserSettings().deviceToken = token 63 | 64 | guard let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { 65 | return 66 | } 67 | 68 | if UserSettings().isSubscribedToPro { 69 | API.putDeviceToken(token: token, version: appVersion) { result in 70 | switch result { 71 | case .success: 72 | print("Registered for notifications with XKCDY") 73 | case .failure: 74 | print("Failed to register for notifications with XKCDY") 75 | } 76 | } 77 | } 78 | } 79 | 80 | static func unregister() { 81 | print("Unregistering...") 82 | 83 | if UserSettings().deviceToken == "" { 84 | return 85 | } 86 | 87 | API.removeDeviceToken(token: UserSettings().deviceToken) { result in 88 | switch result { 89 | case .success: 90 | print("Successfully unregistered.") 91 | UserSettings().deviceToken = "" 92 | UserSettings().sendNotifications = false 93 | case .failure: 94 | print("Failed to unregister.") 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /XKCDY/Support/TimeTracker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeTracker.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 8/5/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | final class TimeTracker { 12 | private var userSettings = UserSettings() 13 | private var startedAt = Date().currentTimeMillis() 14 | 15 | func startTracker() { 16 | startedAt = Date().currentTimeMillis() 17 | } 18 | 19 | func stopTracker() { 20 | let difference = Date().currentTimeMillis() - startedAt 21 | 22 | userSettings.timeSpentInApp += difference 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /XKCDY/Support/UserSettings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserSettings.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/17/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Combine 11 | import UIKit 12 | 13 | @propertyWrapper 14 | struct UserDefault { 15 | let key: String 16 | let defaultValue: T 17 | 18 | var wrappedValue: T { 19 | get { 20 | return UserDefaults.standard.object(forKey: key) as? T ?? defaultValue 21 | } 22 | set { 23 | UserDefaults.standard.set(newValue, forKey: key) 24 | } 25 | } 26 | } 27 | 28 | final class UserSettings: ObservableObject { 29 | let objectWillChange = ObservableObjectPublisher() 30 | 31 | @UserDefault(key: "sendNotifications", defaultValue: false) var sendNotifications: Bool 32 | @UserDefault(key: "deviceToken", defaultValue: "") var deviceToken: String 33 | @UserDefault(key: "isSubscribedToPro", defaultValue: false) var isSubscribedToPro: Bool 34 | @UserDefault(key: "timeSpentInApp", defaultValue: 0) var timeSpentInApp: Int64 35 | @UserDefault(key: "showedProAlert", defaultValue: false) var showedProAlert: Bool 36 | @UserDefault(key: "showAltInPager", defaultValue: false) var showAltInPager: Bool 37 | @UserDefault(key: "showComicIdInPager", defaultValue: false) var showComicIdInPager: Bool 38 | @UserDefault(key: "showCOVIDComics", defaultValue: false) var showCOVIDComics: Bool 39 | 40 | var tintColor: UIColor { 41 | get { 42 | if let colorData = UserDefaults.standard.data(forKey: "tintColor") { 43 | do { 44 | if let color = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(colorData) as? UIColor { 45 | return color 46 | } 47 | } catch {} 48 | } 49 | 50 | return UIColor.systemBlue 51 | } 52 | set { 53 | var colorData: NSData? 54 | do { 55 | let data = try NSKeyedArchiver.archivedData(withRootObject: newValue, requiringSecureCoding: false) as NSData? 56 | colorData = data 57 | } catch {} 58 | 59 | UserDefaults.standard.set(colorData, forKey: "tintColor") 60 | } 61 | } 62 | 63 | private var notificationSubscription: AnyCancellable? 64 | 65 | init() { 66 | notificationSubscription = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification).sink { _ in 67 | self.objectWillChange.send() 68 | } 69 | } 70 | } 71 | 72 | let COVID_COMICS = [2275, 2276, 2277, 2278, 2279, 2280, 2281, 2282, 2283, 2284, 2285, 2286, 2287, 2289, 2290, 2291, 2292, 2293, 2294, 2296, 2298, 2299, 2300, 2302, 2305, 2306, 2330, 2331, 2332, 2333, 2338, 2339, 2342, 2346] 73 | -------------------------------------------------------------------------------- /XKCDY/Support/XKCDTimeClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XKCDTimeClient.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 9/16/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Alamofire 11 | 12 | struct TimeComicFrameResponse: Decodable { 13 | let apocryphal: Bool 14 | let baFrameNo: String 15 | let downloadedUrl: String 16 | let epoch: Int 17 | let frameNo: String 18 | let gwFrameNo: String 19 | let hash: String 20 | let xkcdUrl: String 21 | } 22 | 23 | extension TimeComicFrameResponse { 24 | func getImageURL() -> URL? { 25 | return URL(string: xkcdUrl) 26 | } 27 | } 28 | 29 | extension TimeComicFrameResponse { 30 | func toObject() -> TimeComicFrame { 31 | let frame = TimeComicFrame() 32 | 33 | frame.id = hash 34 | frame.date = Date(timeIntervalSince1970: Double(epoch) / 1000.0) 35 | frame.url = xkcdUrl 36 | frame.frameNumber = Int(baFrameNo) ?? 0 37 | 38 | return frame 39 | } 40 | } 41 | 42 | final class XKCDTimeClient { 43 | static func getFrames(completion: @escaping (Result<[TimeComicFrameResponse], APIError>) -> Void) { 44 | AF.request("https://api.xkcdy.com/static/time.json").responseDecodable(of: [TimeComicFrameResponse].self) { (response: AFDataResponse<[TimeComicFrameResponse]>) -> Void in 45 | do { 46 | completion(.success(try response.result.get())) 47 | } catch { 48 | completion(.failure(.decoding)) 49 | } 50 | 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /XKCDY/Support/XKCDYClient.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Alamofire 3 | 4 | struct ComicImgResponse: Decodable { 5 | let height: Int 6 | let width: Int 7 | let ratio: Float 8 | let sourceUrl: String 9 | let size: String 10 | } 11 | 12 | struct ComicResponse: Decodable { 13 | let id: Int 14 | let publishedAt: Date 15 | let news: String 16 | let safeTitle: String 17 | let title: String 18 | let transcript: String 19 | let alt: String 20 | let sourceUrl: String 21 | let explainUrl: String 22 | let interactiveUrl: String? 23 | let imgs: [ComicImgResponse] 24 | } 25 | 26 | enum APIError: Error { 27 | case other 28 | case decoding 29 | } 30 | 31 | enum DateError: String, Error { 32 | case invalidDate 33 | } 34 | 35 | let BASE_URL = "https://api.xkcdy.com" 36 | 37 | final class API { 38 | static func getComics(completion: @escaping (Result<[ComicResponse], APIError>) -> Void) { 39 | self.getComics(since: 0, completion: completion) 40 | } 41 | 42 | static func getComics(since: Int, completion: @escaping (Result<[ComicResponse], APIError>) -> Void) { 43 | AF.request("\(BASE_URL)/comics", parameters: ["since": String(since)]).responseDecodable(of: [ComicResponse].self, decoder: self.getDecoder()) { (response: AFDataResponse<[ComicResponse]>) -> Void in 44 | do { 45 | completion(.success(try response.result.get())) 46 | } catch { 47 | completion(.failure(.decoding)) 48 | } 49 | } 50 | } 51 | 52 | static func putDeviceToken(token: String, version: String, completion: @escaping (Result) -> Void) { 53 | let parameters = [ 54 | "token": token, 55 | "version": version 56 | ] 57 | 58 | AF.request("\(BASE_URL)/device-tokens", method: .put, parameters: parameters, encoder: JSONParameterEncoder.default).response { response in 59 | if response.error != nil { 60 | completion(.failure(.other)) 61 | } else { 62 | completion(.success(())) 63 | } 64 | } 65 | } 66 | 67 | static func removeDeviceToken(token: String, completion: @escaping (Result) -> Void) { 68 | AF.request("\(BASE_URL)/device-tokens/\(token)", method: .delete).response { response in 69 | if response.error != nil { 70 | completion(.failure(.other)) 71 | } else { 72 | completion(.success(())) 73 | } 74 | } 75 | } 76 | 77 | private static func getDecoder() -> JSONDecoder { 78 | let decoder = JSONDecoder() 79 | 80 | let formatter = DateFormatter() 81 | formatter.calendar = Calendar(identifier: .iso8601) 82 | formatter.locale = Locale(identifier: "en_US_POSIX") 83 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 84 | 85 | decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in 86 | let container = try decoder.singleValueContainer() 87 | let dateStr = try container.decode(String.self) 88 | 89 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 90 | if let date = formatter.date(from: dateStr) { 91 | return date 92 | } 93 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX" 94 | if let date = formatter.date(from: dateStr) { 95 | return date 96 | } 97 | throw DateError.invalidDate 98 | }) 99 | 100 | return decoder 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /XKCDY/Views/ActivityIndicator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ActivityIndicator.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/30/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import UIKit 12 | 13 | struct ActivityIndicator: UIViewRepresentable { 14 | typealias UIViewType = UIActivityIndicatorView 15 | let style: UIActivityIndicatorView.Style 16 | 17 | func makeUIView(context: UIViewRepresentableContext) -> ActivityIndicator.UIViewType { 18 | return UIActivityIndicatorView(style: style) 19 | } 20 | 21 | func updateUIView(_ uiView: ActivityIndicator.UIViewType, context: UIViewRepresentableContext) { 22 | uiView.startAnimating() 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /XKCDY/Views/AppIconPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppIconPicker.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/28/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | let ICON_NAME_MAPPING = [ 12 | "beret": "Beret", 13 | "beret-dark": "Beret Dark", 14 | "megan": "Megan", 15 | "megan-dark": "Megan Dark", 16 | "blackhat": "Blackhat", 17 | "blackhat-dark": "Blackhat Dark" 18 | ] 19 | 20 | struct AppIconPicker: View { 21 | @ObservedObject var alternateIcons = AlternateIcons() 22 | 23 | func mapNameToDescription(_ name: String) -> String { 24 | return ICON_NAME_MAPPING[name] ?? name 25 | } 26 | 27 | var body: some View { 28 | VStack { 29 | List { 30 | ForEach(0 ..< alternateIcons.iconNames.count) { i in 31 | HStack { 32 | Image(uiImage: UIImage(named: self.alternateIcons.iconNames[i]) ?? UIImage()) 33 | .resizable() 34 | .renderingMode(.original) 35 | .frame(width: 40, height: 40) 36 | .cornerRadius(10) 37 | 38 | Text(self.mapNameToDescription(self.alternateIcons.iconNames[i])) 39 | 40 | Spacer() 41 | 42 | if i == self.alternateIcons.currentIndex { 43 | Image(systemName: "checkmark") 44 | .font(Font.body.bold()) 45 | .foregroundColor(.accentColor) 46 | } 47 | } 48 | .contentShape(Rectangle()) 49 | .onTapGesture { 50 | self.alternateIcons.currentIndex = i 51 | } 52 | } 53 | } 54 | .padding(.top) 55 | } 56 | .navigationBarTitle(Text("Pick an icon"), displayMode: .inline) 57 | .onReceive([self.alternateIcons.currentIndex].publisher.first()) { value in 58 | let i = self.alternateIcons.iconNames.firstIndex(of: UIApplication.shared.alternateIconName ?? "") ?? 0 59 | 60 | if value != i { 61 | UIApplication.shared.setAlternateIconName(self.alternateIcons.iconNames[value]) 62 | } 63 | } 64 | } 65 | } 66 | 67 | struct AppIconPicker_Previews: PreviewProvider { 68 | static var previews: some View { 69 | AppIconPicker() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /XKCDY/Views/ColorPickerRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorPickerRow.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/28/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ColorOption: Identifiable, Hashable { 12 | var id = UUID() 13 | var color: UIColor 14 | var description: String 15 | } 16 | 17 | struct ColorPickerRow: View { 18 | var option: ColorOption 19 | var selected: Bool 20 | 21 | var body: some View { 22 | HStack { 23 | Rectangle() 24 | .fill(Color(option.color)) 25 | .frame(width: 35, height: 35) 26 | .cornerRadius(8) 27 | 28 | Text(option.description) 29 | 30 | Spacer() 31 | 32 | if selected { 33 | Image(systemName: "checkmark") 34 | .font(Font.body.bold()) 35 | .foregroundColor(.accentColor) 36 | } 37 | } 38 | .contentShape(Rectangle()) 39 | } 40 | } 41 | 42 | struct ColorPickerRow_Previews: PreviewProvider { 43 | static var previews: some View { 44 | ColorPickerRow(option: ColorOption(color: UIColor.red, description: "Bright red"), selected: true) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /XKCDY/Views/ComicBadge.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicBadge.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/7/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ComicBadge: View { 12 | var comic: Comic 13 | 14 | var body: some View { 15 | HStack { 16 | if comic.isFavorite { 17 | Image(systemName: "heart.fill") 18 | .font(.caption) 19 | .foregroundColor(.red) 20 | } 21 | 22 | if SUPPORTED_SPECIAL_COMICS.contains(comic.id) { 23 | Image(systemName: "sparkles") 24 | .font(.caption) 25 | .colorScheme(.dark) 26 | } 27 | 28 | Text(String(comic.id)) 29 | .font(.caption) 30 | .fontWeight(.bold) 31 | .colorScheme(.dark) 32 | } 33 | .padding(EdgeInsets(top: 5, leading: 8, bottom: 5, trailing: 8)) 34 | .background(comic.isRead ? Color(.gray) : Color(.darkGray)) 35 | .cornerRadius(10) 36 | } 37 | } 38 | 39 | struct ComicBadge_Previews: PreviewProvider { 40 | static var previews: some View { 41 | ComicBadge(comic: Comic()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /XKCDY/Views/ComicDetailsSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicDetailsSheet.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/8/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ComicDetailsSheet: View { 12 | var comic: Comic 13 | var onDismiss: () -> Void 14 | @State private var showSheet = false 15 | @State private var webViewUrl = URL(string: "https://xkcdy.com")! 16 | 17 | func getDateFormatter() -> DateFormatter { 18 | let formatter = DateFormatter() 19 | 20 | formatter.dateFormat = "MMMM d, yyyy" 21 | 22 | return formatter 23 | } 24 | 25 | var body: some View { 26 | NavigationView { 27 | VStack { 28 | HStack { 29 | VStack(alignment: .leading) { 30 | Text("#\(comic.id)") 31 | .font(.largeTitle) 32 | .fontWeight(.bold) 33 | Text(getDateFormatter().string(from: comic.publishedAt)) 34 | .font(.headline) 35 | } 36 | 37 | Spacer() 38 | }.padding() 39 | 40 | Spacer() 41 | 42 | Text(comic.alt) 43 | .padding() 44 | 45 | Spacer() 46 | 47 | HStack { 48 | // TODO: remove this hack for iOS 14 once it's out of beta 49 | if self.webViewUrl.absoluteString != "" { 50 | Spacer() 51 | } 52 | 53 | Spacer() 54 | 55 | Button(action: { 56 | if let explainURL = self.comic.explainURL { 57 | self.webViewUrl = explainURL 58 | self.showSheet = true 59 | } 60 | }) { 61 | Image(systemName: "questionmark.circle.fill").resizable().scaledToFit().frame(width: 24, height: 24) 62 | } 63 | 64 | if self.comic.interactiveUrl != nil { 65 | Button(action: { 66 | if let interactiveURL = self.comic.interactiveUrl { 67 | self.webViewUrl = interactiveURL 68 | self.showSheet = true 69 | } 70 | }) { 71 | Image(systemName: "wand.and.stars").resizable().scaledToFit().frame(width: 24, height: 24) 72 | } 73 | .padding(.leading) 74 | } 75 | } 76 | .padding(30) 77 | } 78 | .navigationBarTitle(Text(comic.safeTitle), displayMode: .inline) 79 | .navigationBarItems(trailing: Button(action: self.onDismiss) { 80 | Text("Done").bold() 81 | }) 82 | } 83 | .navigationViewStyle(StackNavigationViewStyle()) 84 | .sheet(isPresented: $showSheet) { 85 | SafariView(url: self.webViewUrl).edgesIgnoringSafeArea(.bottom) 86 | } 87 | } 88 | } 89 | 90 | struct ComicDetailsSheet_Previews: PreviewProvider { 91 | static var previews: some View { 92 | ComicDetailsSheet(comic: .getSample(), onDismiss: {}) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /XKCDY/Views/ComicGridItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicGridItem.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/11/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Kingfisher 11 | import RealmSwift 12 | 13 | struct AnimatableFontModifier: AnimatableModifier { 14 | var size: CGFloat 15 | 16 | var animatableData: CGFloat { 17 | get {size} 18 | set {size = newValue} 19 | } 20 | 21 | func body(content: Content) -> some View { 22 | content.font(.system(size: size)) 23 | } 24 | } 25 | 26 | extension View { 27 | func animatableFont( size: CGFloat) -> some View { 28 | self.modifier(AnimatableFontModifier(size: size)) 29 | } 30 | } 31 | 32 | struct ComicGridItem: View { 33 | var comic: Comic 34 | var onTap: (Int) -> Void 35 | var hideBadge = false 36 | var isScrolling: Bool 37 | @EnvironmentObject var store: Store 38 | @State private var pulseEnded = false 39 | @State private var isLoaded = false 40 | 41 | var body: some View { 42 | GeometryReader { geom -> AnyView in 43 | if !self.isScrolling { 44 | self.store.updatePosition(for: self.comic.id, at: CGRect(x: geom.frame(in: .global).midX, y: geom.frame(in: .global).midY, width: geom.size.width, height: geom.size.height)) 45 | } 46 | 47 | let stack = ZStack { 48 | GeometryReader { _ in 49 | VStack { 50 | KFImage(self.comic.getReasonableImageURL()!) 51 | .onSuccess({ _ in 52 | self.isLoaded = true 53 | }) 54 | .cancelOnDisappear(true) 55 | .resizable() 56 | .scaledToFill() 57 | .opacity(self.isLoaded ? 1 : 0) 58 | .animation(.none) 59 | } 60 | } 61 | 62 | if !self.isLoaded { 63 | VStack { 64 | Rectangle() 65 | .fill(Color.secondary) 66 | .opacity(self.pulseEnded ? 0.4 : 0.2) 67 | .frame(width: geom.size.width, height: geom.size.height) 68 | .animation(Animation.easeInOut(duration: 0.75).repeatForever()) 69 | .onAppear { 70 | self.pulseEnded = true 71 | } 72 | .transition(.opacity) 73 | } 74 | } 75 | } 76 | .frame(width: geom.size.width, height: geom.size.height) 77 | .onTapGesture { 78 | self.onTap(self.comic.id) 79 | } 80 | 81 | return AnyView( 82 | stack 83 | .overlay( 84 | ComicBadge(comic: self.comic) 85 | .opacity(self.hideBadge ? 0 : 1) 86 | .animation(.easeInOut, value: self.hideBadge).padding(EdgeInsets(top: 0, leading: 0, bottom: 5, trailing: 5)), 87 | alignment: .bottomTrailing 88 | ) 89 | ) 90 | } 91 | } 92 | } 93 | 94 | struct ComicGridItem_Previews: PreviewProvider { 95 | static var previews: some View { 96 | ComicGridItem(comic: Comic.getSample(), onTap: { id in 97 | print(id) 98 | }, isScrolling: false) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /XKCDY/Views/ComicPagerOverlay.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ComicPagerOverlay.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/17/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import RealmSwift 11 | import StoreKit 12 | import class Kingfisher.ImageCache 13 | 14 | enum ActiveSheet { 15 | case share, details 16 | } 17 | 18 | struct ButtonBarItem: ViewModifier { 19 | func body(content: Content) -> some View { 20 | content 21 | .font(.system(size: 24)) 22 | .padding(.horizontal) 23 | .padding(.top) 24 | } 25 | } 26 | 27 | struct ComicPagerOverlay: View { 28 | @Binding var showSheet: Bool 29 | @Binding var activeSheet: ActiveSheet 30 | private var generator = UIImpactFeedbackGenerator() 31 | @State private var imageToShare: UIImage? 32 | @State private var showingShareOptionsSheet = false 33 | @EnvironmentObject var store: Store 34 | var onShuffle: () -> Void 35 | var onClose: () -> Void 36 | @ObservedObject private var userSettings = UserSettings() 37 | 38 | init(showSheet: Binding, activeSheet: Binding, onShuffle: @escaping () -> Void, onClose: @escaping () -> Void) { 39 | self._showSheet = showSheet 40 | self._activeSheet = activeSheet 41 | self.onShuffle = onShuffle 42 | self.onClose = onClose 43 | self.generator.prepare() 44 | } 45 | 46 | private func shareImage(withDetails: Bool) { 47 | self.activeSheet = .share 48 | 49 | if withDetails { 50 | self.imageToShare = SharableComicView(comic: self.store.comic).snapshot() 51 | 52 | self.showSheet = true 53 | } else { 54 | ImageCache.default.retrieveImage(forKey: self.store.comic.getBestImageURL()!.absoluteString) { result in 55 | switch result { 56 | case .success(let value): 57 | self.imageToShare = value.image 58 | 59 | self.showSheet = true 60 | 61 | case .failure: 62 | return 63 | } 64 | } 65 | } 66 | } 67 | 68 | private func shareURL() { 69 | self.imageToShare = nil 70 | self.activeSheet = .share 71 | self.showSheet = true 72 | } 73 | 74 | var body: some View { 75 | GeometryReader { geom in 76 | VStack { 77 | ZStack { 78 | HStack { 79 | Button(action: self.onClose) { 80 | Image(systemName: "chevron.left").font(.system(size: 24)).frame(width: 40, height: 40).padding(.leading) 81 | } 82 | 83 | VStack { 84 | Text(self.store.comic.safeTitle) 85 | .font(.title) 86 | .multilineTextAlignment(.center) 87 | 88 | if self.userSettings.showComicIdInPager { 89 | Text("#\(self.store.comic.id)").font(.headline) 90 | } 91 | }.frame(maxWidth: .infinity) 92 | 93 | Image(systemName: "chevron.left").font(.system(size: 24)).frame(width: 40, height: 40).padding(.trailing).hidden() 94 | } 95 | .padding() 96 | .padding(.top, geom.safeAreaInsets.top) 97 | .frame(minWidth: 0, maxWidth: .infinity) 98 | .background(Blur()) 99 | .animation(.none) 100 | } 101 | 102 | Spacer() 103 | 104 | VStack { 105 | if self.userSettings.showAltInPager { 106 | Text(self.store.comic.alt) 107 | .multilineTextAlignment(.center) 108 | // Prevent from overlapping with notch 109 | .padding(.trailing, geom.safeAreaInsets.trailing) 110 | .padding(.leading, geom.safeAreaInsets.leading) 111 | } 112 | 113 | HStack { 114 | Button(action: { 115 | self.showingShareOptionsSheet = true 116 | }) { 117 | Image(systemName: "square.and.arrow.up").modifier(ButtonBarItem()) 118 | } 119 | .actionSheet(isPresented: self.$showingShareOptionsSheet) { 120 | ActionSheet(title: Text("What do you want to share?"), buttons: [ 121 | .default(Text("Share image"), action: { 122 | self.shareImage(withDetails: false) 123 | }), 124 | .default(Text("Share image with details"), action: { 125 | self.shareImage(withDetails: true) 126 | }), 127 | .default(Text("Share link to comic"), action: self.shareURL), 128 | .cancel() 129 | ]) 130 | } 131 | 132 | Button(action: { 133 | self.activeSheet = .details 134 | self.showSheet = true 135 | }) { 136 | Image(systemName: "info.circle.fill").modifier(ButtonBarItem()) 137 | } 138 | 139 | HStack { 140 | Spacer() 141 | 142 | ZStack { 143 | Image(systemName: "heart.fill") 144 | .opacity(self.store.comic.isFavorite ? 1 : 0) 145 | .animation(.none) 146 | .scaleEffect(self.store.comic.isFavorite ? 1 : 0) 147 | .foregroundColor(.red) 148 | 149 | Image(systemName: "heart") 150 | .opacity(self.store.comic.isFavorite ? 0 : 1) 151 | .animation(.none) 152 | .scaleEffect(self.store.comic.isFavorite ? 0 : 1) 153 | .foregroundColor(.accentColor) 154 | } 155 | .modifier(ButtonBarItem()) 156 | .scaleEffect(self.store.comic.isFavorite ? 1.1 : 1) 157 | .animation(.interpolatingSpring(stiffness: 180, damping: 15)) 158 | 159 | Spacer() 160 | } 161 | .contentShape(Rectangle()) 162 | .onTapGesture { 163 | self.generator.impactOccurred() 164 | 165 | let realm = try! Realm() 166 | 167 | try! realm.write { 168 | self.store.comic.isFavorite = !self.store.comic.isFavorite 169 | } 170 | 171 | // Request review if appropriate 172 | let numberOfFavoritedComics = realm.objects(Comic.self).filter {$0.isFavorite}.count 173 | 174 | if numberOfFavoritedComics == 2 { 175 | SKStoreReviewController.requestReview() 176 | } 177 | } 178 | 179 | // Invisible icon for padding 180 | Image(systemName: "info.circle.fill").modifier(ButtonBarItem()).hidden() 181 | 182 | Button(action: self.onShuffle) { 183 | Image(systemName: "shuffle").modifier(ButtonBarItem()) 184 | } 185 | } 186 | } 187 | .padding() 188 | .padding(.bottom, geom.safeAreaInsets.bottom) 189 | .background(Blur()) 190 | } 191 | .edgesIgnoringSafeArea(.all) 192 | } 193 | .sheet(isPresented: self.$showSheet) { 194 | if self.activeSheet == .share { 195 | if self.imageToShare == nil { 196 | URLActivityViewController(url: self.store.comic.sourceURL!) 197 | } else { 198 | UIImageActivityViewController(uiImage: self.imageToShare!, title: self.store.comic.safeTitle, url: self.store.comic.sourceURL!) 199 | } 200 | } else if self.activeSheet == .details { 201 | ComicDetailsSheet(comic: self.store.comic, onDismiss: { 202 | self.showSheet = false 203 | }) 204 | } 205 | } 206 | } 207 | } 208 | 209 | struct ComicPagerOverlay_Previews: PreviewProvider { 210 | static var previews: some View { 211 | ComicPagerOverlay(showSheet: .constant(false), activeSheet: .constant(.details), onShuffle: { 212 | print("shuffling") 213 | }, onClose: { 214 | print("closing") 215 | }).colorScheme(.dark) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /XKCDY/Views/ComicsGrid.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // DCKX 4 | // 5 | // Created by Max Isom on 4/13/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | import Combine 12 | import RealmSwift 13 | import ASCollectionView 14 | import class Kingfisher.ImagePrefetcher 15 | import class Kingfisher.ImageCache 16 | 17 | enum ScrollDirection { 18 | case up, down 19 | } 20 | 21 | class WaterfallScreenLayoutDelegate: ASCollectionViewDelegate, ASWaterfallLayoutDelegate { 22 | public var collectionView: Binding = .constant(nil) 23 | 24 | func heightForHeader(sectionIndex: Int) -> CGFloat? { 25 | 0 26 | } 27 | 28 | func heightForCell(at indexPath: IndexPath, context: ASWaterfallLayout.CellLayoutContext) -> CGFloat { 29 | guard let comic: Comic = getDataForItem(at: indexPath) else { return 100 } 30 | let height = context.width / CGFloat(comic.imgs!.x1!.ratio) 31 | return height 32 | } 33 | 34 | func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint { 35 | self.collectionView.wrappedValue = collectionView 36 | 37 | return proposedContentOffset 38 | } 39 | } 40 | 41 | extension ASWaterfallLayout.ColumnCount: Equatable { 42 | public static func == (lhs: ASWaterfallLayout.ColumnCount, rhs: ASWaterfallLayout.ColumnCount) -> Bool { 43 | switch (lhs, rhs) { 44 | case (.fixed(let a), .fixed(let b)): 45 | return a == b 46 | default: 47 | return false 48 | } 49 | 50 | } 51 | } 52 | 53 | @propertyWrapper struct PublishedWithoutViewUpdate { 54 | let publisher: PassthroughSubject 55 | private var _wrappedValue: Value 56 | 57 | init(wrappedValue: Value) { 58 | self.publisher = PassthroughSubject() 59 | self._wrappedValue = wrappedValue 60 | } 61 | 62 | var wrappedValue: Value { 63 | get { 64 | _wrappedValue 65 | } 66 | 67 | set { 68 | _wrappedValue = newValue 69 | publisher.send() 70 | } 71 | } 72 | 73 | var projectedValue: PassthroughSubject { 74 | self.publisher 75 | } 76 | } 77 | 78 | class ScrollStateModel: ObservableObject { 79 | @Published var isScrolling = false 80 | @PublishedWithoutViewUpdate var scrollPosition: CGFloat = 0 { 81 | willSet { 82 | if !self.isScrolling { 83 | self.isScrolling = true 84 | } 85 | } 86 | } 87 | 88 | private var cancellables = Set() 89 | 90 | init() { 91 | $scrollPosition 92 | .debounce(for: .milliseconds(50), scheduler: DispatchQueue.global()) 93 | .sink { _ in 94 | DispatchQueue.main.async { 95 | self.isScrolling = false 96 | } 97 | } 98 | .store(in: &cancellables) 99 | } 100 | } 101 | 102 | struct ComicsGridView: View { 103 | @State var columnMinSize: CGFloat = 150 104 | @State var inViewUrls: [String] = [] 105 | var onComicOpen: () -> Void 106 | var hideCurrentComic: Bool 107 | @Binding var scrollDirection: ScrollDirection 108 | @Binding var collectionView: UICollectionView? 109 | @EnvironmentObject var store: Store 110 | @State private var scrollPosition: ASCollectionViewScrollPosition? 111 | @State private var showErrorAlert = false 112 | @State private var lastScrollPositions: [CGFloat] = [] 113 | @State private var shouldBlurStatusBar = false 114 | @ObservedObject private var scrollState = ScrollStateModel() 115 | 116 | func onCellEvent(_ event: CellEvent) { 117 | switch event { 118 | case let .prefetchForData(data): 119 | ImagePrefetcher(urls: data.map {$0.getReasonableImageURL()!}).start() 120 | case let .cancelPrefetchForData(data): 121 | ImagePrefetcher(urls: data.map {$0.getReasonableImageURL()!}).stop() 122 | default: 123 | return 124 | } 125 | } 126 | 127 | func handleComicTap(of comicId: Int) { 128 | self.store.currentComicId = comicId 129 | self.onComicOpen() 130 | } 131 | 132 | func onPullToRefresh(_ endRefreshing: @escaping () -> Void) { 133 | DispatchQueue.global(qos: .background).async { 134 | self.store.refetchComics { result -> Void in 135 | endRefreshing() 136 | 137 | switch result { 138 | case .success: 139 | self.showErrorAlert = false 140 | case .failure: 141 | self.showErrorAlert = true 142 | } 143 | } 144 | } 145 | } 146 | 147 | var body: some View { 148 | GeometryReader {geom in 149 | AnyView(ASCollectionView( 150 | section: ASSection( 151 | id: 0, 152 | // Wrapping with Array() results in significantly better performance 153 | // (even though it shouldn't) because Realm has its own extremely slow 154 | // implementation of .firstIndex(), which ASCollectionView calls when rendering. 155 | // Don't believe me? Try unwrapping it and scrolling to the bottom. 156 | data: Array(self.store.frozenFilteredComics), 157 | dataID: \.self, 158 | onCellEvent: self.onCellEvent, 159 | dragDropConfig: ASDragDropConfig(dataBinding: .constant([])).dragItemProvider { item in 160 | let provider = NSItemProvider() 161 | 162 | provider.registerObject(ofClass: UIImage.self, visibility: .all) { completion in 163 | ImageCache.default.retrieveImage(forKey: item.getBestImageURL()!.absoluteString) { result in 164 | switch result { 165 | case .success(let value): 166 | completion(value.image, nil) 167 | case .failure(let error): 168 | completion(nil, error) 169 | } 170 | } 171 | 172 | return Progress.discreteProgress(totalUnitCount: 0) 173 | } 174 | 175 | return provider 176 | 177 | }) { comic, _ -> AnyView in 178 | AnyView( 179 | ComicGridItem(comic: comic, onTap: self.handleComicTap, hideBadge: self.hideCurrentComic && comic.id == self.store.currentComicId, isScrolling: self.scrollState.isScrolling) 180 | // Isn't SwiftUI fun? 181 | .environmentObject(self.store) 182 | .opacity(self.hideCurrentComic && comic.id == self.store.currentComicId ? 0 : 1) 183 | .animation(.none) 184 | ) 185 | } 186 | ) 187 | .animateOnDataRefresh(false) 188 | .onPullToRefresh(self.onPullToRefresh) 189 | .onScroll { (point, _) in 190 | self.scrollState.scrollPosition = point.y 191 | 192 | DispatchQueue.main.async { 193 | self.shouldBlurStatusBar = point.y > 80 194 | 195 | if point.y < 5 { 196 | self.scrollDirection = .up 197 | return 198 | } 199 | 200 | self.lastScrollPositions.append(point.y) 201 | 202 | self.lastScrollPositions = self.lastScrollPositions.suffix(2) 203 | 204 | if self.lastScrollPositions.count == 2 { 205 | self.scrollDirection = self.lastScrollPositions[0] < self.lastScrollPositions[1] ? .down : .up 206 | } 207 | } 208 | } 209 | .scrollPositionSetter(self.$scrollPosition) 210 | .layout(createCustomLayout: ASWaterfallLayout.init) { layout in 211 | let columns = min(Int(UIScreen.main.bounds.width / self.columnMinSize), 4) 212 | 213 | if layout.columnSpacing != 10 { 214 | layout.columnSpacing = 10 215 | } 216 | if layout.itemSpacing != 10 { 217 | layout.itemSpacing = 10 218 | } 219 | 220 | if layout.numberOfColumns != .fixed(columns) { 221 | layout.numberOfColumns = .fixed(columns) 222 | } 223 | } 224 | .customDelegate({ 225 | let delegate = WaterfallScreenLayoutDelegate.init() 226 | delegate.collectionView = $collectionView 227 | return delegate 228 | }) 229 | .contentInsets(.init(top: 40, left: 10, bottom: 80, right: 10)) 230 | ) 231 | .onReceive(self.store.$debouncedCurrentComicId, perform: { _ -> Void in 232 | if self.store.currentComicId == nil { 233 | return 234 | } 235 | 236 | if let comicIndex = self.store.filteredComics.firstIndex(where: { $0.id == self.store.currentComicId }) { 237 | self.scrollPosition = .indexPath(IndexPath(item: comicIndex, section: 0)) 238 | } 239 | }) 240 | .alert(isPresented: self.$showErrorAlert) { 241 | Alert(title: Text("Error Refreshing"), message: Text("There was an error refreshing. Try again later."), dismissButton: .default(Text("Ok"))) 242 | } 243 | 244 | Rectangle().fill(Color.clear) 245 | .background(Blur(style: .regular)) 246 | .frame(width: geom.size.width, height: geom.safeAreaInsets.top) 247 | .position(x: geom.size.width / 2, y: -geom.safeAreaInsets.top / 2) 248 | .opacity(self.shouldBlurStatusBar && !self.store.showPager ? 1 : 0) 249 | .animation(.default) 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /XKCDY/Views/FloatingButtons.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingButtons.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/15/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct RoundButtonIcon: ViewModifier { 12 | @Environment(\.colorScheme) var colorScheme 13 | 14 | func body(content: Content) -> some View { 15 | content 16 | .padding(12) 17 | .background(Blur()) 18 | .clipShape(Circle()) 19 | .font(.title) 20 | .shadow(radius: 2) 21 | } 22 | } 23 | 24 | struct CustomTextField: UIViewRepresentable { 25 | class Coordinator: NSObject, UITextFieldDelegate { 26 | @Binding var text: String 27 | var didBecomeFirstResponder = false 28 | 29 | init(text: Binding) { 30 | _text = text 31 | } 32 | 33 | func textFieldDidChangeSelection(_ textField: UITextField) { 34 | text = textField.text ?? "" 35 | } 36 | } 37 | 38 | var placeholder: String 39 | @Binding var text: String 40 | var isFirstResponder: Bool = false 41 | 42 | func makeUIView(context: UIViewRepresentableContext) -> UITextField { 43 | let textField = UITextField(frame: .zero) 44 | textField.delegate = context.coordinator 45 | textField.placeholder = placeholder 46 | textField.contentVerticalAlignment = .center 47 | return textField 48 | } 49 | 50 | func makeCoordinator() -> CustomTextField.Coordinator { 51 | return Coordinator(text: $text) 52 | } 53 | 54 | func updateUIView(_ uiView: UITextField, context: UIViewRepresentableContext) { 55 | uiView.text = text 56 | if isFirstResponder && !context.coordinator.didBecomeFirstResponder { 57 | uiView.becomeFirstResponder() 58 | context.coordinator.didBecomeFirstResponder = true 59 | } 60 | } 61 | } 62 | 63 | struct FloatingButtons: View { 64 | @Binding var isSearching: Bool 65 | var onShuffle: () -> Void 66 | @Environment(\.verticalSizeClass) private var verticalSizeClass: UserInterfaceSizeClass? 67 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass: UserInterfaceSizeClass? 68 | @EnvironmentObject private var store: Store 69 | @State private var filteringDisabled = false 70 | @State private var textFieldSize = CGSize.zero 71 | 72 | init(isSearching: Binding, onShuffle: @escaping () -> Void) { 73 | self._isSearching = isSearching 74 | self.onShuffle = onShuffle 75 | UITextField.appearance().clearButtonMode = .always 76 | } 77 | 78 | func isLargeScreen() -> Bool { 79 | self.verticalSizeClass == .some(.regular) && self.horizontalSizeClass == .some(.regular) 80 | } 81 | 82 | var body: some View { 83 | HStack { 84 | if self.isLargeScreen() { 85 | Spacer() 86 | } 87 | 88 | if !self.isSearching { 89 | Button(action: { 90 | self.store.showSettings = true 91 | }) { 92 | Image(systemName: "gear") 93 | .modifier(RoundButtonIcon()) 94 | } 95 | .transition(AnyTransition.opacity.combined(with: .move(edge: .leading))) 96 | 97 | if self.isLargeScreen() { 98 | Spacer().frame(width: 25) 99 | } else { 100 | Spacer() 101 | } 102 | 103 | Button(action: { 104 | self.onShuffle() 105 | }) { 106 | Image(systemName: "shuffle") 107 | .modifier(RoundButtonIcon()) 108 | } 109 | .disabled(self.filteringDisabled) 110 | .transition(AnyTransition.opacity.combined(with: .move(edge: .leading))) 111 | } 112 | 113 | if self.isLargeScreen() { 114 | Spacer().frame(width: 25) 115 | } else { 116 | Spacer() 117 | } 118 | 119 | HStack { 120 | if self.isSearching && self.isLargeScreen() { 121 | GeometryReader { _ in 122 | EmptyView() 123 | }.frame(height: self.textFieldSize.height) 124 | } 125 | 126 | Button(action: { 127 | withAnimation(.spring()) { 128 | if self.isSearching { 129 | self.store.searchText = "" 130 | } 131 | 132 | self.isSearching.toggle() 133 | } 134 | }) { 135 | Image(systemName: self.isSearching ? "arrow.left" : "magnifyingglass") 136 | .transition(.opacity) 137 | .modifier(RoundButtonIcon()) 138 | } 139 | .transition(.move(edge: .trailing)) 140 | .disabled(self.filteringDisabled) 141 | 142 | if self.isSearching { 143 | // Yes, this whole thing is extremely ugly. 144 | // I can't figure out a better way to do it. 145 | GeometryReader { _ in 146 | HStack(alignment: .center) { 147 | Image(systemName: "magnifyingglass") 148 | 149 | Text("") 150 | .font(.title) 151 | .padding(6) 152 | .frame(maxWidth: .infinity) 153 | .layoutPriority(1000) 154 | .overlay( 155 | CustomTextField(placeholder: "Start typing...", text: self.$store.searchText, isFirstResponder: true) 156 | ) 157 | } 158 | .padding(12) 159 | .background(Blur()) 160 | .cornerRadius(8) 161 | .captureSize(in: self.$textFieldSize) 162 | } 163 | .frame(height: self.textFieldSize.height) 164 | .transition(AnyTransition.opacity.combined(with: .move(edge: .trailing))) 165 | } 166 | } 167 | } 168 | .onReceive(self.store.objectWillChange) { _ in 169 | if self.store.filteredComics.count == 0 && self.store.searchText == "" { 170 | self.filteringDisabled = true 171 | } else { 172 | self.filteringDisabled = false 173 | } 174 | } 175 | } 176 | } 177 | 178 | struct FloatingButtons_Previews: PreviewProvider { 179 | static var previews: some View { 180 | FloatingButtons(isSearching: .constant(true), onShuffle: { 181 | print("Shuffle button clicked.") 182 | }) 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /XKCDY/Views/FloatingNavBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FloatingNavBar.swift 3 | // DCKX 4 | // 5 | // Created by Max Isom on 4/21/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | 12 | struct Blur: UIViewRepresentable { 13 | var style: UIBlurEffect.Style = .systemMaterial 14 | func makeUIView(context: Context) -> UIVisualEffectView { 15 | return UIVisualEffectView(effect: UIBlurEffect(style: style)) 16 | } 17 | func updateUIView(_ uiView: UIVisualEffectView, context: Context) { 18 | uiView.effect = UIBlurEffect(style: style) 19 | } 20 | } 21 | 22 | extension UIApplication { 23 | func endEditing() { 24 | sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) 25 | } 26 | } 27 | 28 | struct FloatingNavBarView: View { 29 | @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? 30 | @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? 31 | 32 | func isLargeScreen() -> Bool { 33 | self.verticalSizeClass == .some(.regular) && self.horizontalSizeClass == .some(.regular) 34 | } 35 | 36 | var body: some View { 37 | GeometryReader { geom in 38 | VStack { 39 | Spacer() 40 | 41 | HStack { 42 | ZStack(alignment: .bottom) { 43 | SegmentedPicker().frame(maxWidth: self.isLargeScreen() ? geom.size.width / 2 : .infinity) 44 | } 45 | .padding() 46 | .shadow(radius: 2) 47 | 48 | if self.isLargeScreen() { 49 | Spacer() 50 | } 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /XKCDY/Views/FortuneLoader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FortuneLoader.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 8/6/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct FortuneLoader: View { 12 | @State private var fortune = Fortunes().getRandom() 13 | @State private var timer: Timer? 14 | 15 | var body: some View { 16 | VStack { 17 | ActivityIndicator(style: .large) 18 | 19 | Text(fortune.text) 20 | .fixedSize(horizontal: false, vertical: true) 21 | .multilineTextAlignment(.center) 22 | .id("FortuneLoader" + fortune.text) 23 | .padding() 24 | 25 | Text(String(fortune.comicId)) 26 | .id("FortuneLoader" + String(fortune.comicId)) 27 | .font(Font.caption.bold()) 28 | } 29 | .transition(.opacity) 30 | .animation(.default) 31 | .onAppear { 32 | Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { timer in 33 | self.timer = timer 34 | self.fortune = Fortunes().getRandom() 35 | } 36 | } 37 | .onDisappear { 38 | self.timer?.invalidate() 39 | } 40 | } 41 | } 42 | 43 | struct FortuneLoader_Previews: PreviewProvider { 44 | static var previews: some View { 45 | FortuneLoader() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /XKCDY/Views/Modifiers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Modifiers.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 9/23/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | private struct SizeKey: PreferenceKey { 12 | static let defaultValue: CGSize = .zero 13 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 14 | value = nextValue() 15 | } 16 | } 17 | 18 | extension View { 19 | func captureSize(in binding: Binding) -> some View { 20 | overlay(GeometryReader { proxy in 21 | Color.clear.preference(key: SizeKey.self, value: proxy.size) 22 | }) 23 | .onPreferenceChange(SizeKey.self) { size in binding.wrappedValue = size } 24 | } 25 | } 26 | 27 | struct Rotated: View { 28 | var view: Rotated 29 | var angle: Angle 30 | 31 | init(_ view: Rotated, angle: Angle = .degrees(-90)) { 32 | self.view = view 33 | self.angle = angle 34 | } 35 | 36 | @State private var size: CGSize = .zero 37 | 38 | var body: some View { 39 | // Rotate the frame, and compute the smallest integral frame that contains it 40 | let newFrame = CGRect(origin: .zero, size: size) 41 | .offsetBy(dx: -size.width/2, dy: -size.height/2) 42 | .applying(.init(rotationAngle: CGFloat(angle.radians))) 43 | .integral 44 | 45 | return view 46 | .fixedSize() // Don't change the view's ideal frame 47 | .captureSize(in: $size) // Capture the size of the view's ideal frame 48 | .rotationEffect(angle) // Rotate the view 49 | .frame(width: newFrame.width, // And apply the new frame 50 | height: newFrame.height) 51 | } 52 | } 53 | 54 | extension View { 55 | func rotated(_ angle: Angle = .degrees(-90)) -> some View { 56 | Rotated(self, angle: angle) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /XKCDY/Views/ProgressBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProgressBar.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 9/23/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | struct ProgressBar: View { 12 | @Binding var value: Float 13 | 14 | var body: some View { 15 | GeometryReader { geom in 16 | ZStack(alignment: .leading) { 17 | Rectangle() 18 | .frame(width: geom.size.width, height: geom.size.height) 19 | .opacity(0.3) 20 | .foregroundColor(Color.accentColor) 21 | 22 | Rectangle() 23 | .frame(width: min(CGFloat(value) * geom.size.width, geom.size.width), height: geom.size.height) 24 | .foregroundColor(Color.accentColor) 25 | .animation(.linear) 26 | }.cornerRadius(45) 27 | }.frame(height: 20) 28 | } 29 | } 30 | 31 | struct ProgressBar_Previews: PreviewProvider { 32 | static var previews: some View { 33 | ProgressBar(value: .constant(0.5)) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /XKCDY/Views/SafariView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariView.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 9/12/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import UIKit 11 | import SafariServices 12 | 13 | // https://david.y4ng.fr/swiftui-and-sfsafariviewcontroller/ 14 | struct SafariView: UIViewControllerRepresentable { 15 | typealias UIViewControllerType = CustomSafariViewController 16 | 17 | var url: URL? 18 | 19 | func makeUIViewController(context: UIViewControllerRepresentableContext) -> CustomSafariViewController { 20 | return CustomSafariViewController() 21 | } 22 | 23 | func updateUIViewController(_ safariViewController: CustomSafariViewController, context: UIViewControllerRepresentableContext) { 24 | safariViewController.url = url 25 | } 26 | } 27 | 28 | final class CustomSafariViewController: UIViewController { 29 | var url: URL? { 30 | didSet { 31 | // when url changes, reset the safari child view controller 32 | configureChildViewController() 33 | } 34 | } 35 | 36 | private var safariViewController: SFSafariViewController? 37 | 38 | override func viewDidLoad() { 39 | super.viewDidLoad() 40 | 41 | configureChildViewController() 42 | } 43 | 44 | private func configureChildViewController() { 45 | // Remove the previous safari child view controller if not nil 46 | if let safariViewController = safariViewController { 47 | safariViewController.willMove(toParent: self) 48 | safariViewController.view.removeFromSuperview() 49 | safariViewController.removeFromParent() 50 | self.safariViewController = nil 51 | } 52 | 53 | guard let url = url else { return } 54 | 55 | // Create a new safari child view controller with the url 56 | let newSafariViewController = SFSafariViewController(url: url) 57 | addChild(newSafariViewController) 58 | newSafariViewController.view.frame = view.frame 59 | view.addSubview(newSafariViewController.view) 60 | newSafariViewController.didMove(toParent: self) 61 | self.safariViewController = newSafariViewController 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /XKCDY/Views/SegmentedPicker.swift: -------------------------------------------------------------------------------- 1 | // https://gist.github.com/frankfka/2784adff55be72a4f044d8c2bcc9fd3f 2 | import SwiftUI 3 | 4 | extension View { 5 | func eraseToAnyView() -> AnyView { 6 | AnyView(self) 7 | } 8 | } 9 | 10 | struct SizePreferenceKey: PreferenceKey { 11 | typealias Value = CGSize 12 | static var defaultValue: CGSize = .zero 13 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) { 14 | value = nextValue() 15 | } 16 | } 17 | struct BackgroundGeometryReader: View { 18 | var body: some View { 19 | GeometryReader { geometry in 20 | return Color 21 | .clear 22 | .preference(key: SizePreferenceKey.self, value: geometry.size) 23 | } 24 | } 25 | } 26 | struct SizeAwareViewModifier: ViewModifier { 27 | 28 | @Binding private var viewSize: CGSize 29 | 30 | init(viewSize: Binding) { 31 | self._viewSize = viewSize 32 | } 33 | 34 | func body(content: Content) -> some View { 35 | content 36 | .background(BackgroundGeometryReader()) 37 | .onPreferenceChange(SizePreferenceKey.self, perform: { if self.viewSize != $0 { self.viewSize = $0 }}) 38 | } 39 | } 40 | 41 | extension Comparable { 42 | func clamped(to limits: ClosedRange) -> Self { 43 | return min(max(self, limits.lowerBound), limits.upperBound) 44 | } 45 | } 46 | 47 | // Note: ideally we could pass in a CaseIterable enum as a parameter. 48 | // Couldn't figure out a quick and dirty way to do it, so this works for now. 49 | struct SegmentedPicker: View { 50 | private let ActiveSegmentColor: Color = Color(.tertiarySystemBackground) 51 | private let BackgroundColor: Color = Color(.secondarySystemBackground) 52 | private let ShadowColor: Color = Color.black.opacity(0.2) 53 | private let TextColor: Color = Color(.secondaryLabel) 54 | private let SelectedTextColor: Color = Color(.label) 55 | 56 | private let TextFont: Font = .system(size: 12) 57 | 58 | private let SegmentCornerRadius: CGFloat = 20 59 | private let ShadowRadius: CGFloat = 4 60 | private let SegmentXPadding: CGFloat = 16 61 | private let SegmentYPadding: CGFloat = 8 62 | private let PickerPadding: CGFloat = 4 63 | 64 | @State private var offset: CGSize = .zero 65 | @Environment(\.colorScheme) var colorScheme 66 | @EnvironmentObject var store: Store 67 | 68 | // Stores the size of a segment, used to create the active segment rect 69 | @State private var segmentSize: CGSize = .zero 70 | // Rounded rectangle to denote active segment 71 | private var activeSegmentView: AnyView { 72 | // Don't show the active segment until we have initialized the view 73 | // This is required for `.animation()` to display properly, otherwise the animation will fire on init 74 | let isInitialized: Bool = segmentSize != .zero 75 | if !isInitialized { return EmptyView().eraseToAnyView() } 76 | return 77 | Rectangle() 78 | .fill(Color.clear) 79 | .background(Blur(style: colorScheme == .dark ? .light : .prominent)) 80 | .cornerRadius(self.SegmentCornerRadius) 81 | .shadow(color: self.ShadowColor, radius: self.ShadowRadius) 82 | .frame(width: self.segmentSize.width, height: self.segmentSize.height) 83 | .offset(x: self.computeActiveSegmentHorizontalOffset(), y: 0) 84 | .offset(x: self.offset.width) 85 | .animation(Animation.spring()) 86 | .gesture(DragGesture().onChanged(self.handleDragChange).onEnded(self.handleDragEnd)) 87 | .eraseToAnyView() 88 | } 89 | 90 | private func handleDragChange(gesture: DragGesture.Value) { 91 | if (0 <= gesture.translation.width + self.computeActiveSegmentHorizontalOffset()) && (gesture.translation.width + self.computeActiveSegmentHorizontalOffset() <= self.getMaxOffset()) { 92 | self.offset = gesture.translation 93 | } 94 | } 95 | 96 | private func handleDragEnd(_: DragGesture.Value) { 97 | var i = CGFloat((self.computeActiveSegmentHorizontalOffset() + self.offset.width)) / CGFloat((self.segmentSize.width + self.SegmentXPadding / 2)) 98 | i.round() 99 | 100 | self.offset = .zero 101 | self.store.selectedPage = Page.allCases[Int(i).clamped(to: 0...(Page.allCases.count - 1))] 102 | } 103 | 104 | var body: some View { 105 | // Align the ZStack to the leading edge to make calculating offset on activeSegmentView easier 106 | ZStack(alignment: .leading) { 107 | // activeSegmentView indicates the current selection 108 | self.activeSegmentView 109 | HStack { 110 | ForEach(Page.allCases, id: \.self) { page in 111 | self.getSegmentView(for: page) 112 | } 113 | } 114 | } 115 | .padding(self.PickerPadding) 116 | .background(Blur()) 117 | .clipShape(RoundedRectangle(cornerRadius: self.SegmentCornerRadius)) 118 | } 119 | 120 | // Helper method to compute the offset based on the selected index 121 | private func computeActiveSegmentHorizontalOffset() -> CGFloat { 122 | let index = Page.allCases.firstIndex(of: self.store.selectedPage)! 123 | 124 | return CGFloat(index) * (self.segmentSize.width + self.SegmentXPadding / 2) 125 | } 126 | 127 | private func getMaxOffset() -> CGFloat { 128 | CGFloat(Page.allCases.count - 1) * (self.segmentSize.width + self.SegmentXPadding / 2) 129 | } 130 | 131 | // Gets text view for the segment 132 | private func getSegmentView(for page: Page) -> some View { 133 | let isSelected = self.store.selectedPage == page 134 | return 135 | Text(page.name) 136 | .font(Font.body.bold()) 137 | // Dark test for selected segment 138 | .foregroundColor(Color.white) 139 | .colorMultiply(isSelected ? self.SelectedTextColor: self.TextColor) 140 | .lineLimit(1) 141 | .padding(.vertical, self.SegmentYPadding) 142 | .padding(.horizontal, self.SegmentXPadding) 143 | .frame(minWidth: 0, maxWidth: .infinity) 144 | // Watch for the size of the 145 | .modifier(SizeAwareViewModifier(viewSize: self.$segmentSize)) 146 | .contentShape(Rectangle()) 147 | .gesture(DragGesture().onChanged(self.handleDragChange).onEnded(self.handleDragEnd)) 148 | .onTapGesture { self.onItemTap(page: page) } 149 | .eraseToAnyView() 150 | } 151 | 152 | // On tap to change the selection 153 | private func onItemTap(page: Page) { 154 | self.store.selectedPage = page 155 | } 156 | 157 | } 158 | 159 | struct PreviewView: View { 160 | @State var selection: Page = .all 161 | private let items: [String] = ["M", "T", "W", "T", "F"] 162 | 163 | var body: some View { 164 | SegmentedPicker() 165 | .padding() 166 | } 167 | } 168 | 169 | struct SegmentedPicker_Previews: PreviewProvider { 170 | static var previews: some View { 171 | PreviewView() 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /XKCDY/Views/SharableComicView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SharableComicView.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 8/16/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import Kingfisher 11 | 12 | struct SharableComicView: View { 13 | var comic: Comic 14 | @State private var isLoaded = false 15 | 16 | func getFontSize() -> CGFloat { 17 | if let ratio = comic.imgs?.x1?.ratio { 18 | if ratio > 1 { 19 | return 30 20 | } 21 | } 22 | 23 | return 20 24 | } 25 | 26 | var body: some View { 27 | VStack { 28 | HStack(alignment: .center) { 29 | Text("#\(comic.id):") 30 | .font(Font.system(size: self.getFontSize()).bold()) 31 | .foregroundColor(Color.white) 32 | 33 | Text(comic.safeTitle) 34 | .lineLimit(1) 35 | .foregroundColor(Color.white) 36 | 37 | Spacer() 38 | 39 | Text(getDateFormatter().string(from: comic.publishedAt)) 40 | .foregroundColor(Color.white) 41 | } 42 | .padding(.horizontal) 43 | .padding(.vertical, self.getFontSize() / 2) 44 | 45 | KFImage(comic.getBestImageURL()) 46 | .onSuccess({ _ in 47 | self.isLoaded = true 48 | }) 49 | .resizable() 50 | .frame(height: CGFloat(comic.getBestAvailableSize()?.height ?? 0)) 51 | 52 | HStack { 53 | Text(comic.alt) 54 | .fixedSize(horizontal: false, vertical: true) 55 | .foregroundColor(Color.white) 56 | Spacer() 57 | } 58 | .padding(.horizontal) 59 | .padding(.vertical, self.getFontSize() / 2) 60 | } 61 | .padding(.vertical) 62 | .font(Font.system(size: self.getFontSize())) 63 | .frame(width: CGFloat(comic.getBestAvailableSize()?.width ?? 0)) 64 | .ignoresSafeArea() 65 | .background(Color.black) 66 | } 67 | 68 | func getDateFormatter() -> DateFormatter { 69 | let formatter = DateFormatter() 70 | 71 | formatter.dateFormat = "MMMM d, yyyy" 72 | 73 | return formatter 74 | } 75 | } 76 | 77 | // https://stackoverflow.com/a/59333377/2129808, https://ericasadun.com/2019/06/20/swiftui-render-your-mojave-swiftui-views-on-the-fly/ 78 | extension View { 79 | func snapshot() -> UIImage { 80 | let controller = UIHostingController(rootView: self) 81 | let view = controller.view 82 | 83 | let targetSize = controller.view.intrinsicContentSize 84 | view?.bounds = CGRect(origin: .zero, size: targetSize) 85 | view?.backgroundColor = .black 86 | 87 | let renderer = UIGraphicsImageRenderer(size: targetSize) 88 | 89 | return renderer.image { _ in 90 | view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) 91 | } 92 | } 93 | } 94 | 95 | struct SharableComicView_Previews: PreviewProvider { 96 | static var comic = Comic.getSample() 97 | 98 | static var previews: some View { 99 | SharableComicView(comic: comic) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /XKCDY/Views/ShareSheet.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import LinkPresentation 4 | 5 | class ImageInfoSource: UIViewController, UIActivityItemSource { 6 | var uiImage: UIImage! 7 | var text: String! 8 | var url: URL! 9 | 10 | func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { 11 | return "" 12 | } 13 | 14 | func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? { 15 | return nil 16 | } 17 | 18 | func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? { 19 | let imageProvider = NSItemProvider(object: uiImage) 20 | let metadata = LPLinkMetadata() 21 | metadata.imageProvider = imageProvider 22 | metadata.title = text 23 | metadata.originalURL = url 24 | 25 | return metadata 26 | } 27 | } 28 | 29 | struct UIImageActivityViewController: UIViewControllerRepresentable { 30 | let uiImage: UIImage 31 | let title: String 32 | let url: URL 33 | 34 | func makeUIViewController(context: Context) -> UIActivityViewController { 35 | let source = ImageInfoSource() 36 | 37 | source.uiImage = uiImage 38 | source.text = title 39 | source.url = url 40 | 41 | let activityViewController = UIActivityViewController(activityItems: [uiImage, source], applicationActivities: []) 42 | 43 | return activityViewController 44 | } 45 | 46 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} 47 | } 48 | 49 | struct URLActivityViewController: UIViewControllerRepresentable { 50 | let url: URL 51 | 52 | func makeUIViewController(context: Context) -> UIActivityViewController { 53 | let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) 54 | 55 | return activityViewController 56 | } 57 | 58 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} 59 | } 60 | -------------------------------------------------------------------------------- /XKCDY/Views/SpecialComicViewer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpecialComicViewer.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 9/18/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import SwiftUIPager 11 | import RealmSwift 12 | import Kingfisher 13 | import class Kingfisher.ImagePrefetcher 14 | import protocol Kingfisher.Resource 15 | import class Kingfisher.ImageCache 16 | 17 | public let SUPPORTED_SPECIAL_COMICS = [1190] 18 | 19 | let TIME_FRAME_DIMENSIONS = CGSize(width: 553, height: 395) 20 | 21 | extension ClosedRange { 22 | func clamp(_ value: Bound) -> Bound { 23 | return self.lowerBound > value ? self.lowerBound 24 | : self.upperBound < value ? self.upperBound 25 | : value 26 | } 27 | } 28 | 29 | struct TimeComicViewer: View { 30 | @State private var loadingProgress: Float = 0.0 31 | @State private var loading = false 32 | @EnvironmentObject private var store: Store 33 | @State private var currentFrame: Double = 0 34 | @State private var areAllFramesCached = true 35 | @State private var prefetcher: ImagePrefetcher? 36 | 37 | func cacheImages() { 38 | loading = true 39 | 40 | var urls: [URL] = [] 41 | 42 | for frame in store.timeComicFrames { 43 | if let url = frame.getURL() { 44 | urls.append(url) 45 | } 46 | } 47 | 48 | prefetcher = ImagePrefetcher(urls: urls, progressBlock: self.onProgressUpdate, completionHandler: { _, _, _ in 49 | loading = false 50 | areAllFramesCached = true 51 | }) 52 | 53 | prefetcher?.maxConcurrentDownloads = 100 54 | prefetcher?.start() 55 | } 56 | 57 | func onProgressUpdate(_ skippedResources: [Resource], _ failedResources: [Resource], _ completedResources: [Resource]) { 58 | let numberOfLoadedImages = skippedResources.count + completedResources.count 59 | 60 | loadingProgress = Float(numberOfLoadedImages) / Float(store.timeComicFrames.count) 61 | } 62 | 63 | func isLandscape(_ geom: GeometryProxy) -> Bool { 64 | geom.size.width > geom.size.height 65 | } 66 | 67 | func getImageWidthHeight(_ geom: GeometryProxy) -> CGSize { 68 | let isLandscape = geom.size.width > geom.size.height 69 | 70 | let ratio = CGFloat(TIME_FRAME_DIMENSIONS.width) / CGFloat(TIME_FRAME_DIMENSIONS.height) 71 | 72 | if isLandscape { 73 | return CGSize(width: geom.size.height / (1 / ratio), height: geom.size.height) 74 | } 75 | 76 | return CGSize(width: geom.size.width, height: geom.size.width / ratio) 77 | } 78 | 79 | func getFrameRange() -> ClosedRange { 80 | (0...Double(store.timeComicFrames.count - 1)) 81 | } 82 | 83 | func handleAppear() { 84 | for frame in store.timeComicFrames { 85 | if let url = frame.getURL() { 86 | if !ImageCache.default.isCached(forKey: url.absoluteString) { 87 | self.areAllFramesCached = false 88 | break 89 | } 90 | } 91 | } 92 | } 93 | 94 | var body: some View { 95 | Group { 96 | if store.timeComicFrames.count > 0 { 97 | GeometryReader { geom in 98 | VStack { 99 | if !self.isLandscape(geom) { 100 | Spacer() 101 | 102 | HStack { 103 | Spacer() 104 | 105 | Text("\(Int(currentFrame + 1)) / \(store.timeComicFrames.count)") 106 | .font(.headline) 107 | .animation(.none) 108 | .transition(.identity) 109 | } 110 | } 111 | 112 | HStack { 113 | if self.isLandscape(geom) { 114 | HStack { 115 | Slider(value: $currentFrame, in: getFrameRange(), step: 1) 116 | .padding() 117 | .frame(width: geom.size.height) 118 | .rotated(.degrees(90)) 119 | 120 | if self.loading { 121 | ProgressBar(value: $loadingProgress) 122 | .padding() 123 | .frame(width: geom.size.height) 124 | .rotated(.degrees(90)) 125 | .onDisappear { 126 | self.prefetcher?.stop() 127 | } 128 | } 129 | 130 | Spacer() 131 | } 132 | } 133 | 134 | KFImage(self.store.timeComicFrames[Int(currentFrame)].getURL()) 135 | .cancelOnDisappear(true) 136 | .resizable() 137 | .aspectRatio(contentMode: .fit) 138 | .frame(width: self.getImageWidthHeight(geom).width, height: self.getImageWidthHeight(geom).height) 139 | .gesture(DragGesture(minimumDistance: 0).onEnded { value in 140 | if abs(value.translation.width) + abs(value.translation.height) < 50 { 141 | // Probably a tap 142 | let isLeftTap = value.location.x < self.getImageWidthHeight(geom).width / 2 143 | 144 | if isLeftTap { 145 | self.currentFrame = getFrameRange().clamp(self.currentFrame - 1) 146 | } else { 147 | self.currentFrame = getFrameRange().clamp(self.currentFrame + 1) 148 | } 149 | } else { 150 | self.currentFrame = getFrameRange().clamp(value.translation.width < 0 ? self.currentFrame + 1 : self.currentFrame - 1) 151 | } 152 | }) 153 | .animation(.none) 154 | .transition(.identity) 155 | 156 | if self.isLandscape(geom) { 157 | HStack { 158 | Spacer() 159 | 160 | VStack(alignment: .trailing) { 161 | Text("\(Int(currentFrame + 1)) / \(store.timeComicFrames.count)") 162 | .font(.headline) 163 | .animation(.none) 164 | .transition(.identity) 165 | 166 | Spacer() 167 | 168 | if !self.areAllFramesCached && !self.loading { 169 | Button(action: { 170 | self.cacheImages() 171 | }) { 172 | Text("Load all frames") 173 | } 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | if !self.isLandscape(geom) { 181 | Slider(value: $currentFrame, in: getFrameRange(), step: 1).gesture(DragGesture()) 182 | 183 | Spacer() 184 | 185 | HStack { 186 | Spacer() 187 | 188 | if !self.areAllFramesCached && !self.loading { 189 | Button(action: { 190 | self.cacheImages() 191 | }) { 192 | Text("Load all frames") 193 | }.padding() 194 | } 195 | 196 | if self.loading { 197 | ProgressBar(value: $loadingProgress).padding() 198 | .onDisappear { 199 | self.prefetcher?.stop() 200 | } 201 | } 202 | } 203 | } 204 | } 205 | } 206 | .padding() 207 | } else { 208 | Text("Not loaded.") 209 | } 210 | } 211 | .animation(.default) 212 | .transition(AnyTransition.opacity.animation(.easeInOut(duration: 0.2))) 213 | .onAppear { 214 | self.handleAppear() 215 | } 216 | } 217 | } 218 | 219 | struct SpecialComicViewer: View { 220 | var id: Int 221 | 222 | var body: some View { 223 | if id == 1190 { 224 | TimeComicViewer() 225 | } 226 | } 227 | } 228 | 229 | struct SpecialComicViewer_Previews: PreviewProvider { 230 | static var previews: some View { 231 | SpecialComicViewer(id: 1190) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /XKCDY/Views/TintColorPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorPicker.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/28/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | let COLORS_TO_PICK_FROM: [ColorOption] = [ 12 | ColorOption(color: UIColor.systemBlue, description: "Default"), 13 | ColorOption(color: UIColor(red: 0.18, green: 0.80, blue: 0.80, alpha: 1.00), description: "Surf Water"), 14 | ColorOption(color: UIColor(red: 0.15, green: 0.67, blue: 0.98, alpha: 1.00), description: "Pool Water"), 15 | ColorOption(color: UIColor(red: 0.48, green: 0.30, blue: 0.91, alpha: 1.00), description: "Night Sky"), 16 | ColorOption(color: UIColor(red: 0.80, green: 0.30, blue: 0.87, alpha: 1.00), description: "Clematis"), 17 | ColorOption(color: UIColor(red: 0.95, green: 0.39, blue: 0.66, alpha: 1.00), description: "Panther"), 18 | ColorOption(color: UIColor(red: 1.00, green: 0.26, blue: 0.41, alpha: 1.00), description: "Not Quite Red"), 19 | ColorOption(color: UIColor(red: 0.99, green: 0.69, blue: 0.23, alpha: 1.00), description: "Sunset"), 20 | ColorOption(color: UIColor(red: 0.98, green: 0.89, blue: 0.22, alpha: 1.00), description: "Not Swimming Water"), 21 | ColorOption(color: UIColor(red: 0.54, green: 0.87, blue: 0.16, alpha: 1.00), description: "Lime Zest"), 22 | ColorOption(color: UIColor(red: 0.17, green: 0.85, blue: 0.42, alpha: 1.00), description: "Hellebores") 23 | ] 24 | 25 | struct TintColorPicker: View { 26 | @ObservedObject private var userSettings = UserSettings() 27 | 28 | var body: some View { 29 | VStack { 30 | List { 31 | ForEach(COLORS_TO_PICK_FROM, id: \.self) { option in 32 | ColorPickerRow(option: option, selected: option.color == self.userSettings.tintColor) 33 | .onTapGesture { 34 | self.userSettings.tintColor = option.color 35 | } 36 | } 37 | } 38 | .padding(.top) 39 | } 40 | .navigationBarTitle(Text("Pick a color"), displayMode: .inline) 41 | } 42 | } 43 | 44 | struct ColorPicker_Previews: PreviewProvider { 45 | static var previews: some View { 46 | TintColorPicker() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /XKCDY/Views/UncontrolledWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UncontrolledWebView.swift 3 | // XKCDY 4 | // 5 | // Created by Max Isom on 7/8/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import WebView 11 | 12 | struct ControlledWebView: View { 13 | var onDismiss: () -> Void 14 | var webViewStore: WebViewStore 15 | 16 | var body: some View { 17 | NavigationView { 18 | WebView(webView: webViewStore.webView) 19 | .navigationBarTitle(Text(verbatim: webViewStore.webView.title ?? ""), displayMode: .inline) 20 | .navigationBarItems(leading: HStack { 21 | Button(action: goBack) { 22 | Image(systemName: "chevron.left") 23 | .imageScale(.large) 24 | .aspectRatio(contentMode: .fit) 25 | .frame(width: 32, height: 32) 26 | }.disabled(!webViewStore.webView.canGoBack) 27 | 28 | Button(action: goForward) { 29 | Image(systemName: "chevron.right") 30 | .imageScale(.large) 31 | .aspectRatio(contentMode: .fit) 32 | .frame(width: 32, height: 32) 33 | }.disabled(!webViewStore.webView.canGoForward) 34 | }, trailing: HStack { 35 | Button(action: onDismiss) { 36 | Text("Done") 37 | } 38 | }) 39 | .edgesIgnoringSafeArea(.bottom) 40 | } 41 | .navigationViewStyle(StackNavigationViewStyle()) 42 | // .onAppear { 43 | // self.webViewStore.webView.load(URLRequest(url: self.url)) 44 | // } 45 | } 46 | 47 | func goBack() { 48 | webViewStore.webView.goBack() 49 | } 50 | 51 | func goForward() { 52 | webViewStore.webView.goForward() 53 | } 54 | } 55 | 56 | struct UncontrolledWebView_Previews: PreviewProvider { 57 | static var previews: some View { 58 | ControlledWebView(url: URL(string: "https://apple.com")!, onDismiss: {}, webViewStore: WebViewStore()) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /XKCDY/Views/ZoomableImage.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import SwiftUI 3 | import Kingfisher 4 | 5 | class UIShortTapGestureRecognizer: UITapGestureRecognizer { 6 | let tapMaxDelay: Double = 0.3 // anything below 0.3 may cause doubleTap to be inaccessible by many users 7 | 8 | override func touchesBegan(_ touches: Set, with event: UIEvent) { 9 | super.touchesBegan(touches, with: event) 10 | 11 | DispatchQueue.main.asyncAfter(deadline: .now() + tapMaxDelay) { [weak self] in 12 | if self?.state != UIGestureRecognizer.State.recognized { 13 | self?.state = UIGestureRecognizer.State.failed 14 | } 15 | } 16 | } 17 | } 18 | 19 | class ZoomableImage: UIScrollView, UIScrollViewDelegate, UIGestureRecognizerDelegate { 20 | var imageView: UIImageView! 21 | var singleTapRecognizer: UITapGestureRecognizer! 22 | var doubleTapRecognizer: UITapGestureRecognizer! 23 | var longPressRecognizer: UILongPressGestureRecognizer! 24 | var onSingleTap: () -> Void = {} 25 | var onLongPress: () -> Void = {} 26 | var onScale: (CGFloat) -> Void = {_ in} 27 | var dimensions: CGSize = .zero 28 | var orientation = UIDevice.current.orientation 29 | 30 | convenience init(frame f: CGRect, image: UIImageView, onSingleTap: @escaping () -> Void, onLongPress: @escaping () -> Void, onScale: @escaping (CGFloat) -> Void, dimensions: CGSize) { 31 | self.init(frame: f) 32 | 33 | self.onSingleTap = onSingleTap 34 | self.onLongPress = onLongPress 35 | self.onScale = onScale 36 | self.dimensions = dimensions 37 | 38 | imageView = image 39 | 40 | imageView.frame = f 41 | imageView.contentMode = .scaleAspectFit 42 | addSubview(imageView) 43 | 44 | setupScrollView() 45 | setupGestureRecognizer() 46 | 47 | updateInset() 48 | } 49 | 50 | func updateInset() { 51 | if let window = UIApplication.shared.windows.filter({$0.isKeyWindow}).first { 52 | contentInset = UIEdgeInsets(top: window.safeAreaInsets.top, left: window.safeAreaInsets.left, bottom: window.safeAreaInsets.bottom, right: window.safeAreaInsets.right) 53 | } 54 | } 55 | 56 | func setupScrollView() { 57 | let imageSize = self.dimensions 58 | 59 | let initialDisplayedWidth = bounds.size.height * (imageSize.width / imageSize.height) 60 | let initialDisplayedHeight = bounds.size.width * (imageSize.height / imageSize.width) 61 | 62 | delegate = self 63 | minimumZoomScale = 1.0 64 | maximumZoomScale = max(3.0, bounds.size.width / initialDisplayedWidth, bounds.size.height / initialDisplayedHeight, (imageSize.height * imageSize.width) / 5000000) 65 | showsVerticalScrollIndicator = false 66 | showsHorizontalScrollIndicator = false 67 | } 68 | 69 | private func setupGestureRecognizer() { 70 | doubleTapRecognizer = UIShortTapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) 71 | doubleTapRecognizer.numberOfTapsRequired = 2 72 | addGestureRecognizer(doubleTapRecognizer) 73 | 74 | singleTapRecognizer = UIShortTapGestureRecognizer(target: self, action: #selector(handleSingleTap)) 75 | singleTapRecognizer.numberOfTapsRequired = 1 76 | addGestureRecognizer(singleTapRecognizer) 77 | 78 | singleTapRecognizer.require(toFail: doubleTapRecognizer) 79 | 80 | longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress)) 81 | addGestureRecognizer(longPressRecognizer) 82 | } 83 | 84 | @objc private func handleSingleTap() { 85 | onSingleTap() 86 | } 87 | 88 | @objc private func handleDoubleTap() { 89 | if zoomScale == 1 { 90 | zoom(to: zoomRectForScale(maximumZoomScale, center: doubleTapRecognizer.location(in: doubleTapRecognizer.view)), animated: true) 91 | } else { 92 | setZoomScale(minimumZoomScale, animated: true) 93 | } 94 | } 95 | 96 | func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { 97 | self.onScale(scale) 98 | if scale == 1 { 99 | isScrollEnabled = false 100 | } else { 101 | isScrollEnabled = true 102 | } 103 | } 104 | 105 | @objc private func handleLongPress() { 106 | onLongPress() 107 | } 108 | 109 | private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect { 110 | var zoomRect = CGRect.zero 111 | zoomRect.size.height = imageView.frame.size.height / scale 112 | zoomRect.size.width = imageView.frame.size.width / scale 113 | let newCenter = convert(center, from: imageView) 114 | zoomRect.origin.x = newCenter.x - (zoomRect.size.width / 2.0) 115 | zoomRect.origin.y = newCenter.y - (zoomRect.size.height / 2.0) 116 | return zoomRect 117 | } 118 | 119 | internal func viewForZooming(in scrollView: UIScrollView) -> UIView? { 120 | return imageView 121 | } 122 | } 123 | 124 | struct ZoomableImageView: UIViewRepresentable { 125 | var imageURL: URL 126 | var onSingleTap: () -> Void 127 | var onLongPress: () -> Void 128 | var onScale: (CGFloat) -> Void 129 | var dimensions: CGSize 130 | var frame: CGRect = .infinite 131 | 132 | func makeUIView(context: Context) -> ZoomableImage { 133 | let image = UIImageView() 134 | image.kf.setImage(with: imageURL) 135 | 136 | return ZoomableImage(frame: frame, image: image, onSingleTap: onSingleTap, onLongPress: onLongPress, onScale: onScale, dimensions: dimensions) 137 | } 138 | 139 | func updateUIView(_ uiView: ZoomableImage, context: Context) { 140 | // Only should update on orientation change 141 | if uiView.orientation != UIDevice.current.orientation { 142 | uiView.updateInset() 143 | uiView.frame = frame 144 | uiView.setZoomScale(1, animated: false) 145 | uiView.isScrollEnabled = false 146 | uiView.imageView.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height) 147 | uiView.contentSize = frame.size 148 | uiView.setupScrollView() 149 | 150 | uiView.orientation = UIDevice.current.orientation 151 | 152 | DispatchQueue.main.async { 153 | self.onScale(1) 154 | } 155 | } 156 | } 157 | } 158 | 159 | extension ZoomableImageView { 160 | func frame(from f: CGRect) -> Self { 161 | var copy = self 162 | copy.frame = f 163 | 164 | return copy 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /XKCDY/XKCDY.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.siri 8 | 9 | com.apple.security.application-groups 10 | 11 | group.com.maxisom.XKCDY 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /XKCDY/XKCDY.xcdatamodeld/DCKX.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /XKCDYIntents/GetComicIntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetComicIntentHandler.swift 3 | // XKCDYIntents 4 | // 5 | // Created by Max Isom on 7/9/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | class GetComicIntentHandler: NSObject, GetComicIntentHandling { 13 | func handle(intent: GetComicIntent, completion: @escaping (GetComicIntentResponse) -> Void) { 14 | // Update store 15 | let store = Store(isLive: false) 16 | 17 | store.partialRefetchComics { _ in 18 | let comicId = intent.comicId!.intValue 19 | 20 | let realm = try! Realm() 21 | 22 | let savedComic = realm.object(ofType: Comic.self, forPrimaryKey: comicId) 23 | 24 | let response = GetComicIntentResponse(code: .success, userActivity: nil) 25 | 26 | response.comic = savedComic?.toIntentResponse() 27 | 28 | completion(response) 29 | } 30 | } 31 | 32 | func resolveComicId(for intent: GetComicIntent, with completion: @escaping (GetComicComicIdResolutionResult) -> Void) { 33 | var result: GetComicComicIdResolutionResult = .unsupported() 34 | 35 | defer {completion(result) } 36 | 37 | if let id = intent.comicId?.intValue { 38 | result = GetComicComicIdResolutionResult.success(with: id) 39 | } 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /XKCDYIntents/GetLatestComicIntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // GetLatestComicIntentHandler.swift 3 | // XKCDYIntents 4 | // 5 | // Created by Max Isom on 7/10/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import RealmSwift 11 | 12 | class GetLatestComicIntentHandler: NSObject, GetLatestComicIntentHandling { 13 | func handle(intent: GetLatestComicIntent, completion: @escaping (GetLatestComicIntentResponse) -> Void) { 14 | // Update store 15 | let store = Store(isLive: false) 16 | 17 | store.partialRefetchComics { _ in 18 | let realm = try! Realm() 19 | 20 | let savedComic = realm.objects(Comic.self).sorted(byKeyPath: "id", ascending: false).first 21 | 22 | let response = GetLatestComicIntentResponse(code: .success, userActivity: nil) 23 | 24 | response.comic = savedComic?.toIntentResponse() 25 | 26 | completion(response) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /XKCDYIntents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | XKCDYIntents 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 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | NSExtension 24 | 25 | NSExtensionAttributes 26 | 27 | IntentsRestrictedWhileLocked 28 | 29 | IntentsRestrictedWhileProtectedDataUnavailable 30 | 31 | IntentsSupported 32 | 33 | GetComicIntent 34 | GetLatestComicIntent 35 | 36 | 37 | NSExtensionPointIdentifier 38 | com.apple.intents-service 39 | NSExtensionPrincipalClass 40 | $(PRODUCT_MODULE_NAME).IntentHandler 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /XKCDYIntents/IntentHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IntentHandler.swift 3 | // XKCDYIntents 4 | // 5 | // Created by Max Isom on 7/9/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import Intents 10 | import RealmSwift 11 | 12 | class IntentHandler: INExtension { 13 | func configureRealm() { 14 | // Set Realm file location 15 | let realmFileURL = FileManager.default 16 | .containerURL(forSecurityApplicationGroupIdentifier: "group.com.maxisom.XKCDY")! 17 | .appendingPathComponent("default.realm") 18 | Realm.Configuration.defaultConfiguration.fileURL = realmFileURL 19 | } 20 | 21 | override func handler(for intent: INIntent) -> Any { 22 | configureRealm() 23 | 24 | if intent is GetComicIntent { 25 | return GetComicIntentHandler() 26 | } else if intent is GetLatestComicIntent { 27 | return GetLatestComicIntentHandler() 28 | } 29 | 30 | return GetComicIntentHandler() 31 | } 32 | 33 | } 34 | 35 | extension Comic { 36 | func toIntentResponse() -> ComicSiri { 37 | let comic = ComicSiri(identifier: String(id), display: title) 38 | 39 | comic.publishedAt = Calendar.current.dateComponents([.year, .month, .day], from: publishedAt) 40 | comic.title = safeTitle 41 | comic.transcript = transcript 42 | comic.alt = alt 43 | comic.sourceUrl = sourceURL 44 | 45 | if let imgs = imgs { 46 | if let x2 = imgs.x2 { 47 | comic.imageUrl = x2.url 48 | } else if let x1 = imgs.x1 { 49 | comic.imageUrl = x1.url 50 | } 51 | } 52 | 53 | return comic 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /XKCDYIntents/XKCDYIntentsDebug.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.maxisom.XKCDY 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /XKCDYIntents/XKCDYIntentsRelease.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.application-groups 6 | 7 | group.com.maxisom.XKCDY 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /XKCDYTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /XKCDYTests/XKCDYTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DCKXTests.swift 3 | // DCKXTests 4 | // 5 | // Created by Max Isom on 4/13/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import DCKX 11 | 12 | class DCKXTests: XCTestCase { 13 | 14 | override func setUpWithError() throws { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDownWithError() throws { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() throws { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() throws { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /XKCDYUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /XKCDYUITests/XKCDYUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DCKXUITests.swift 3 | // DCKXUITests 4 | // 5 | // Created by Max Isom on 4/13/20. 6 | // Copyright © 2020 Max Isom. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class DCKXUITests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 20 | } 21 | 22 | override func tearDownWithError() throws { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testExample() throws { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use recording to get started writing UI tests. 32 | // Use XCTAssert and related functions to verify your tests produce the correct results. 33 | } 34 | 35 | func testLaunchPerformance() throws { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.maxisom.XKCDY") # The bundle identifier of your app 2 | apple_id("codetheweb@icloud.com") # Your Apple email address 3 | 4 | itc_team_id("121736716") # App Store Connect Team ID 5 | team_id("RSMU2QR9ZN") # Developer Portal Team ID 6 | 7 | # For more information about the Appfile, see: 8 | # https://docs.fastlane.tools/advanced/#appfile 9 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:ios) 17 | 18 | platform :ios do 19 | desc "Push a new beta build to TestFlight" 20 | lane :beta do 21 | keychain_name = ENV["MATCH_KEYCHAIN_NAME"] 22 | keychain_password = ENV["MATCH_KEYCHAIN_PASSWORD"] 23 | 24 | # Create a temporary keychain to 25 | # store the certificates. 26 | create_keychain( 27 | name: keychain_name, 28 | password: keychain_password, 29 | default_keychain: true, 30 | unlock: true, 31 | timeout: 3600, 32 | add_to_search_list: true 33 | ) 34 | 35 | match( 36 | type: "appstore", 37 | app_identifier: ["com.maxisom.XKCDY", "com.maxisom.XKCDY.XKCDYIntents", "com.maxisom.XKCDY.Widgets"], 38 | readonly: true, 39 | keychain_name: keychain_name, 40 | keychain_password: keychain_password 41 | ) 42 | 43 | increment_build_number({ 44 | build_number: latest_testflight_build_number + 1 45 | }) 46 | 47 | build_app(project: "XKCDY.xcodeproj", scheme: "XKCDY") 48 | 49 | changelog_from_git_commits 50 | 51 | upload_to_testflight 52 | 53 | delete_keychain( 54 | name: keychain_name 55 | ) 56 | end 57 | 58 | desc "Push new release build to the App Store" 59 | lane :release do 60 | keychain_name = ENV["MATCH_KEYCHAIN_NAME"] 61 | keychain_password = ENV["MATCH_KEYCHAIN_PASSWORD"] 62 | 63 | # Create a temporary keychain to 64 | # store the certificates. 65 | create_keychain( 66 | name: keychain_name, 67 | password: keychain_password, 68 | default_keychain: true, 69 | unlock: true, 70 | timeout: 3600, 71 | add_to_search_list: true 72 | ) 73 | 74 | match( 75 | type: "appstore", 76 | app_identifier: ["com.maxisom.XKCDY", "com.maxisom.XKCDY.XKCDYIntents", "com.maxisom.XKCDY.Widgets"], 77 | readonly: true, 78 | keychain_name: keychain_name, 79 | keychain_password: keychain_password 80 | ) 81 | 82 | increment_version_number( 83 | version_number: last_git_tag.sub("v", "") 84 | ) 85 | 86 | build_app(project: "XKCDY.xcodeproj", scheme: "XKCDY") 87 | 88 | upload_to_app_store(force: true) 89 | 90 | delete_keychain( 91 | name: keychain_name 92 | ) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /fastlane/Matchfile: -------------------------------------------------------------------------------- 1 | git_url("https://github.com/XKCDY/keys.git") 2 | 3 | storage_mode("git") 4 | 5 | type("development") # The default type, can be: appstore, adhoc, enterprise or development 6 | 7 | # app_identifier(["tools.fastlane.app", "tools.fastlane.app2"]) 8 | # username("user@fastlane.tools") # Your Apple Developer Portal username 9 | 10 | # For all available options run `fastlane match --help` 11 | # Remove the # in the beginning of the line to enable the other options 12 | 13 | # The docs are available on https://docs.fastlane.tools/actions/match 14 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios beta 19 | 20 | ```sh 21 | [bundle exec] fastlane ios beta 22 | ``` 23 | 24 | Push a new beta build to TestFlight 25 | 26 | ### ios release 27 | 28 | ```sh 29 | [bundle exec] fastlane ios release 30 | ``` 31 | 32 | Push new release build to the App Store 33 | 34 | ---- 35 | 36 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 37 | 38 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 39 | 40 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 41 | --------------------------------------------------------------------------------