├── .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 |
--------------------------------------------------------------------------------