├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── feature_request.yml
└── workflows
│ ├── build_and_test.yml
│ ├── build_example_project.yml
│ └── deploy_documentation.yml
├── .gitignore
├── Assets
├── example-app.png
├── example.png
├── icloud-key-value-storage.png
└── logo.png
├── Example
├── Example.xcodeproj
│ ├── project.pbxproj
│ ├── project.xcworkspace
│ │ ├── contents.xcworkspacedata
│ │ └── xcshareddata
│ │ │ └── IDEWorkspaceChecks.plist
│ └── xcshareddata
│ │ └── xcschemes
│ │ └── Example.xcscheme
└── Example
│ ├── App.swift
│ ├── Assets.xcassets
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── AppIcon-1024px.png
│ │ ├── Contents.json
│ │ ├── macOS-AppIcon-1024px.png
│ │ ├── macOS-AppIcon-128px-128pt@1x.png
│ │ ├── macOS-AppIcon-16px-16pt@1x.png
│ │ ├── macOS-AppIcon-256px-128pt@2x.png
│ │ ├── macOS-AppIcon-256px-256pt@1x.png
│ │ ├── macOS-AppIcon-32px-16pt@2x.png
│ │ ├── macOS-AppIcon-32px-32pt@1x.png
│ │ ├── macOS-AppIcon-512px-256pt@2x.png
│ │ ├── macOS-AppIcon-512px.png
│ │ └── macOS-AppIcon-64px-32pt@2x.png
│ └── Contents.json
│ ├── ContentView.swift
│ └── ExamplesView.swift
├── LICENSE
├── Package.swift
├── README.md
├── Sources
├── Collection
│ ├── WhatsNewCollection.swift
│ ├── WhatsNewCollectionBuilder.swift
│ └── WhatsNewCollectionProvider.swift
├── Environment
│ ├── WhatsNewEnvironment+Key.swift
│ └── WhatsNewEnvironment.swift
├── Extensions
│ ├── ScrollView+alwaysBounceVertical.swift
│ ├── Text+WhatsNewText.swift
│ ├── UIVisualEffectView+Representable.swift
│ ├── View+WhatsNewSheet.swift
│ └── WhatsNew+Version+Key.swift
├── Models
│ ├── WhatsNew+Feature+Image.swift
│ ├── WhatsNew+Feature.swift
│ ├── WhatsNew+HapticFeedback.swift
│ ├── WhatsNew+Layout.swift
│ ├── WhatsNew+PrimaryAction.swift
│ ├── WhatsNew+SecondaryAction+Action.swift
│ ├── WhatsNew+SecondaryAction.swift
│ ├── WhatsNew+Text.swift
│ ├── WhatsNew+Title.swift
│ ├── WhatsNew+Version.swift
│ └── WhatsNew.swift
├── Resources
│ └── PrivacyInfo.xcprivacy
├── Store
│ ├── InMemoryWhatsNewVersionStore.swift
│ ├── NSUbiquitousKeyValueWhatsNewVersionStore.swift
│ ├── UserDefaultsWhatsNewVersionStore.swift
│ └── WhatsNewVersionStore.swift
├── View
│ ├── WhatsNewView+FeaturesPadding.swift
│ ├── WhatsNewView+FooterPadding.swift
│ ├── WhatsNewView+PrimaryButtonStyle.swift
│ └── WhatsNewView.swift
└── ViewController
│ └── WhatsNewViewController.swift
└── Tests
├── WhatsNewEnvironmentTests.swift
├── WhatsNewKitTestCase.swift
├── WhatsNewVersionStoreTests.swift
├── WhatsNewVersionTests.swift
└── WhatsNewViewControllerTests.swift
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: SvenTiigi
2 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report.
3 | labels: ["bug"]
4 | assignees:
5 | - SvenTiigi
6 | body:
7 | - type: textarea
8 | id: bug-description
9 | attributes:
10 | label: What happened?
11 | description: Please describe the bug.
12 | placeholder: Description of the bug.
13 | validations:
14 | required: true
15 | - type: textarea
16 | id: steps-to-reproduce
17 | attributes:
18 | label: What are the steps to reproduce?
19 | description: Please describe the steps to reproduce the bug.
20 | placeholder: |
21 | Step 1: ...
22 | Step 2: ...
23 | Step 3: ...
24 | validations:
25 | required: true
26 | - type: textarea
27 | id: expected-behavior
28 | attributes:
29 | label: What is the expected behavior?
30 | description: Please describe the behavior you expect of WhatsNewKit.
31 | placeholder: I expect that WhatsNewKit would...
32 | validations:
33 | required: true
34 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Suggest an idea for this project
3 | labels: ["feature"]
4 | assignees:
5 | - SvenTiigi
6 | body:
7 | - type: textarea
8 | id: problem
9 | attributes:
10 | label: Is your feature request related to a problem?
11 | description: A clear and concise description of what the problem is.
12 | placeholder: Yes, the problem is that...
13 | validations:
14 | required: true
15 | - type: textarea
16 | id: solution
17 | attributes:
18 | label: What solution would you like?
19 | description: A clear and concise description of what you want to happen.
20 | placeholder: I would like that...
21 | validations:
22 | required: true
23 | - type: textarea
24 | id: alternatives
25 | attributes:
26 | label: What alternatives have you considered?
27 | description: A clear and concise description of any alternative solutions or features you've considered.
28 | placeholder: I have considered to...
29 | validations:
30 | required: true
31 | - type: textarea
32 | id: context
33 | attributes:
34 | label: Any additional context?
35 | description: Add any other context or screenshots about the feature request here.
36 |
--------------------------------------------------------------------------------
/.github/workflows/build_and_test.yml:
--------------------------------------------------------------------------------
1 | name: Build and Test
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | paths:
7 | - "Sources/**"
8 | - "Tests/**"
9 | - "!Sources/Documentation.docc/**"
10 | pull_request:
11 | paths:
12 | - "Sources/**"
13 | - "Tests/**"
14 | - "!Sources/Documentation.docc/**"
15 |
16 | jobs:
17 | iOS:
18 | name: Build and test on iOS
19 | runs-on: macos-14
20 | steps:
21 | - uses: actions/checkout@v3
22 | - name: Build
23 | run: xcodebuild build-for-testing -scheme WhatsNewKit -destination 'platform=iOS Simulator,name=iPhone 14'
24 | - name: Test
25 | run: xcodebuild test-without-building -scheme WhatsNewKit -destination 'platform=iOS Simulator,name=iPhone 14'
26 | macOS:
27 | name: Build and test on macOS
28 | runs-on: macos-14
29 | steps:
30 | - uses: actions/checkout@v3
31 | - name: Build
32 | run: swift build -v
33 | - name: Test
34 | run: swift test -v
35 |
--------------------------------------------------------------------------------
/.github/workflows/build_example_project.yml:
--------------------------------------------------------------------------------
1 | name: Build Example Project
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | paths:
7 | - "Example/**"
8 | - "Sources/**"
9 | pull_request:
10 | paths:
11 | - "Example/**"
12 | - "Sources/**"
13 |
14 | jobs:
15 | build:
16 | name: Build iOS example project
17 | runs-on: macos-14
18 | steps:
19 | - name: Checkout
20 | uses: actions/checkout@v2
21 | - name: Build
22 | run: xcodebuild build -project Example/Example.xcodeproj -scheme Example -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14'
23 |
--------------------------------------------------------------------------------
/.github/workflows/deploy_documentation.yml:
--------------------------------------------------------------------------------
1 | name: Deploy Documentation
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - main
8 |
9 | permissions:
10 | contents: read
11 | pages: write
12 | id-token: write
13 |
14 | concurrency:
15 | group: "pages"
16 | cancel-in-progress: true
17 |
18 | jobs:
19 | build:
20 | runs-on: macos-14
21 | steps:
22 | - name: Checkout
23 | uses: actions/checkout@v3
24 | - name: Setup Pages
25 | uses: actions/configure-pages@v2
26 | - name: Build Documentation
27 | run: |
28 | xcodebuild docbuild\
29 | -scheme WhatsNewKit\
30 | -destination 'generic/platform=iOS'\
31 | -derivedDataPath ../DerivedData
32 | - name: Process Archive
33 | run: |
34 | mkdir _site
35 | $(xcrun --find docc) process-archive \
36 | transform-for-static-hosting ../DerivedData/Build/Products/Debug-iphoneos/WhatsNewKit.doccarchive \
37 | --output-path _site \
38 | --hosting-base-path WhatsNewKit
39 | - name: Create Custom index.html
40 | run: |
41 | rm _site/index.html
42 | cat > _site/index.html <<- EOM
43 |
44 |
45 |
46 |
47 |
48 |
49 | Please follow this link.
50 |
51 |
52 | EOM
53 | - name: Upload Artifact
54 | uses: actions/upload-pages-artifact@v1
55 |
56 | deploy:
57 | environment:
58 | name: github-pages
59 | url: ${{ steps.deployment.outputs.page_url }}
60 | runs-on: ubuntu-latest
61 | needs: build
62 | steps:
63 | - name: Deploy to GitHub Pages
64 | id: deployment
65 | uses: actions/deploy-pages@v1
66 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/
--------------------------------------------------------------------------------
/Assets/example-app.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Assets/example-app.png
--------------------------------------------------------------------------------
/Assets/example.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Assets/example.png
--------------------------------------------------------------------------------
/Assets/icloud-key-value-storage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Assets/icloud-key-value-storage.png
--------------------------------------------------------------------------------
/Assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Assets/logo.png
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 55;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 3D5FCF782767887F00D3211F /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5FCF772767887F00D3211F /* App.swift */; };
11 | 3D5FCF7A2767887F00D3211F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5FCF792767887F00D3211F /* ContentView.swift */; };
12 | 3D5FCF88276788FE00D3211F /* WhatsNewKit in Frameworks */ = {isa = PBXBuildFile; productRef = 3D5FCF87276788FE00D3211F /* WhatsNewKit */; };
13 | 3D999705276E144B00438FB6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3D999703276E144800438FB6 /* Assets.xcassets */; };
14 | 3DA482062769E7F900F526B0 /* ExamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA482052769E7F900F526B0 /* ExamplesView.swift */; };
15 | /* End PBXBuildFile section */
16 |
17 | /* Begin PBXFileReference section */
18 | 3D5FCF742767887F00D3211F /* WhatsNewKit-Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "WhatsNewKit-Example.app"; sourceTree = BUILT_PRODUCTS_DIR; };
19 | 3D5FCF772767887F00D3211F /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; };
20 | 3D5FCF792767887F00D3211F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
21 | 3D5FCF85276788F900D3211F /* WhatsNewKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WhatsNewKit; path = ..; sourceTree = ""; };
22 | 3D999703276E144800438FB6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
23 | 3DA482052769E7F900F526B0 /* ExamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExamplesView.swift; sourceTree = ""; };
24 | /* End PBXFileReference section */
25 |
26 | /* Begin PBXFrameworksBuildPhase section */
27 | 3D5FCF712767887E00D3211F /* Frameworks */ = {
28 | isa = PBXFrameworksBuildPhase;
29 | buildActionMask = 2147483647;
30 | files = (
31 | 3D5FCF88276788FE00D3211F /* WhatsNewKit in Frameworks */,
32 | );
33 | runOnlyForDeploymentPostprocessing = 0;
34 | };
35 | /* End PBXFrameworksBuildPhase section */
36 |
37 | /* Begin PBXGroup section */
38 | 3D5FCF6B2767887E00D3211F = {
39 | isa = PBXGroup;
40 | children = (
41 | 3D5FCF762767887F00D3211F /* Example */,
42 | 3D5FCF752767887F00D3211F /* Products */,
43 | 3D5FCF86276788FE00D3211F /* Frameworks */,
44 | 3D5FCF85276788F900D3211F /* WhatsNewKit */,
45 | );
46 | sourceTree = "";
47 | };
48 | 3D5FCF752767887F00D3211F /* Products */ = {
49 | isa = PBXGroup;
50 | children = (
51 | 3D5FCF742767887F00D3211F /* WhatsNewKit-Example.app */,
52 | );
53 | name = Products;
54 | sourceTree = "";
55 | };
56 | 3D5FCF762767887F00D3211F /* Example */ = {
57 | isa = PBXGroup;
58 | children = (
59 | 3D5FCF772767887F00D3211F /* App.swift */,
60 | 3D5FCF792767887F00D3211F /* ContentView.swift */,
61 | 3DA482052769E7F900F526B0 /* ExamplesView.swift */,
62 | 3D999703276E144800438FB6 /* Assets.xcassets */,
63 | );
64 | path = Example;
65 | sourceTree = "";
66 | };
67 | 3D5FCF86276788FE00D3211F /* Frameworks */ = {
68 | isa = PBXGroup;
69 | children = (
70 | );
71 | name = Frameworks;
72 | sourceTree = "";
73 | };
74 | /* End PBXGroup section */
75 |
76 | /* Begin PBXNativeTarget section */
77 | 3D5FCF732767887E00D3211F /* Example */ = {
78 | isa = PBXNativeTarget;
79 | buildConfigurationList = 3D5FCF822767888000D3211F /* Build configuration list for PBXNativeTarget "Example" */;
80 | buildPhases = (
81 | 3D5FCF702767887E00D3211F /* Sources */,
82 | 3D5FCF712767887E00D3211F /* Frameworks */,
83 | 3D5FCF722767887E00D3211F /* Resources */,
84 | );
85 | buildRules = (
86 | );
87 | dependencies = (
88 | );
89 | name = Example;
90 | packageProductDependencies = (
91 | 3D5FCF87276788FE00D3211F /* WhatsNewKit */,
92 | );
93 | productName = Example;
94 | productReference = 3D5FCF742767887F00D3211F /* WhatsNewKit-Example.app */;
95 | productType = "com.apple.product-type.application";
96 | };
97 | /* End PBXNativeTarget section */
98 |
99 | /* Begin PBXProject section */
100 | 3D5FCF6C2767887E00D3211F /* Project object */ = {
101 | isa = PBXProject;
102 | attributes = {
103 | BuildIndependentTargetsInParallel = 1;
104 | LastSwiftUpdateCheck = 1320;
105 | LastUpgradeCheck = 1520;
106 | TargetAttributes = {
107 | 3D5FCF732767887E00D3211F = {
108 | CreatedOnToolsVersion = 13.1;
109 | };
110 | };
111 | };
112 | buildConfigurationList = 3D5FCF6F2767887E00D3211F /* Build configuration list for PBXProject "Example" */;
113 | compatibilityVersion = "Xcode 13.0";
114 | developmentRegion = en;
115 | hasScannedForEncodings = 0;
116 | knownRegions = (
117 | en,
118 | Base,
119 | );
120 | mainGroup = 3D5FCF6B2767887E00D3211F;
121 | productRefGroup = 3D5FCF752767887F00D3211F /* Products */;
122 | projectDirPath = "";
123 | projectRoot = "";
124 | targets = (
125 | 3D5FCF732767887E00D3211F /* Example */,
126 | );
127 | };
128 | /* End PBXProject section */
129 |
130 | /* Begin PBXResourcesBuildPhase section */
131 | 3D5FCF722767887E00D3211F /* Resources */ = {
132 | isa = PBXResourcesBuildPhase;
133 | buildActionMask = 2147483647;
134 | files = (
135 | 3D999705276E144B00438FB6 /* Assets.xcassets in Resources */,
136 | );
137 | runOnlyForDeploymentPostprocessing = 0;
138 | };
139 | /* End PBXResourcesBuildPhase section */
140 |
141 | /* Begin PBXSourcesBuildPhase section */
142 | 3D5FCF702767887E00D3211F /* Sources */ = {
143 | isa = PBXSourcesBuildPhase;
144 | buildActionMask = 2147483647;
145 | files = (
146 | 3D5FCF7A2767887F00D3211F /* ContentView.swift in Sources */,
147 | 3DA482062769E7F900F526B0 /* ExamplesView.swift in Sources */,
148 | 3D5FCF782767887F00D3211F /* App.swift in Sources */,
149 | );
150 | runOnlyForDeploymentPostprocessing = 0;
151 | };
152 | /* End PBXSourcesBuildPhase section */
153 |
154 | /* Begin XCBuildConfiguration section */
155 | 3D5FCF802767888000D3211F /* Debug */ = {
156 | isa = XCBuildConfiguration;
157 | buildSettings = {
158 | ALWAYS_SEARCH_USER_PATHS = NO;
159 | CLANG_ANALYZER_NONNULL = YES;
160 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
161 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
162 | CLANG_CXX_LIBRARY = "libc++";
163 | CLANG_ENABLE_MODULES = YES;
164 | CLANG_ENABLE_OBJC_ARC = YES;
165 | CLANG_ENABLE_OBJC_WEAK = YES;
166 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
167 | CLANG_WARN_BOOL_CONVERSION = YES;
168 | CLANG_WARN_COMMA = YES;
169 | CLANG_WARN_CONSTANT_CONVERSION = YES;
170 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
171 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
172 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
173 | CLANG_WARN_EMPTY_BODY = YES;
174 | CLANG_WARN_ENUM_CONVERSION = YES;
175 | CLANG_WARN_INFINITE_RECURSION = YES;
176 | CLANG_WARN_INT_CONVERSION = YES;
177 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
178 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
179 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
180 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
181 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
182 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
183 | CLANG_WARN_STRICT_PROTOTYPES = YES;
184 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
185 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
186 | CLANG_WARN_UNREACHABLE_CODE = YES;
187 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
188 | COPY_PHASE_STRIP = NO;
189 | DEBUG_INFORMATION_FORMAT = dwarf;
190 | ENABLE_STRICT_OBJC_MSGSEND = YES;
191 | ENABLE_TESTABILITY = YES;
192 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
193 | GCC_C_LANGUAGE_STANDARD = gnu11;
194 | GCC_DYNAMIC_NO_PIC = NO;
195 | GCC_NO_COMMON_BLOCKS = YES;
196 | GCC_OPTIMIZATION_LEVEL = 0;
197 | GCC_PREPROCESSOR_DEFINITIONS = (
198 | "DEBUG=1",
199 | "$(inherited)",
200 | );
201 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
202 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
203 | GCC_WARN_UNDECLARED_SELECTOR = YES;
204 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
205 | GCC_WARN_UNUSED_FUNCTION = YES;
206 | GCC_WARN_UNUSED_VARIABLE = YES;
207 | IPHONEOS_DEPLOYMENT_TARGET = 17.0;
208 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
209 | MTL_FAST_MATH = YES;
210 | ONLY_ACTIVE_ARCH = YES;
211 | SDKROOT = iphoneos;
212 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
213 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
214 | };
215 | name = Debug;
216 | };
217 | 3D5FCF812767888000D3211F /* Release */ = {
218 | isa = XCBuildConfiguration;
219 | buildSettings = {
220 | ALWAYS_SEARCH_USER_PATHS = NO;
221 | CLANG_ANALYZER_NONNULL = YES;
222 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
223 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
224 | CLANG_CXX_LIBRARY = "libc++";
225 | CLANG_ENABLE_MODULES = YES;
226 | CLANG_ENABLE_OBJC_ARC = YES;
227 | CLANG_ENABLE_OBJC_WEAK = YES;
228 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
229 | CLANG_WARN_BOOL_CONVERSION = YES;
230 | CLANG_WARN_COMMA = YES;
231 | CLANG_WARN_CONSTANT_CONVERSION = YES;
232 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
233 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
234 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
235 | CLANG_WARN_EMPTY_BODY = YES;
236 | CLANG_WARN_ENUM_CONVERSION = YES;
237 | CLANG_WARN_INFINITE_RECURSION = YES;
238 | CLANG_WARN_INT_CONVERSION = YES;
239 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
240 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
241 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
242 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
243 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
244 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
245 | CLANG_WARN_STRICT_PROTOTYPES = YES;
246 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
247 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
248 | CLANG_WARN_UNREACHABLE_CODE = YES;
249 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
250 | COPY_PHASE_STRIP = NO;
251 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
252 | ENABLE_NS_ASSERTIONS = NO;
253 | ENABLE_STRICT_OBJC_MSGSEND = YES;
254 | ENABLE_USER_SCRIPT_SANDBOXING = YES;
255 | GCC_C_LANGUAGE_STANDARD = gnu11;
256 | GCC_NO_COMMON_BLOCKS = YES;
257 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
258 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
259 | GCC_WARN_UNDECLARED_SELECTOR = YES;
260 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
261 | GCC_WARN_UNUSED_FUNCTION = YES;
262 | GCC_WARN_UNUSED_VARIABLE = YES;
263 | IPHONEOS_DEPLOYMENT_TARGET = 17.0;
264 | MTL_ENABLE_DEBUG_INFO = NO;
265 | MTL_FAST_MATH = YES;
266 | SDKROOT = iphoneos;
267 | SWIFT_COMPILATION_MODE = wholemodule;
268 | SWIFT_OPTIMIZATION_LEVEL = "-O";
269 | VALIDATE_PRODUCT = YES;
270 | };
271 | name = Release;
272 | };
273 | 3D5FCF832767888000D3211F /* Debug */ = {
274 | isa = XCBuildConfiguration;
275 | buildSettings = {
276 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
277 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
278 | CODE_SIGN_STYLE = Automatic;
279 | CURRENT_PROJECT_VERSION = 1;
280 | DEVELOPMENT_ASSET_PATHS = "";
281 | ENABLE_PREVIEWS = YES;
282 | GENERATE_INFOPLIST_FILE = YES;
283 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
284 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
285 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
286 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
287 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
288 | IPHONEOS_DEPLOYMENT_TARGET = 17.0;
289 | LD_RUNPATH_SEARCH_PATHS = (
290 | "$(inherited)",
291 | "@executable_path/Frameworks",
292 | );
293 | MACOSX_DEPLOYMENT_TARGET = 14.0;
294 | MARKETING_VERSION = 1.0;
295 | PRODUCT_BUNDLE_IDENTIFIER = "de.tiigi.WhatsNewKit.Example-iOS";
296 | PRODUCT_NAME = "WhatsNewKit-Example";
297 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
298 | SUPPORTS_MACCATALYST = NO;
299 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
300 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
301 | SWIFT_EMIT_LOC_STRINGS = YES;
302 | SWIFT_VERSION = 5.0;
303 | TARGETED_DEVICE_FAMILY = "1,2,7";
304 | };
305 | name = Debug;
306 | };
307 | 3D5FCF842767888000D3211F /* Release */ = {
308 | isa = XCBuildConfiguration;
309 | buildSettings = {
310 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
311 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
312 | CODE_SIGN_STYLE = Automatic;
313 | CURRENT_PROJECT_VERSION = 1;
314 | DEVELOPMENT_ASSET_PATHS = "";
315 | ENABLE_PREVIEWS = YES;
316 | GENERATE_INFOPLIST_FILE = YES;
317 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
318 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
319 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
320 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
321 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
322 | IPHONEOS_DEPLOYMENT_TARGET = 17.0;
323 | LD_RUNPATH_SEARCH_PATHS = (
324 | "$(inherited)",
325 | "@executable_path/Frameworks",
326 | );
327 | MACOSX_DEPLOYMENT_TARGET = 14.0;
328 | MARKETING_VERSION = 1.0;
329 | PRODUCT_BUNDLE_IDENTIFIER = "de.tiigi.WhatsNewKit.Example-iOS";
330 | PRODUCT_NAME = "WhatsNewKit-Example";
331 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
332 | SUPPORTS_MACCATALYST = NO;
333 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
334 | SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
335 | SWIFT_EMIT_LOC_STRINGS = YES;
336 | SWIFT_VERSION = 5.0;
337 | TARGETED_DEVICE_FAMILY = "1,2,7";
338 | };
339 | name = Release;
340 | };
341 | /* End XCBuildConfiguration section */
342 |
343 | /* Begin XCConfigurationList section */
344 | 3D5FCF6F2767887E00D3211F /* Build configuration list for PBXProject "Example" */ = {
345 | isa = XCConfigurationList;
346 | buildConfigurations = (
347 | 3D5FCF802767888000D3211F /* Debug */,
348 | 3D5FCF812767888000D3211F /* Release */,
349 | );
350 | defaultConfigurationIsVisible = 0;
351 | defaultConfigurationName = Release;
352 | };
353 | 3D5FCF822767888000D3211F /* Build configuration list for PBXNativeTarget "Example" */ = {
354 | isa = XCConfigurationList;
355 | buildConfigurations = (
356 | 3D5FCF832767888000D3211F /* Debug */,
357 | 3D5FCF842767888000D3211F /* Release */,
358 | );
359 | defaultConfigurationIsVisible = 0;
360 | defaultConfigurationName = Release;
361 | };
362 | /* End XCConfigurationList section */
363 |
364 | /* Begin XCSwiftPackageProductDependency section */
365 | 3D5FCF87276788FE00D3211F /* WhatsNewKit */ = {
366 | isa = XCSwiftPackageProductDependency;
367 | productName = WhatsNewKit;
368 | };
369 | /* End XCSwiftPackageProductDependency section */
370 | };
371 | rootObject = 3D5FCF6C2767887E00D3211F /* Project object */;
372 | }
373 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/Example/Example/App.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import WhatsNewKit
3 |
4 | // MARK: - App
5 |
6 | /// The App
7 | @main
8 | struct App {}
9 |
10 | // MARK: - SwiftUI.App
11 |
12 | extension App: SwiftUI.App {
13 |
14 | /// The content and behavior of the app.
15 | var body: some Scene {
16 | WindowGroup {
17 | ContentView()
18 | .environment(
19 | \.whatsNew,
20 | .init(
21 | versionStore: InMemoryWhatsNewVersionStore(),
22 | whatsNewCollection: self
23 | )
24 | )
25 | }
26 | }
27 |
28 | }
29 |
30 | // MARK: - App+WhatsNewCollectionProvider
31 |
32 | extension App: WhatsNewCollectionProvider {
33 |
34 | /// A WhatsNewCollection
35 | var whatsNewCollection: WhatsNewCollection {
36 | WhatsNew(
37 | version: "1.0.0",
38 | title: "WhatsNewKit",
39 | features: [
40 | .init(
41 | image: .init(
42 | systemName: "star.fill",
43 | foregroundColor: .orange
44 | ),
45 | title: "Showcase your new App Features",
46 | subtitle: "Present your new app features just like a native app from Apple."
47 | ),
48 | .init(
49 | image: .init(
50 | systemName: "wand.and.stars",
51 | foregroundColor: .cyan
52 | ),
53 | title: "Automatic Presentation",
54 | subtitle: .init(
55 | try! AttributedString(
56 | markdown: "Simply declare a WhatsNew per Version and present it automatically by using the `.whatsNewSheet()` modifier."
57 | )
58 | )
59 | ),
60 | .init(
61 | image: .init(
62 | systemName: "gear.circle.fill",
63 | foregroundColor: .gray
64 | ),
65 | title: "Configuration",
66 | subtitle: "Easily adjust colors, strings, haptic feedback, behaviours and the layout of the presented WhatsNewView to your needs."
67 | ),
68 | .init(
69 | image: .init(
70 | systemName: "swift",
71 | foregroundColor: .init(.init(red: 240.0 / 255, green: 81.0 / 255, blue: 56.0 / 255, alpha: 1))
72 | ),
73 | title: "Swift Package Manager",
74 | subtitle: "WhatsNewKit can be easily integrated via the Swift Package Manager."
75 | )
76 | ],
77 | primaryAction: .init(
78 | hapticFeedback: {
79 | #if os(iOS)
80 | .notification(.success)
81 | #else
82 | nil
83 | #endif
84 | }()
85 | ),
86 | secondaryAction: .init(
87 | title: "Learn more",
88 | action: .openURL(.init(string: "https://github.com/SvenTiigi/WhatsNewKit"))
89 | )
90 | )
91 | }
92 |
93 | }
94 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "color" : {
5 | "platform" : "universal",
6 | "reference" : "systemBlueColor"
7 | },
8 | "idiom" : "universal"
9 | }
10 | ],
11 | "info" : {
12 | "author" : "xcode",
13 | "version" : 1
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-1024px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-1024px.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "AppIcon-1024px.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | },
9 | {
10 | "filename" : "macOS-AppIcon-16px-16pt@1x.png",
11 | "idiom" : "mac",
12 | "scale" : "1x",
13 | "size" : "16x16"
14 | },
15 | {
16 | "filename" : "macOS-AppIcon-32px-16pt@2x.png",
17 | "idiom" : "mac",
18 | "scale" : "2x",
19 | "size" : "16x16"
20 | },
21 | {
22 | "filename" : "macOS-AppIcon-32px-32pt@1x.png",
23 | "idiom" : "mac",
24 | "scale" : "1x",
25 | "size" : "32x32"
26 | },
27 | {
28 | "filename" : "macOS-AppIcon-64px-32pt@2x.png",
29 | "idiom" : "mac",
30 | "scale" : "2x",
31 | "size" : "32x32"
32 | },
33 | {
34 | "filename" : "macOS-AppIcon-128px-128pt@1x.png",
35 | "idiom" : "mac",
36 | "scale" : "1x",
37 | "size" : "128x128"
38 | },
39 | {
40 | "filename" : "macOS-AppIcon-256px-128pt@2x.png",
41 | "idiom" : "mac",
42 | "scale" : "2x",
43 | "size" : "128x128"
44 | },
45 | {
46 | "filename" : "macOS-AppIcon-256px-256pt@1x.png",
47 | "idiom" : "mac",
48 | "scale" : "1x",
49 | "size" : "256x256"
50 | },
51 | {
52 | "filename" : "macOS-AppIcon-512px-256pt@2x.png",
53 | "idiom" : "mac",
54 | "scale" : "2x",
55 | "size" : "256x256"
56 | },
57 | {
58 | "filename" : "macOS-AppIcon-512px.png",
59 | "idiom" : "mac",
60 | "scale" : "1x",
61 | "size" : "512x512"
62 | },
63 | {
64 | "filename" : "macOS-AppIcon-1024px.png",
65 | "idiom" : "mac",
66 | "scale" : "2x",
67 | "size" : "512x512"
68 | }
69 | ],
70 | "info" : {
71 | "author" : "xcode",
72 | "version" : 1
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-1024px.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-128px-128pt@1x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-16px-16pt@1x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-128pt@2x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-256pt@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-256px-256pt@1x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-16pt@2x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-32px-32pt@1x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px-256pt@2x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-512px.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SvenTiigi/WhatsNewKit/6157c77e8be9b3d2310bc680681b61a8d9e290ac/Example/Example/Assets.xcassets/AppIcon.appiconset/macOS-AppIcon-64px-32pt@2x.png
--------------------------------------------------------------------------------
/Example/Example/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Example/Example/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import WhatsNewKit
3 |
4 | // MARK: - ContentView
5 |
6 | /// The ContentView
7 | struct ContentView {}
8 |
9 | // MARK: - View
10 |
11 | extension ContentView: View {
12 |
13 | /// The content and behavior of the view
14 | var body: some View {
15 | NavigationStack {
16 | ExamplesView()
17 | }
18 | .whatsNewSheet()
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/Example/Example/ExamplesView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import WhatsNewKit
3 |
4 | // MARK: - ExamplesView
5 |
6 | /// The ExamplesView
7 | struct ExamplesView {
8 |
9 | /// The Examples
10 | private let examples = WhatsNew.Example.allCases
11 |
12 | /// The currently presented WhatsNew object
13 | @State
14 | private var whatsNew: WhatsNew?
15 |
16 | }
17 |
18 | // MARK: - View
19 |
20 | extension ExamplesView: View {
21 |
22 | /// The content and behavior of the view
23 | var body: some View {
24 | List {
25 | Section(
26 | header: Text(
27 | verbatim: "Examples"
28 | ),
29 | footer: Text(
30 | verbatim: "Tap on an example to manually present a WhatsNewView"
31 | )
32 | ) {
33 | ForEach(
34 | self.examples,
35 | id: \.rawValue
36 | ) { example in
37 | Button(
38 | action: {
39 | self.whatsNew = example.whatsNew
40 | }
41 | ) {
42 | Text(
43 | verbatim: example.displayName
44 | )
45 | }
46 | }
47 | }
48 | }
49 | .navigationTitle("WhatsNewKit")
50 | .sheet(
51 | whatsNew: self.$whatsNew
52 | )
53 | }
54 |
55 | }
56 |
57 | // MARK: - WhatsNew+Example
58 |
59 | private extension WhatsNew {
60 |
61 | /// A WhatsNew Example
62 | enum Example: String, Codable, Hashable, CaseIterable {
63 | /// Calendar
64 | case calendar
65 | /// Maps
66 | case maps
67 | /// Translate
68 | case translate
69 | }
70 |
71 | }
72 |
73 | // MARK: - WhatsNew+Example+displayName
74 |
75 | private extension WhatsNew.Example {
76 |
77 | /// The user friendly display name
78 | var displayName: String {
79 | self.rawValue.prefix(1).capitalized + self.rawValue.dropFirst()
80 | }
81 |
82 | }
83 |
84 | // MARK: - WhatsNew+Example+whatsNew
85 |
86 | private extension WhatsNew.Example {
87 |
88 | /// The WhatsNew
89 | var whatsNew: WhatsNew {
90 | switch self {
91 | case .calendar:
92 | return .init(
93 | title: "What's New in Calendar",
94 | features: [
95 | .init(
96 | image: .init(
97 | systemName: "envelope",
98 | foregroundColor: .red
99 | ),
100 | title: "Found Events",
101 | subtitle: "Siri suggests events found in Mail, Messages, and Safari, so you can add them easily, such as flight reservations and hotel bookings."
102 | ),
103 | .init(
104 | image: .init(
105 | systemName: "clock",
106 | foregroundColor: .red
107 | ),
108 | title: "Time to Leave",
109 | subtitle: "Calendar uses Apple Maps to look up locations, traffic conditions, and transit options to tell you when it's time to leave."
110 | ),
111 | .init(
112 | image: .init(
113 | systemName: "location",
114 | foregroundColor: .red
115 | ),
116 | title: "Location Suggestions",
117 | subtitle: "Calendar suggests locations based on your past events and significant locations."
118 | )
119 | ],
120 | primaryAction: .init(
121 | backgroundColor: .red
122 | )
123 | )
124 | case .maps:
125 | return .init(
126 | title: "What's New in Maps",
127 | features: [
128 | .init(
129 | image: .init(
130 | systemName: "map.fill",
131 | foregroundColor: .green
132 | ),
133 | title: "Updated Map Style",
134 | subtitle: "An improved design makes it easier to navigate and explore the map."
135 | ),
136 | .init(
137 | image: .init(
138 | systemName: "mappin.and.ellipse",
139 | foregroundColor: .pink
140 | ),
141 | title: "All-New Place Cards",
142 | subtitle: "Completely redesigned place cards make it easier to learn about and interact with places."
143 | ),
144 | .init(
145 | image: .init(
146 | systemName: "magnifyingglass",
147 | foregroundColor: .blue
148 | ),
149 | title: "Improved Search",
150 | subtitle: "Finding places is now easier with filters and automatic updates when you're browsing results on the map."
151 | )
152 | ],
153 | primaryAction: .init(backgroundColor: .blue),
154 | secondaryAction: .init(
155 | title: "About Apple Maps & Privacy",
156 | foregroundColor: .blue,
157 | action: .openURL(.init(string: "maps://"))
158 | )
159 | )
160 | case .translate:
161 | return .init(
162 | title: .init(
163 | text: .init(
164 | "What's New in "
165 | + AttributedString(
166 | "Translate",
167 | attributes: .foregroundColor(.cyan)
168 | )
169 | )
170 | ),
171 | features: [
172 | .init(
173 | image: .init(
174 | systemName: "rectangle.portrait.bottomthird.inset.filled",
175 | foregroundColor: .cyan
176 | ),
177 | title: "Conversation Views",
178 | subtitle: "Choose a side-by-side or face-to-face conversation view."
179 | ),
180 | .init(
181 | image: .init(
182 | systemName: "mic",
183 | foregroundColor: .cyan
184 | ),
185 | title: "Auto Translate",
186 | subtitle: "Respond in conversations without tapping the microphone button."
187 | ),
188 | .init(
189 | image: .init(
190 | systemName: "iphone",
191 | foregroundColor: .cyan
192 | ),
193 | title: "System-Wide Translation",
194 | subtitle: "Translate selected text anywhere on your iPhone."
195 | )
196 | ],
197 | primaryAction: .init(
198 | backgroundColor: .cyan
199 | ),
200 | secondaryAction: .init(
201 | title: "About Translation & Privacy",
202 | foregroundColor: .cyan,
203 | action: .openURL(
204 | .init(string: "https://apple.com/privacy")
205 | )
206 | )
207 | )
208 | }
209 | }
210 |
211 | }
212 |
213 | // MARK: - AttributeContainer+foregroundColor
214 |
215 | private extension AttributeContainer {
216 |
217 | /// A AttributeContainer with a given foreground color
218 | /// - Parameter color: The foreground color
219 | static func foregroundColor(
220 | _ color: Color
221 | ) -> Self {
222 | var container = Self()
223 | container.foregroundColor = color
224 | return container
225 | }
226 |
227 | }
228 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Sven Tiigi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 |
3 | import PackageDescription
4 |
5 | let package = Package(
6 | name: "WhatsNewKit",
7 | platforms: [
8 | .iOS(.v13),
9 | .macOS(.v11),
10 | .visionOS(.v1)
11 | ],
12 | products: [
13 | .library(
14 | name: "WhatsNewKit",
15 | targets: [
16 | "WhatsNewKit"
17 | ]
18 | )
19 | ],
20 | targets: [
21 | .target(
22 | name: "WhatsNewKit",
23 | path: "Sources",
24 | resources: [
25 | .process("Resources/PrivacyInfo.xcprivacy")
26 | ]
27 | ),
28 | .testTarget(
29 | name: "WhatsNewKitTests",
30 | dependencies: [
31 | "WhatsNewKit"
32 | ],
33 | path: "Tests"
34 | )
35 | ]
36 | )
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | WhatsNewKit
9 |
10 |
11 |
12 | A Swift Package to easily showcase your new app features.
13 |
14 | It's designed from the ground up to be fully customized to your needs.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | ```swift
42 | import SwiftUI
43 | import WhatsNewKit
44 |
45 | struct ContentView: View {
46 |
47 | var body: some View {
48 | NavigationView {
49 | // ...
50 | }
51 | .whatsNewSheet()
52 | }
53 |
54 | }
55 | ```
56 |
57 | ## Features
58 |
59 | - [x] Easily present your new app features 🤩
60 | - [x] Automatic & Manual presentation mode ✅
61 | - [x] Support for SwiftUI, UIKit and AppKit 🧑🎨
62 | - [x] Runs on iOS, macOS and visionOS 📱 🖥 👓
63 | - [x] Adjustable layout 🔧
64 |
65 | ## Installation
66 |
67 | ### Swift Package Manager
68 |
69 | To integrate using Apple's [Swift Package Manager](https://swift.org/package-manager/), add the following as a dependency to your `Package.swift`:
70 |
71 | ```swift
72 | dependencies: [
73 | .package(url: "https://github.com/SvenTiigi/WhatsNewKit.git", from: "2.0.0")
74 | ]
75 | ```
76 |
77 | Or navigate to your Xcode project then select `Swift Packages`, click the “+” icon and search for `WhatsNewKit`.
78 |
79 | ## Example
80 |
81 | Check out the example application to see WhatsNewKit in action. Simply open the `Example/Example.xcodeproj` and run the "Example" scheme.
82 |
83 |
84 |
85 |
86 |
87 | ## Usage
88 |
89 | ### Table of contents
90 |
91 | - [Manual Presentation](https://github.com/SvenTiigi/WhatsNewKit/tree/main#manual-presentation)
92 | - [Automatic Presentation](https://github.com/SvenTiigi/WhatsNewKit/tree/main#automatic-presentation)
93 | - [WhatsNewEnvironment](https://github.com/SvenTiigi/WhatsNewKit/tree/main#whatsnewenvironment)
94 | - [WhatsNewVersionStore](https://github.com/SvenTiigi/WhatsNewKit/tree/main#whatsnewversionstore)
95 | - [WhatsNew](https://github.com/SvenTiigi/WhatsNewKit/tree/main#whatsnew)
96 | - [Layout](https://github.com/SvenTiigi/WhatsNewKit/tree/main#layout)
97 | - [WhatsNewViewController](https://github.com/SvenTiigi/WhatsNewKit/tree/main#whatsnewviewcontroller)
98 |
99 | ### Manual Presentation
100 |
101 | If you wish to manually present a `WhatsNewView` you can make use of the `sheet(whatsNew:)` modifier.
102 |
103 | ```swift
104 | struct ContentView: View {
105 |
106 | @State
107 | var whatsNew: WhatsNew? = WhatsNew(
108 | title: "WhatsNewKit",
109 | features: [
110 | .init(
111 | image: .init(
112 | systemName: "star.fill",
113 | foregroundColor: .orange
114 | ),
115 | title: "Showcase your new App Features",
116 | subtitle: "Present your new app features..."
117 | ),
118 | // ...
119 | ]
120 | )
121 |
122 | var body: some View {
123 | NavigationView {
124 | // ...
125 | }
126 | .sheet(
127 | whatsNew: self.$whatsNew
128 | )
129 | }
130 |
131 | }
132 | ```
133 |
134 | ### Automatic Presentation
135 |
136 | The automatic presentation mode allows you to simply declare your new features via the SwiftUI Environment and WhatsNewKit will take care to present the corresponding `WhatsNewView`.
137 |
138 | First add a `.whatsNewSheet()` modifier to the view where the `WhatsNewView` should be presented on.
139 |
140 | ```swift
141 | struct ContentView: View {
142 |
143 | var body: some View {
144 | NavigationView {
145 | // ...
146 | }
147 | // Automatically present a WhatsNewView, if needed.
148 | // The WhatsNew that should be presented to the user
149 | // is automatically retrieved from the `WhatsNewEnvironment`
150 | .whatsNewSheet()
151 | }
152 |
153 | }
154 | ```
155 |
156 | The `.whatsNewSheet()` modifier is making use of the `WhatsNewEnvironment` to retrieve an optional WhatsNew object that should be presented to the user for the current version. Therefore you can easily configure the `WhatsNewEnvironment` via the `environment` modifier.
157 |
158 | ```swift
159 | extension App: SwiftUI.App {
160 |
161 | var body: some Scene {
162 | WindowGroup {
163 | ContentView()
164 | .environment(
165 | \.whatsNew,
166 | WhatsNewEnvironment(
167 | // Specify in which way the presented WhatsNew Versions are stored.
168 | // In default the `UserDefaultsWhatsNewVersionStore` is used.
169 | versionStore: UserDefaultsWhatsNewVersionStore(),
170 | // Pass a `WhatsNewCollectionProvider` or an array of WhatsNew instances
171 | whatsNewCollection: self
172 | )
173 | )
174 | }
175 | }
176 |
177 | }
178 |
179 | // MARK: - App+WhatsNewCollectionProvider
180 |
181 | extension App: WhatsNewCollectionProvider {
182 |
183 | /// Declare your WhatsNew instances per version
184 | var whatsNewCollection: WhatsNewCollection {
185 | WhatsNew(
186 | version: "1.0.0",
187 | // ...
188 | )
189 | WhatsNew(
190 | version: "1.1.0",
191 | // ...
192 | )
193 | WhatsNew(
194 | version: "1.2.0",
195 | // ...
196 | )
197 | }
198 |
199 | }
200 | ```
201 |
202 | ## WhatsNewEnvironment
203 |
204 | The `WhatsNewEnvironment` will take care to determine the matching WhatsNew object that should be presented to the user for the current version.
205 |
206 | As seen in the previous example you can initialize a `WhatsNewEnvironment` by specifying the `WhatsNewVersionStore` and providing a `WhatsNewCollection`.
207 |
208 | ```swift
209 | // Initialize WhatsNewEnvironment by passing an array of WhatsNew Instances.
210 | // UserDefaultsWhatsNewVersionStore is used as default WhatsNewVersionStore
211 | let whatsNewEnvironment = WhatsNewEnvironment(
212 | whatsNewCollection: [
213 | WhatsNew(
214 | version: "1.0.0",
215 | // ...
216 | )
217 | ]
218 | )
219 |
220 | // Initialize WhatsNewEnvironment with NSUbiquitousKeyValueWhatsNewVersionStore
221 | // which stores the presented versions in iCloud.
222 | // WhatsNewCollection is provided by a `WhatsNewBuilder` closure
223 | let whatsNewEnvironment = WhatsNewEnvironment(
224 | versionStore: NSUbiquitousKeyValueWhatsNewVersionStore(),
225 | whatsNewCollection: {
226 | WhatsNew(
227 | version: "1.0.0",
228 | // ...
229 | )
230 | }
231 | )
232 | ```
233 |
234 | Additionally, the `WhatsNewEnvironment` includes a fallback for patch versions. For example when a user installs version `1.0.1` and you only have declared a `WhatsNew` for version `1.0.0` the environment will automatically fallback to version `1.0.0` and present the `WhatsNewView` to the user if needed.
235 |
236 | If you wish to further customize the behaviour of the `WhatsNewEnvironment` you can easily subclass it and override the `whatsNew()` function.
237 |
238 | ```swift
239 | class MyCustomWhatsNewEnvironment: WhatsNewEnvironment {
240 |
241 | /// Retrieve a WhatsNew that should be presented to the user, if available.
242 | override func whatsNew() -> WhatsNew? {
243 | // The current version
244 | let currentVersion = self.currentVersion
245 | // Your declared WhatsNew objects
246 | let whatsNewCollection = self.whatsNewCollection
247 | // The WhatsNewVersionStore used to determine the already presented versions
248 | let versionStore = self.whatsNewVersionStore
249 | // TODO: Determine WhatsNew that should be presented to the user...
250 | }
251 |
252 | }
253 | ```
254 |
255 | ## WhatsNewVersionStore
256 |
257 | A `WhatsNewVersionStore` is a protocol type which is responsible for saving and retrieving versions that have been presented to the user.
258 |
259 | ```swift
260 | let whatsNewVersionStore: WhatsNewVersionStore
261 |
262 | // Save presented versions
263 | whatsNewVersionStore.save(presentedVersion: "1.0.0")
264 |
265 | // Retrieve presented versions
266 | let presentedVersions = whatsNewVersionStore.presentedVersions
267 |
268 | // Retrieve bool value if a given version has already been presented
269 | let hasPresented = whatsNewVersionStore.hasPresented("1.0.0")
270 | ```
271 |
272 | WhatsNewKit comes along with three predefined implementations:
273 |
274 | ```swift
275 | // Persists presented versions in the UserDefaults
276 | let userDefaultsWhatsNewVersionStore = UserDefaultsWhatsNewVersionStore()
277 |
278 | // Persists presented versions in iCloud using the NSUbiquitousKeyValueStore
279 | let ubiquitousKeyValueWhatsNewVersionStore = NSUbiquitousKeyValueWhatsNewVersionStore()
280 |
281 | // Stores presented versions in memory. Perfect for testing purposes
282 | let inMemoryWhatsNewVersionStore = InMemoryWhatsNewVersionStore()
283 | ```
284 |
285 | If you already have a specific implementation to store user related settings like Realm or Core Data you can easily adopt your existing implementation to the `WhatsNewVersionStore` protocol.
286 |
287 | ### NSUbiquitousKeyValueWhatsNewVersionStore
288 |
289 | If you are making use of the `NSUbiquitousKeyValueWhatsNewVersionStore` please ensure to enable the [iCloud Key-value storage](https://developer.apple.com/library/archive/documentation/General/Conceptual/iCloudDesignGuide/Chapters/DesigningForKey-ValueDataIniCloud.html) capability in the "Signing & Capabilities" section of your Xcode project.
290 |
291 |
292 |
293 |
294 |
295 | ## WhatsNew
296 |
297 | The following sections explains how a `WhatsNew` struct can be initialized in order to describe the new features for a given version of your app.
298 |
299 | ```swift
300 | let whatsnew = WhatsNew(
301 | // The Version that relates to the features you want to showcase
302 | version: "1.0.0",
303 | // The title that is shown at the top
304 | title: "What's New",
305 | // The features you want to showcase
306 | features: [
307 | WhatsNew.Feature(
308 | image: .init(systemName: "star.fill"),
309 | title: "Title",
310 | subtitle: "Subtitle"
311 | )
312 | ],
313 | // The primary action that is used to dismiss the WhatsNewView
314 | primaryAction: WhatsNew.PrimaryAction(
315 | title: "Continue",
316 | backgroundColor: .accentColor,
317 | foregroundColor: .white,
318 | hapticFeedback: .notification(.success),
319 | onDismiss: {
320 | print("WhatsNewView has been dismissed")
321 | }
322 | ),
323 | // The optional secondary action that is displayed above the primary action
324 | secondaryAction: WhatsNew.SecondaryAction(
325 | title: "Learn more",
326 | foregroundColor: .accentColor,
327 | hapticFeedback: .selection,
328 | action: .openURL(
329 | .init(string: "https://github.com/SvenTiigi/WhatsNewKit")
330 | )
331 | )
332 | )
333 | ```
334 |
335 | ### WhatsNew.Version
336 |
337 | The `WhatsNew.Version` specifies the version that has introduced certain features to your app.
338 |
339 | ```swift
340 | // Initialize with major, minor, and patch
341 | let version = WhatsNew.Version(
342 | major: 1,
343 | minor: 0,
344 | patch: 0
345 | )
346 |
347 | // Initialize by string literal
348 | let version: WhatsNew.Version = "1.0.0"
349 |
350 | // Initialize WhatsNew Version by using the current version of your bundle
351 | let version: WhatsNew.Version = .current()
352 | ```
353 |
354 | ### WhatsNew.Title
355 |
356 | A `WhatsNew.Title` represents the title text that is rendered above the features.
357 |
358 | ```swift
359 | // Initialize by string literal
360 | let title: WhatsNew.Title = "Continue"
361 |
362 | // Initialize with text and foreground color
363 | let title = WhatsNew.Title(
364 | text: "Continue",
365 | foregroundColor: .primary
366 | )
367 |
368 | // On >= iOS 15 initialize with AttributedString using Markdown
369 | let title = WhatsNew.Title(
370 | text: try AttributedString(
371 | markdown: "What's **New**"
372 | )
373 | )
374 | ```
375 |
376 | ### WhatsNew.Feature
377 |
378 | A `WhatsNew.Feature` describe a specific feature of your app and generally consist of an image, title, and subtitle.
379 |
380 | ```swift
381 | let feature = WhatsNew.Feature(
382 | image: .init(
383 | systemName: "wand.and.stars"
384 | ),
385 | title: "New Design",
386 | subtitle: .init(
387 | try AttributedString(
388 | markdown: "An awesome new _Design_"
389 | )
390 | )
391 | )
392 | ```
393 |
394 | ### WhatsNew.PrimaryAction
395 |
396 | The `WhatsNew.PrimaryAction` allows you to configure the behaviour of the primary button which is used to dismiss the presented `WhatsNewView`
397 |
398 | ```swift
399 | let primaryAction = WhatsNew.PrimaryAction(
400 | title: "Continue",
401 | backgroundColor: .blue,
402 | foregroundColor: .white,
403 | hapticFeedback: .notification(.success),
404 | onDismiss: {
405 | print("WhatsNewView has been dismissed")
406 | }
407 | )
408 | ```
409 |
410 | > Note: HapticFeedback will only be executed on iOS
411 |
412 | ### WhatsNew.SecondaryAction
413 |
414 | A `WhatsNew.SecondaryAction` which is displayed above the `WhatsNew.PrimaryAction` can be optionally supplied when initializing a `WhatsNew` instance and allows you to present an additional View, perform a custom action or open an URL.
415 |
416 | ```swift
417 | // SecondaryAction that presents a View
418 | let secondaryActionPresentAboutView = WhatsNew.SecondaryAction(
419 | title: "Learn more",
420 | foregroundColor: .blue,
421 | hapticFeedback: .selection,
422 | action: .present {
423 | AboutView()
424 | }
425 | )
426 |
427 | // SecondaryAction that opens a URL
428 | let secondaryActionOpenURL = WhatsNew.SecondaryAction(
429 | title: "Read more",
430 | foregroundColor: .blue,
431 | hapticFeedback: .selection,
432 | action: .open(
433 | url: .init(string: "https://github.com/SvenTiigi/WhatsNewKit")
434 | )
435 | )
436 |
437 | // SecondaryAction with custom execution
438 | let secondaryActionCustom = WhatsNew.SecondaryAction(
439 | title: "Custom",
440 | action: .custom { presentationMode in
441 | // ...
442 | }
443 | )
444 | ```
445 |
446 | > Note: HapticFeedback will only be executed on iOS
447 |
448 | ## Layout
449 |
450 | WhatsNewKit allows you to adjust the layout of a presented `WhatsNewView` in various ways.
451 |
452 | The most simple way is by mutating the `WhatsNew.Layout.default` instance.
453 |
454 | ```swift
455 | WhatsNew.Layout.default.featureListSpacing = 35
456 | ```
457 |
458 | When using the automatic presentation style you can supply a default layout when initializing the WhatsNewEnvironment.
459 |
460 | ```swift
461 | .environment(
462 | \.whatsNew,
463 | .init(
464 | defaultLayout: WhatsNew.Layout(
465 | showsScrollViewIndicators: true,
466 | featureListSpacing: 35
467 | ),
468 | whatsNew: self
469 | )
470 | )
471 | ```
472 |
473 | Alternatively you can pass a `WhatsNew.Layout` when automatically or manually presenting the WhatsNewView
474 |
475 | ```swift
476 | .whatsNewSheet(
477 | layout: WhatsNew.Layout(
478 | contentPadding: .init(
479 | top: 80,
480 | leading: 0,
481 | bottom: 0,
482 | trailing: 0
483 | )
484 | )
485 | )
486 | ```
487 |
488 | ```swift
489 | .sheet(
490 | whatsNew: self.$whatsNew,
491 | layout: WhatsNew.Layout(
492 | footerActionSpacing: 20
493 | )
494 | )
495 | ```
496 |
497 | ## WhatsNewViewController
498 |
499 | When using `UIKit` or `AppKit` you can make use of the `WhatsNewViewController`.
500 |
501 | ```swift
502 | let whatsNewViewController = WhatsNewViewController(
503 | whatsNew: WhatsNew(
504 | version: "1.0.0",
505 | // ...
506 | ),
507 | layout: WhatsNew.Layout(
508 | contentSpacing: 80
509 | )
510 | )
511 | ```
512 |
513 | If you wish to present a `WhatsNewViewController` only if the version of the WhatsNew instance has not been presented you can make use of the convenience failable initializer.
514 |
515 | ```swift
516 | // Verify WhatsNewViewController is available for presentation
517 | guard let whatsNewViewController = WhatsNewViewController(
518 | whatsNew: WhatsNew(
519 | version: "1.0.0",
520 | // ...
521 | ),
522 | versionStore: UserDefaultsWhatsNewVersionStore()
523 | ) else {
524 | // Version of WhatsNew has already been presented
525 | return
526 | }
527 |
528 | // Present WhatsNewViewController
529 | // Version will be automatically saved in the provided
530 | // WhatsNewVersionStore when the WhatsNewViewController gets dismissed
531 | self.present(whatsNewViewController, animated: true)
532 | ```
533 |
--------------------------------------------------------------------------------
/Sources/Collection/WhatsNewCollection.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A WhatsNewCollection type representing an array of WhatsNew elements
4 | public typealias WhatsNewCollection = [WhatsNew]
5 |
--------------------------------------------------------------------------------
/Sources/Collection/WhatsNewCollectionBuilder.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNewCollectionBuilder
4 |
5 | /// A WhatsNewCollectionBuilder
6 | @resultBuilder
7 | public enum WhatsNewCollectionBuilder {}
8 |
9 | // MARK: - ResultBuilder
10 |
11 | public extension WhatsNewCollectionBuilder {
12 |
13 | /// Build WhatsNewCollection
14 | /// - Parameter components: The WhatsNew elements
15 | static func buildBlock(
16 | _ components: WhatsNewCollection.Element?...
17 | ) -> WhatsNewCollection {
18 | components.compactMap { $0 }
19 | }
20 |
21 | /// Build optional WhatsNewCollection
22 | /// - Parameter component: The optional WhatsNewCollection
23 | static func buildOptional(
24 | _ component: WhatsNewCollection?
25 | ) -> WhatsNewCollection {
26 | component ?? .init()
27 | }
28 |
29 | /// Build either first WhatsNewCollection
30 | /// - Parameter component: The first WhatsNewCollection
31 | static func buildEither(
32 | first component: WhatsNewCollection
33 | ) -> WhatsNewCollection {
34 | component
35 | }
36 |
37 | /// Build either second WhatsNewCollection
38 | /// - Parameter component: The second WhatsNewCollection
39 | static func buildEither(
40 | second component: WhatsNewCollection
41 | ) -> WhatsNewCollection {
42 | component
43 | }
44 |
45 | /// Build array of WhatsNewCollections
46 | /// - Parameter components: The array of WhatsNewCollections
47 | static func buildArray(
48 | _ components: [WhatsNewCollection]
49 | ) -> WhatsNewCollection {
50 | components.flatMap { $0 }
51 | }
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/Sources/Collection/WhatsNewCollectionProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNewProvider
4 |
5 | /// A WhatsNewCollection Provider type
6 | public protocol WhatsNewCollectionProvider {
7 |
8 | /// A WhatsNewCollection
9 | @WhatsNewCollectionBuilder
10 | var whatsNewCollection: WhatsNewCollection { get }
11 |
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/Environment/WhatsNewEnvironment+Key.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNewEnvironment+Key
4 |
5 | public extension WhatsNewEnvironment {
6 |
7 | /// The WhatsNewEnvironment Key
8 | enum Key: EnvironmentKey {
9 |
10 | /// The default value for the environment key
11 | public static var defaultValue = WhatsNewEnvironment()
12 |
13 | }
14 |
15 | }
16 |
17 | // MARK: - EnvironmentValues+whatsNew
18 |
19 | public extension EnvironmentValues {
20 |
21 | /// The WhatsNewEnvironment
22 | var whatsNew: WhatsNewEnvironment {
23 | get {
24 | self[WhatsNewEnvironment.Key.self]
25 | }
26 | set {
27 | self[WhatsNewEnvironment.Key.self] = newValue
28 | }
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/Sources/Environment/WhatsNewEnvironment.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNewEnvironment
4 |
5 | /// A WhatsNew Environment
6 | open class WhatsNewEnvironment {
7 |
8 | // MARK: Properties
9 |
10 | /// The current WhatsNew Version
11 | public let currentVersion: WhatsNew.Version
12 |
13 | /// The WhatsNewVersionStore
14 | public let whatsNewVersionStore: WhatsNewVersionStore
15 |
16 | /// The default WhatsNew Layout
17 | public let defaultLayout: WhatsNew.Layout
18 |
19 | /// The WhatsNewCollection
20 | public let whatsNewCollection: WhatsNewCollection
21 |
22 | // MARK: Initializer
23 |
24 | /// Creates a new instance of `WhatsNewEnvironment`
25 | /// - Parameters:
26 | /// - currentVersion: The current WhatsNew Version. Default value `.current()`
27 | /// - versionStore: The WhatsNewVersionStore. Default value `UserDefaultsWhatsNewVersionStore()`
28 | /// - defaultLayout: The default WhatsNew Layout. Default value `.default`
29 | /// - whatsNewCollection: The WhatsNewCollection
30 | public init(
31 | currentVersion: WhatsNew.Version = .current(),
32 | versionStore: WhatsNewVersionStore = UserDefaultsWhatsNewVersionStore(),
33 | defaultLayout: WhatsNew.Layout = .default,
34 | whatsNewCollection: WhatsNewCollection = .init()
35 | ) {
36 | self.currentVersion = currentVersion
37 | self.whatsNewVersionStore = versionStore
38 | self.defaultLayout = defaultLayout
39 | self.whatsNewCollection = whatsNewCollection
40 | }
41 |
42 | /// Creates a new instance of `WhatsNewEnvironment`
43 | /// - Parameters:
44 | /// - currentVersion: The current WhatsNew Version. Default value `.current()`
45 | /// - versionStore: The WhatsNewVersionStore. Default value `UserDefaultsWhatsNewVersionStore()`
46 | /// - defaultLayout: The default WhatsNew Layout. Default value `.default`
47 | /// - whatsNewCollection: The WhatsNewCollectionProvider
48 | public convenience init(
49 | currentVersion: WhatsNew.Version = .current(),
50 | versionStore: WhatsNewVersionStore = UserDefaultsWhatsNewVersionStore(),
51 | defaultLayout: WhatsNew.Layout = .default,
52 | whatsNewCollection whatsNewCollectionProvider: WhatsNewCollectionProvider
53 | ) {
54 | self.init(
55 | currentVersion: currentVersion,
56 | versionStore: versionStore,
57 | defaultLayout: defaultLayout,
58 | whatsNewCollection: whatsNewCollectionProvider.whatsNewCollection
59 | )
60 | }
61 |
62 | /// Creates a new instance of `WhatsNewEnvironment`
63 | /// - Parameters:
64 | /// - currentVersion: The current WhatsNew Version. Default value `.current()`
65 | /// - versionStore: The WhatsNewVersionStore. Default value `UserDefaultsWhatsNewVersionStore()`
66 | /// - defaultLayout: The default WhatsNew Layout. Default value `.default`
67 | /// - whatsNewCollection: A result builder closure that produces a WhatsNewCollection
68 | public convenience init(
69 | currentVersion: WhatsNew.Version = .current(),
70 | versionStore: WhatsNewVersionStore = UserDefaultsWhatsNewVersionStore(),
71 | defaultLayout: WhatsNew.Layout = .default,
72 | @WhatsNewCollectionBuilder
73 | whatsNewCollection: () -> WhatsNewCollection
74 | ) {
75 | self.init(
76 | currentVersion: currentVersion,
77 | versionStore: versionStore,
78 | defaultLayout: defaultLayout,
79 | whatsNewCollection: whatsNewCollection()
80 | )
81 | }
82 |
83 | // MARK: WhatsNew
84 |
85 | /// Retrieve a WhatsNew that should be presented to the user, if available.
86 | open func whatsNew() -> WhatsNew? {
87 | // Retrieve presented WhatsNew Versions from WhatsNewVersionStore
88 | let presentedWhatsNewVersions = self.whatsNewVersionStore.presentedVersions
89 | // Verify the current Version has not been presented
90 | guard !presentedWhatsNewVersions.contains(self.currentVersion) else {
91 | // Otherwise WhatsNew has already been presented for the current version
92 | return nil
93 | }
94 | // Check if a WhatsNew is available for the current Version
95 | if let whatsNew = self.whatsNewCollection.first(where: { $0.version == self.currentVersion }) {
96 | // Return WhatsNew for the current Version
97 | return whatsNew
98 | }
99 | // Otherwise initialize current minor release Version
100 | let currentMinorVersion = WhatsNew.Version(
101 | major: self.currentVersion.major,
102 | minor: self.currentVersion.minor,
103 | patch: 0
104 | )
105 | // Verify the current minor release Version has not been presented
106 | guard !presentedWhatsNewVersions.contains(currentMinorVersion) else {
107 | // Otherwise WhatsNew for current minor release Version has already been preseted
108 | return nil
109 | }
110 | // Return WhatsNew for current minor release Version, if available
111 | return self.whatsNewCollection.first { $0.version == currentMinorVersion }
112 | }
113 |
114 | }
115 |
--------------------------------------------------------------------------------
/Sources/Extensions/ScrollView+alwaysBounceVertical.swift:
--------------------------------------------------------------------------------
1 | #if os(iOS)
2 | import SwiftUI
3 |
4 | // MARK: - ScrollView+alwaysBounceVertical
5 |
6 | extension ScrollView {
7 |
8 | /// Resolve the underlying `UIScrollView` to update the `alwaysBounceVertical` attribute which is
9 | /// a Boolean value that determines whether bouncing always occurs when vertical scrolling reaches the end of the content.
10 | /// - Parameter alwaysBounceVertical: Bool value if the UIScrollView should always bounce vertical
11 | func alwaysBounceVertical(
12 | _ alwaysBounceVertical: Bool
13 | ) -> some View {
14 | self.overlay(
15 | ViewControllerResolver { viewController in
16 | // Verify UIScrollView is available
17 | guard let scrollView = viewController
18 | .view
19 | .subviews
20 | .first(where: { $0 is UIScrollView }) as? UIScrollView else {
21 | // Otherwise return out of function
22 | return
23 | }
24 | // Set alwaysBounceVertical
25 | scrollView.alwaysBounceVertical = alwaysBounceVertical
26 | }
27 | .frame(width: 0, height: 0)
28 | )
29 | }
30 |
31 | }
32 |
33 | // MARK: - ViewControllerResolver
34 |
35 | /// The ViewControllerResolver
36 | private struct ViewControllerResolver: UIViewControllerRepresentable {
37 |
38 | // MARK: Typealias
39 |
40 | /// A typealias represents a UIViewController resolver closure
41 | typealias Resolver = (UIViewController) -> Void
42 |
43 | // MARK: Properties
44 |
45 | /// The Resolver
46 | let resolver: Resolver
47 |
48 | // MARK: UIViewControllerRepresentable
49 |
50 | /// Make ResolvedViewController
51 | /// - Parameter context: The Context
52 | func makeUIViewController(
53 | context: Context
54 | ) -> Content {
55 | .init(resolver: self.resolver)
56 | }
57 |
58 | /// Update ResolvedViewController
59 | /// - Parameters:
60 | /// - uiViewController: The ResolvedViewController
61 | /// - context: The Context
62 | func updateUIViewController(
63 | _ content: Content,
64 | context: Context
65 | ) {
66 | content.resolver = self.resolver
67 | }
68 |
69 | }
70 |
71 | // MARK: - ViewControllerResolver+Content
72 |
73 | private extension ViewControllerResolver {
74 |
75 | /// The ViewControllerResolver Content
76 | final class Content: UIViewController {
77 |
78 | // MARK: Properties
79 |
80 | /// The Resolver
81 | var resolver: Resolver
82 |
83 | // MARK: Initializer
84 |
85 | /// Creates a new instance of `ViewControllerResolver.Content`
86 | /// - Parameter onResolve: The Resolver
87 | init(
88 | resolver: @escaping Resolver
89 | ) {
90 | self.resolver = resolver
91 | super.init(nibName: nil, bundle: nil)
92 | }
93 |
94 | /// Initializer with NSCoder is unavailable
95 | @available(*, unavailable)
96 | required init?(
97 | coder aDecoder: NSCoder
98 | ) { nil }
99 |
100 | // MARK: View-Lifecycle
101 |
102 | /// Did move to parent ViewController
103 | /// - Parameter parent: The parent ViewController
104 | override func didMove(
105 | toParent parent: UIViewController?
106 | ) {
107 | super.didMove(toParent: parent)
108 | parent.flatMap(self.resolver)
109 | }
110 | }
111 |
112 | }
113 | #endif
114 |
--------------------------------------------------------------------------------
/Sources/Extensions/Text+WhatsNewText.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - Text+init(whatsNewText:)
4 |
5 | extension Text {
6 |
7 | /// Creates a new instance of `Text` from a `WhatsNew.Text` instance
8 | /// - Parameter whatsNewText: The WhatsNew Text
9 | init(
10 | whatsNewText: WhatsNew.Text
11 | ) {
12 | // Check if iOS 15 or greater is available
13 | if #available(iOS 15.0, macOS 12.0, visionOS 1.0, *) {
14 | // Initialize with AttributedString
15 | self.init(
16 | AttributedString(
17 | whatsNewText.attributedString
18 | )
19 | )
20 | } else {
21 | // Initialize with raw string value
22 | self.init(
23 | verbatim: whatsNewText.attributedString.string
24 | )
25 | }
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/Extensions/UIVisualEffectView+Representable.swift:
--------------------------------------------------------------------------------
1 | #if os(iOS)
2 | import SwiftUI
3 |
4 | // MARK: - UIVisualEffectView+Representable
5 |
6 | extension UIVisualEffectView {
7 |
8 | /// A UIVisualEffect SwiftUI Representable View
9 | struct Representable: UIViewRepresentable {
10 |
11 | // MARK: Properties
12 |
13 | /// The UIVisualEffect. Default value `UIBlurEffect(style: .regular)`
14 | var effect: UIVisualEffect = UIBlurEffect(style: .regular)
15 |
16 | // MARK: UIViewRepresentable
17 |
18 | /// Make UIVisualEffectView
19 | /// - Parameter context: The Context
20 | func makeUIView(
21 | context: Context
22 | ) -> UIVisualEffectView {
23 | .init(
24 | effect: self.effect
25 | )
26 | }
27 |
28 | /// Update UIVisualEffectView
29 | /// - Parameters:
30 | /// - visualEffectView: The UIVisualEffectView
31 | /// - context: The Context
32 | func updateUIView(
33 | _ visualEffectView: UIVisualEffectView,
34 | context: Context
35 | ) {
36 | visualEffectView.effect = self.effect
37 | }
38 |
39 | }
40 |
41 | }
42 | #endif
43 |
--------------------------------------------------------------------------------
/Sources/Extensions/View+WhatsNewSheet.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - View+sheet(whatsNew:)
4 |
5 | public extension View {
6 |
7 | /// Presents a WhatsNewView using the given WhatsNew object as a data source for the sheet’s content.
8 | /// - Parameters:
9 | /// - whatsNew: A Binding to an optional WhatsNew object
10 | /// - versionStore: The optional WhatsNewVersionStore. Default value `nil`
11 | /// - layout: The WhatsNew Layout. Default value `.default`
12 | /// - onDismiss: The closure to execute when dismissing the sheet. Default value `nil`
13 | func sheet(
14 | whatsNew: Binding,
15 | versionStore: WhatsNewVersionStore? = nil,
16 | layout: WhatsNew.Layout = .default,
17 | onDismiss: (() -> Void)? = nil
18 | ) -> some View {
19 | self.modifier(
20 | ManualWhatsNewSheetViewModifier(
21 | whatsNew: whatsNew,
22 | versionStore: versionStore,
23 | layout: layout,
24 | onDismiss: onDismiss
25 | )
26 | )
27 | }
28 |
29 | }
30 |
31 | // MARK: - ManualWhatsNewSheetViewModifier
32 |
33 | /// A Manual WhatsNew Sheet ViewModifier
34 | private struct ManualWhatsNewSheetViewModifier: ViewModifier {
35 |
36 | // MARK: Properties
37 |
38 | /// A Binding to an optional WhatsNew object
39 | let whatsNew: Binding
40 |
41 | /// The optional WhatsNewVersionStore
42 | let versionStore: WhatsNewVersionStore?
43 |
44 | /// The WhatsNew Layout
45 | let layout: WhatsNew.Layout
46 |
47 | /// The closure to execute when dismissing the sheet
48 | let onDismiss: (() -> Void)?
49 |
50 | // MARK: ViewModifier
51 |
52 | /// Gets the current body of the caller.
53 | /// - Parameter content: The Content
54 | func body(
55 | content: Content
56 | ) -> some View {
57 | // Check if a WhatsNew object is available
58 | if let whatsNew = self.whatsNew.wrappedValue {
59 | // Check if the WhatsNew Version has already been presented
60 | if self.versionStore?.hasPresented(whatsNew.version) == true {
61 | // Show content
62 | content
63 | } else {
64 | // Show WhatsNew Sheet
65 | content.sheet(
66 | item: self.whatsNew,
67 | onDismiss: self.onDismiss
68 | ) { whatsNew in
69 | WhatsNewView(
70 | whatsNew: whatsNew,
71 | versionStore: self.versionStore,
72 | layout: self.layout
73 | )
74 | }
75 | }
76 | } else {
77 | // Otherwise show content
78 | content
79 | }
80 | }
81 |
82 | }
83 |
84 | // MARK: - View+whatsNewSheet()
85 |
86 | public extension View {
87 |
88 | /// Auto-Presents a WhatsNewView to the user if needed based on the `WhatsNewEnvironment`
89 | /// - Parameters:
90 | /// - layout: The optional custom WhatsNew Layout. Default value `nil`
91 | /// - onDismiss: The closure to execute when dismissing the sheet. Default value `nil`
92 | func whatsNewSheet(
93 | layout: WhatsNew.Layout? = nil,
94 | onDismiss: (() -> Void)? = nil
95 | ) -> some View {
96 | self.modifier(
97 | AutomaticWhatsNewSheetViewModifier(
98 | layout: layout,
99 | onDismiss: onDismiss
100 | )
101 | )
102 | }
103 |
104 | }
105 |
106 | // MARK: - WhatsNewSheetViewModifier
107 |
108 | /// A Automatic WhatsNew Sheet ViewModifier
109 | private struct AutomaticWhatsNewSheetViewModifier: ViewModifier {
110 |
111 | // MARK: Properties
112 |
113 | /// The optional WhatsNew Layout
114 | let layout: WhatsNew.Layout?
115 |
116 | /// The optional closure to execute when dismissing the sheet
117 | let onDismiss: (() -> Void)?
118 |
119 | /// Bool value if sheet is dismissed
120 | @State
121 | private var isDismissed: Bool?
122 |
123 | /// The WhatsNewEnvironment
124 | @Environment(\.whatsNew)
125 | private var whatsNewEnvironment
126 |
127 | // MARK: ViewModifier
128 |
129 | /// Gets the current body of the caller.
130 | /// - Parameter content: The Content
131 | func body(
132 | content: Content
133 | ) -> some View {
134 | content.sheet(
135 | item: .init(
136 | get: {
137 | self.isDismissed == true
138 | ? nil
139 | : self.whatsNewEnvironment.whatsNew()
140 | },
141 | set: {
142 | self.isDismissed = $0 == nil
143 | }
144 | ),
145 | onDismiss: self.onDismiss
146 | ) { whatsNew in
147 | WhatsNewView(
148 | whatsNew: whatsNew,
149 | versionStore: self.whatsNewEnvironment.whatsNewVersionStore,
150 | layout: self.layout ?? self.whatsNewEnvironment.defaultLayout
151 | )
152 | }
153 | }
154 |
155 | }
156 |
--------------------------------------------------------------------------------
/Sources/Extensions/WhatsNew+Version+Key.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNew.Version+key
4 |
5 | extension WhatsNew.Version {
6 |
7 | /// The WhatsNew Version Key prefix
8 | static let keyPrefix = "WhatsNewKit"
9 |
10 | /// A WhatsNew Version Key the can be used to save
11 | /// a WhatsNew Version to the `UserDefaults` or `NSUbiquitousKeyValueStore`
12 | var key: String {
13 | [
14 | Self.keyPrefix,
15 | self.description
16 | ]
17 | .joined(separator: ".")
18 | }
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+Feature+Image.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+Item+Image
4 |
5 | public extension WhatsNew.Feature {
6 |
7 | /// A WhatsNew Feature Image
8 | struct Image {
9 |
10 | // MARK: Properties
11 |
12 | /// A closure that produces the Image View
13 | public let view: () -> AnyView
14 |
15 | // MARK: Initializer
16 |
17 | /// Creates a new instance of `WhatsNew.Feature.Image`
18 | /// - Parameters:
19 | /// - image: A ViewBuilder closure that produces an Image View
20 | public init(
21 | @ViewBuilder
22 | image: @escaping () -> Image
23 | ) {
24 | self.view = { .init(image()) }
25 | }
26 |
27 | }
28 |
29 | }
30 |
31 | // MARK: - Image+init(image:)
32 |
33 | public extension WhatsNew.Feature.Image {
34 |
35 | /// Creates a new instance of `WhatsNew.Feature.Image`
36 | /// - Parameter image: The Image
37 | init(
38 | image: Image
39 | ) {
40 | self.init { image }
41 | }
42 |
43 | }
44 |
45 | // MARK: - Image+init(name:)
46 |
47 | public extension WhatsNew.Feature.Image {
48 |
49 | /// Creates a new instance of `WhatsNew.Feature.Image`
50 | /// - Parameters:
51 | /// - name: The name of the image resource to lookup
52 | /// - bundle: The bundle to search for the image resource. Default value `.main`
53 | /// - renderingMode: The mode SwiftUI uses to render images. Default value `.template`
54 | /// - foregroundColor: The foreground color to use when displaying this view. Default value `.accentColor`
55 | init(
56 | name: String,
57 | bundle: Bundle = .main,
58 | renderingMode: Image.TemplateRenderingMode? = .template,
59 | foregroundColor: Color? = .accentColor
60 | ) {
61 | self.init {
62 | Image(
63 | name,
64 | bundle: bundle
65 | )
66 | .renderingMode(renderingMode)
67 | .font(.title)
68 | .imageScale(.large)
69 | .foregroundColor(foregroundColor)
70 | }
71 | }
72 |
73 | }
74 |
75 | // MARK: - Image+init(systemName:)
76 |
77 | public extension WhatsNew.Feature.Image {
78 |
79 | /// Creates a new instance of `WhatsNew.Feature.Image`
80 | /// - Parameters:
81 | /// - systemName: The name of the system symbol image
82 | /// - renderingMode: The mode SwiftUI uses to render images. Default value `.template`
83 | /// - foregroundColor: The foreground color to use when displaying this view. Default value `.accentColor`
84 | init(
85 | systemName: String,
86 | renderingMode: Image.TemplateRenderingMode? = .template,
87 | foregroundColor: Color? = .accentColor
88 | ) {
89 | self.init {
90 | Image(
91 | systemName: systemName
92 | )
93 | .renderingMode(renderingMode)
94 | .font(.title)
95 | .imageScale(.large)
96 | .foregroundColor(foregroundColor)
97 | }
98 | }
99 |
100 | /// Creates a new instance of `WhatsNew.Feature.Image`
101 | /// - Parameters:
102 | /// - systemName: The name of the system symbol image
103 | /// - renderingMode: The mode SwiftUI uses to render images. Default value `.template`
104 | /// - symbolRenderingMode: The symbol rendering mode to use
105 | /// - foregroundColor: The foreground color to use when displaying this view. Default value `.accentColor`
106 | @available(iOS 15.0, macOS 12.0, *)
107 | init(
108 | systemName: String,
109 | renderingMode: Image.TemplateRenderingMode? = .template,
110 | symbolRenderingMode: SymbolRenderingMode?,
111 | foregroundColor: Color? = .accentColor
112 | ) {
113 | self.init {
114 | Image(
115 | systemName: systemName
116 | )
117 | .renderingMode(renderingMode)
118 | .symbolRenderingMode(symbolRenderingMode)
119 | .font(.title)
120 | .imageScale(.large)
121 | .foregroundColor(foregroundColor)
122 | }
123 | }
124 |
125 | }
126 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+Feature.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+Feature
4 |
5 | public extension WhatsNew {
6 |
7 | /// A WhatsNew Feature
8 | struct Feature {
9 |
10 | // MARK: Properties
11 |
12 | /// The image
13 | public var image: Image
14 |
15 | /// The title Text
16 | public var title: Text
17 |
18 | /// The subtitle Text
19 | public var subtitle: Text
20 |
21 | // MARK: Initializer
22 |
23 | /// Creates a new instance of `WhatsNew.Feature`
24 | /// - Parameters:
25 | /// - image: The image
26 | /// - title: The title Text
27 | /// - subtitle: The subtitle Text
28 | public init(
29 | image: Image,
30 | title: Text,
31 | subtitle: Text
32 | ) {
33 | self.image = image
34 | self.title = title
35 | self.subtitle = subtitle
36 | }
37 |
38 | }
39 |
40 | }
41 |
42 | // MARK: - Feature+Equatable
43 |
44 | extension WhatsNew.Feature: Equatable {
45 |
46 | /// Returns a Boolean value indicating whether two values are equal.
47 | /// - Parameters:
48 | /// - lhs: A value to compare.
49 | /// - rhs: Another value to compare.
50 | public static func == (
51 | lhs: Self,
52 | rhs: Self
53 | ) -> Bool {
54 | lhs.title == rhs.title
55 | && lhs.subtitle == rhs.subtitle
56 | }
57 |
58 | }
59 |
60 | // MARK: - Feature+Hashable
61 |
62 | extension WhatsNew.Feature: Hashable {
63 |
64 | /// Hashes the essential components of this value by feeding them into the given hasher.
65 | /// - Parameter hasher: The hasher to use when combining the components of this instance.
66 | public func hash(
67 | into hasher: inout Hasher
68 | ) {
69 | hasher.combine(self.title)
70 | hasher.combine(self.subtitle)
71 | }
72 |
73 | }
74 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+HapticFeedback.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | #if os(iOS)
3 | import UIKit
4 | #endif
5 |
6 | // MARK: - WhatsNew+HapticFeedback
7 |
8 | public extension WhatsNew {
9 |
10 | /// The WhatsNew HapticFeedback
11 | enum HapticFeedback: Hashable {
12 | #if os(iOS) && !os(visionOS)
13 | /// Impact HapticFeedback
14 | case impact(
15 | style: UIImpactFeedbackGenerator.FeedbackStyle? = nil,
16 | intensity: CGFloat? = nil
17 | )
18 | /// Selection HapticFeedback
19 | case selection
20 | /// Notification HapticFeedback
21 | case notification(
22 | UINotificationFeedbackGenerator.FeedbackType = .success
23 | )
24 | #endif
25 | }
26 |
27 | }
28 |
29 | // MARK: - Call-as-Function
30 |
31 | public extension WhatsNew.HapticFeedback {
32 |
33 | /// Call HapticFeedback as function to execute the HapticFeedback
34 | func callAsFunction() {
35 | #if os(iOS) && !os(visionOS)
36 | switch self {
37 | case .impact(let style, let intensity):
38 | let feedbackGenerator = style.flatMap(UIImpactFeedbackGenerator.init) ?? .init()
39 | if let intensity = intensity {
40 | feedbackGenerator.impactOccurred(intensity: intensity)
41 | } else {
42 | feedbackGenerator.impactOccurred()
43 | }
44 | case .selection:
45 | UISelectionFeedbackGenerator().selectionChanged()
46 | case .notification(let type):
47 | UINotificationFeedbackGenerator().notificationOccurred(type)
48 | }
49 | #endif
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+Layout.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+Layout
4 |
5 | public extension WhatsNew {
6 |
7 | /// The WhatsNew Layout
8 | struct Layout {
9 |
10 | // MARK: Properties
11 |
12 | /// A Boolean value if the scroll view indicator should be visible
13 | public var showsScrollViewIndicators: Bool
14 |
15 | /// The scroll view bottom content inset
16 | public var scrollViewBottomContentInset: CGFloat
17 |
18 | /// The content spacing
19 | public var contentSpacing: CGFloat
20 |
21 | /// The content padding
22 | public var contentPadding: EdgeInsets
23 |
24 | /// The feature list spacing
25 | public var featureListSpacing: CGFloat
26 |
27 | /// The feature list padding
28 | public var featureListPadding: EdgeInsets
29 |
30 | /// The feature image width
31 | public var featureImageWidth: CGFloat
32 |
33 | /// The feature horizontal spacing
34 | public var featureHorizontalSpacing: CGFloat
35 |
36 | /// The feature horizontal alignment
37 | public var featureHorizontalAlignment: VerticalAlignment
38 |
39 | /// The feature vertical spacing
40 | public var featureVerticalSpacing: CGFloat
41 |
42 | /// The footer action spacing
43 | public var footerActionSpacing: CGFloat
44 |
45 | /// The corner radius of the primary action button
46 | public var footerPrimaryActionButtonCornerRadius: CGFloat
47 |
48 | /// The footer visual effect view padding
49 | public var footerVisualEffectViewPadding: EdgeInsets
50 |
51 | // MARK: Initializer
52 |
53 | /// Creates a new instance of `WhatsNew.Layout`
54 | /// - Parameters:
55 | /// - showsScrollViewIndicators: A Boolean value if the scroll view indicator should be visible. Default value `false`
56 | /// - scrollViewBottomContentInset: The scroll view bottom content inset. Default value `150`
57 | /// - contentSpacing: The content spacing. Default value `60`
58 | /// - contentPadding: The content padding. Default value `top: 65`
59 | /// - featureListSpacing: The feature list spacing. Default value `25`
60 | /// - featureListPadding: The feature list padding. Default value `leading: 15`
61 | /// - featureImageWidth: The feature image width. Default value `40`
62 | /// - featureHorizontalSpacing: The feature horizontal spacing. Default value `15`
63 | /// - featureVerticalSpacing: The feature vertical spacing. Default value `2`
64 | /// - footerActionSpacing: The footer action spacing. Default value `15`
65 | /// - footerPrimaryActionButtonCornerRadius: The corner radius of the primary action button. Default value `14`
66 | /// - footerVisualEffectViewPadding: The footer visual effect view padding. Default value `top: -10`
67 | public init(
68 | showsScrollViewIndicators: Bool = false,
69 | scrollViewBottomContentInset: CGFloat = 150,
70 | contentSpacing: CGFloat = 60,
71 | contentPadding: EdgeInsets = .init(top: 65, leading: 0, bottom: 0, trailing: 0),
72 | featureListSpacing: CGFloat = 25,
73 | featureListPadding: EdgeInsets = .init(top: 0, leading: 15, bottom: 0, trailing: 0),
74 | featureImageWidth: CGFloat = 40,
75 | featureHorizontalSpacing: CGFloat = 15,
76 | featureHorizontalAlignment: VerticalAlignment = .center,
77 | featureVerticalSpacing: CGFloat = 2,
78 | footerActionSpacing: CGFloat = 15,
79 | footerPrimaryActionButtonCornerRadius: CGFloat = 14,
80 | footerVisualEffectViewPadding: EdgeInsets = .init(top: -10, leading: 0, bottom: 0, trailing: 0)
81 | ) {
82 | self.showsScrollViewIndicators = showsScrollViewIndicators
83 | self.scrollViewBottomContentInset = scrollViewBottomContentInset
84 | self.contentSpacing = contentSpacing
85 | self.contentPadding = contentPadding
86 | self.featureListSpacing = featureListSpacing
87 | self.featureListPadding = featureListPadding
88 | self.featureImageWidth = featureImageWidth
89 | self.featureHorizontalSpacing = featureHorizontalSpacing
90 | self.featureHorizontalAlignment = featureHorizontalAlignment
91 | self.featureVerticalSpacing = featureVerticalSpacing
92 | self.footerActionSpacing = footerActionSpacing
93 | self.footerPrimaryActionButtonCornerRadius = footerPrimaryActionButtonCornerRadius
94 | self.footerVisualEffectViewPadding = footerVisualEffectViewPadding
95 | }
96 |
97 | }
98 |
99 | }
100 |
101 | // MARK: - Layout+default
102 |
103 | public extension WhatsNew.Layout {
104 |
105 | /// The mutable default Layout
106 | static var `default` = Self()
107 |
108 | }
109 |
110 | // MARK: - Layout+reset
111 |
112 | public extension WhatsNew.Layout {
113 |
114 | /// Reset the Layout to default values
115 | mutating func reset() {
116 | self = .init()
117 | }
118 |
119 | }
120 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+PrimaryAction.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+PrimaryAction
4 |
5 | public extension WhatsNew {
6 |
7 | /// The WhatsNew PrimaryAction
8 | struct PrimaryAction {
9 |
10 | // MARK: Properties
11 |
12 | /// The title Text
13 | public var title: Text
14 |
15 | /// The background color
16 | public var backgroundColor: Color
17 |
18 | /// The foreground color
19 | public var foregroundColor: Color
20 |
21 | /// The optional HapticFeedback
22 | public var hapticFeedback: HapticFeedback?
23 |
24 | /// The optional on dismiss closure
25 | public var onDismiss: (() -> Void)?
26 |
27 | // MARK: Initializer
28 |
29 | /// Creates a new instance of `WhatsNew.PrimaryAction`
30 | /// - Parameters:
31 | /// - title: The title Text. Default value `Continue`
32 | /// - backgroundColor: The background color. Default value `.accentColor`
33 | /// - foregroundColor: The foreground color. Default value `.white`
34 | /// - hapticFeedback: The optional HapticFeedback. Default value `nil`
35 | /// - onDismiss: The optional on dismiss closure. Default value `nil`
36 | public init(
37 | title: Text = "Continue",
38 | backgroundColor: Color = .accentColor,
39 | foregroundColor: Color = .white,
40 | hapticFeedback: HapticFeedback? = nil,
41 | onDismiss: (() -> Void)? = nil
42 | ) {
43 | self.title = title
44 | self.backgroundColor = backgroundColor
45 | self.foregroundColor = foregroundColor
46 | self.hapticFeedback = hapticFeedback
47 | self.onDismiss = onDismiss
48 | }
49 |
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+SecondaryAction+Action.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - SecondaryAction+Action
4 |
5 | public extension WhatsNew.SecondaryAction {
6 |
7 | /// A WhatsNew Secondary Action
8 | enum Action {
9 | /// Present View
10 | case present(AnyView)
11 | /// Custom Action
12 | case custom((Binding) -> Void)
13 | }
14 |
15 | }
16 |
17 | // MARK: - Action+present
18 |
19 | public extension WhatsNew.SecondaryAction.Action {
20 |
21 | /// Present View on WhatsNewView
22 | /// - Parameters:
23 | /// - content: The ViewBuilder closure that produces the Content View
24 | static func present(
25 | @ViewBuilder
26 | _ content: () -> Content
27 | ) -> Self {
28 | .present(.init(content()))
29 | }
30 |
31 | }
32 |
33 | // MARK: - Action+dismiss
34 |
35 | public extension WhatsNew.SecondaryAction.Action {
36 |
37 | /// Dismiss WhatsNewView
38 | static let dismiss: Self = .custom { presentationMode in
39 | presentationMode.wrappedValue.dismiss()
40 | }
41 |
42 | }
43 |
44 | // MARK: - Action+openURL
45 |
46 | public extension WhatsNew.SecondaryAction.Action {
47 |
48 | /// Open a URL
49 | /// - Parameters:
50 | /// - url: The URL that should be opened
51 | /// - application: The UIApplication used to open the URL. Default value `.shared`
52 | static func openURL(
53 | _ url: URL?
54 | ) -> Self {
55 | .custom { _ in
56 | // Verify URL is available
57 | guard let url = url else {
58 | // Otherwise return out of function
59 | return
60 | }
61 | // Open URL
62 | #if os(macOS)
63 | NSWorkspace.shared.open(
64 | url
65 | )
66 | #else
67 | UIApplication.shared.open(
68 | url,
69 | options: .init()
70 | )
71 | #endif
72 | }
73 | }
74 |
75 | }
76 |
77 | // MARK: - Action+PresentedView
78 |
79 | extension WhatsNew.SecondaryAction.Action {
80 |
81 | /// The WhatsNew Secondary Action PresentedView
82 | struct PresentedView: Identifiable {
83 |
84 | /// The identifier
85 | var id: UUID = .init()
86 |
87 | /// The View
88 | let view: AnyView
89 |
90 | }
91 |
92 | }
93 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+SecondaryAction.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+SecondaryAction
4 |
5 | public extension WhatsNew {
6 |
7 | /// The WhatsNew SecondaryAction
8 | struct SecondaryAction {
9 |
10 | // MARK: Properties
11 |
12 | /// The title Text
13 | public var title: Text
14 |
15 | /// The foreground color
16 | public var foregroundColor: Color
17 |
18 | /// The optional HapticFeedback
19 | public var hapticFeedback: HapticFeedback?
20 |
21 | /// The Action
22 | public var action: Action
23 |
24 | // MARK: Initializer
25 |
26 | /// Creates a new instance of `WhatsNew.PrimaryAction`
27 | /// - Parameters:
28 | /// - title: The title Text
29 | /// - foregroundColor: The foreground color. Default value `.accentColor`
30 | /// - hapticFeedback: The optional HapticFeedback. Default value `nil`
31 | /// - action: The Action
32 | public init(
33 | title: Text,
34 | foregroundColor: Color = {
35 | #if os(visionOS)
36 | .primary
37 | #else
38 | .accentColor
39 | #endif
40 | }(),
41 | hapticFeedback: HapticFeedback? = nil,
42 | action: Action
43 | ) {
44 | self.title = title
45 | self.foregroundColor = foregroundColor
46 | self.hapticFeedback = hapticFeedback
47 | self.action = action
48 | }
49 |
50 | }
51 |
52 | }
53 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+Text.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+Text
4 |
5 | public extension WhatsNew {
6 |
7 | /// A WhatsNew Text
8 | struct Text: Hashable {
9 |
10 | // MARK: Properties
11 |
12 | /// The NSAttributedString
13 | public var attributedString: NSAttributedString
14 |
15 | // MARK: Initializer
16 |
17 | /// Creates a new instance of `WhatsNew.Text` from a given String
18 | /// - Parameter string: The String
19 | public init(
20 | _ string: String
21 | ) {
22 | self.attributedString = .init(string: string)
23 | }
24 |
25 | }
26 |
27 | }
28 |
29 | // MARK: - AttributedString Initializer
30 |
31 | @available(iOS 15.0, macOS 12.0, *)
32 | public extension WhatsNew.Text {
33 |
34 | /// Creates a new instance of `WhatsNew.Text` from a given NSAttributedString
35 | /// - Parameter attributedString: The NSAttributedString
36 | init(
37 | _ attributedString: NSAttributedString
38 | ) {
39 | self.attributedString = attributedString
40 | }
41 |
42 | /// Creates a new instance of `WhatsNew.Text` from a given AttributedString
43 | /// - Parameter attributedString: The AttributedString
44 | init(
45 | _ attributedString: AttributedString
46 | ) {
47 | self.attributedString = .init(attributedString)
48 | }
49 |
50 | }
51 |
52 | // MARK: - ExpressibleByStringLiteral
53 |
54 | extension WhatsNew.Text: ExpressibleByStringLiteral {
55 |
56 | /// Creates a new instance of `WhatsNew.Text` from a given String literal
57 | /// - Parameter value: The String literal
58 | public init(
59 | stringLiteral value: String
60 | ) {
61 | self.init(value)
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+Title.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNew+Title
4 |
5 | public extension WhatsNew {
6 |
7 | /// The WhatsNew Title
8 | struct Title: Hashable {
9 |
10 | // MARK: Properties
11 |
12 | /// The title Text
13 | public var text: Text
14 |
15 | /// The foreground color
16 | public var foregroundColor: Color
17 |
18 | // MARK: Initializer
19 |
20 | /// Creates a new instance of `WhatsNew.Title`
21 | /// - Parameters:
22 | /// - text: The title Text
23 | /// - foregroundColor: The foreground color. Default value `.primary`
24 | public init(
25 | text: Text,
26 | foregroundColor: Color = .primary
27 | ) {
28 | self.text = text
29 | self.foregroundColor = foregroundColor
30 | }
31 |
32 | }
33 |
34 | }
35 |
36 | // MARK: - ExpressibleByStringLiteral
37 |
38 | extension WhatsNew.Title: ExpressibleByStringLiteral {
39 |
40 | /// Creates a new instance of `WhatsNew.Title`
41 | /// - Parameter value: The String literal value
42 | public init(
43 | stringLiteral value: String
44 | ) {
45 | self.init(
46 | text: .init(value)
47 | )
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew+Version.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNew+Version
4 |
5 | public extension WhatsNew {
6 |
7 | /// A WhatsNew Version
8 | struct Version: Hashable {
9 |
10 | // MARK: Properties
11 |
12 | /// The major version
13 | public var major: Int
14 |
15 | /// The minor version
16 | public var minor: Int
17 |
18 | /// The patch version
19 | public var patch: Int
20 |
21 | // MARK: Initializer
22 |
23 | /// Creates a new instance of `WhatsNew.Version`
24 | /// - Parameters:
25 | /// - major: The major version
26 | /// - minor: The minor version
27 | /// - patch: The patch version
28 | public init(
29 | major: Int,
30 | minor: Int,
31 | patch: Int
32 | ) {
33 | self.major = major
34 | self.minor = minor
35 | self.patch = patch
36 | }
37 |
38 | }
39 |
40 | }
41 |
42 | // MARK: - Comparable
43 |
44 | extension WhatsNew.Version: Comparable {
45 |
46 | /// Returns a Boolean value indicating whether the value of the first
47 | /// argument is less than that of the second argument.
48 | /// - Parameters:
49 | /// - lhs: A value to compare.
50 | /// - rhs: Another value to compare.
51 | public static func < (
52 | lhs: Self,
53 | rhs: Self
54 | ) -> Bool {
55 | lhs.description.compare(rhs.description, options: .numeric) == .orderedAscending
56 | }
57 |
58 | }
59 |
60 | // MARK: - CustomStringConvertible
61 |
62 | extension WhatsNew.Version: CustomStringConvertible {
63 |
64 | /// A textual representation of this instance.
65 | public var description: String {
66 | [
67 | self.major,
68 | self.minor,
69 | self.patch
70 | ]
71 | .map(String.init)
72 | .joined(separator: ".")
73 | }
74 |
75 | }
76 |
77 | // MARK: - ExpressibleByStringLiteral
78 |
79 | extension WhatsNew.Version: ExpressibleByStringLiteral {
80 |
81 | /// Creates an instance initialized to the given string value.
82 | /// - Parameter value: The value of the new instance.
83 | public init(
84 | stringLiteral value: String
85 | ) {
86 | let components = value.components(separatedBy: ".").compactMap(Int.init)
87 | self.major = components.indices.contains(0) ? components[0] : 0
88 | self.minor = components.indices.contains(1) ? components[1] : 0
89 | self.patch = components.indices.contains(2) ? components[2] : 0
90 | }
91 |
92 | }
93 |
94 | // MARK: - Current
95 |
96 | public extension WhatsNew.Version {
97 |
98 | /// Retrieve current WhatsNew Version based on the current Version String in the Bundle
99 | /// - Parameter bundle: The Bundle. Default value `.main`
100 | /// - Returns: WhatsNew.Version
101 | static func current(
102 | in bundle: Bundle = .main
103 | ) -> WhatsNew.Version {
104 | // Retrieve Bundle short Version String
105 | let shortVersionString = bundle.infoDictionary?["CFBundleShortVersionString"] as? String
106 | // Return initialized Version via String Literal
107 | return .init(
108 | stringLiteral: shortVersionString ?? ""
109 | )
110 | }
111 |
112 | }
113 |
114 |
--------------------------------------------------------------------------------
/Sources/Models/WhatsNew.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNew
4 |
5 | /// A WhatsNew object
6 | public struct WhatsNew {
7 |
8 | // MARK: Properties
9 |
10 | /// The Version
11 | public var version: Version
12 |
13 | /// The Title
14 | public var title: Title
15 |
16 | /// The Features
17 | public var features: [Feature]
18 |
19 | /// The PrimaryAction
20 | public var primaryAction: PrimaryAction
21 |
22 | /// The optional SecondaryAction
23 | public var secondaryAction: SecondaryAction?
24 |
25 | // MARK: Initializer
26 |
27 | /// Creates a new instance of `WhatsNew`
28 | /// - Parameters:
29 | /// - version: The Version. Default value `.current()`
30 | /// - title: The Title
31 | /// - items: The Features
32 | /// - primaryAction: The PrimaryAction. Default value `.init()`
33 | /// - secondaryAction: The optional SecondaryAction. Default value `nil`
34 | public init(
35 | version: Version = .current(),
36 | title: Title,
37 | features: [Feature],
38 | primaryAction: PrimaryAction = .init(),
39 | secondaryAction: SecondaryAction? = nil
40 | ) {
41 | self.version = version
42 | self.title = title
43 | self.features = features
44 | self.primaryAction = primaryAction
45 | self.secondaryAction = secondaryAction
46 | }
47 |
48 | }
49 |
50 | // MARK: - Identifiable
51 |
52 | extension WhatsNew: Identifiable {
53 |
54 | /// The stable identity of the entity associated with this instance.
55 | public var id: Version {
56 | self.version
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/Sources/Resources/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyAccessedAPITypes
6 |
7 |
8 | NSPrivacyAccessedAPIType
9 | NSPrivacyAccessedAPICategoryUserDefaults
10 | NSPrivacyAccessedAPITypeReasons
11 |
12 | C56D.1
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/Sources/Store/InMemoryWhatsNewVersionStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - InMemoryWhatsNewVersionStore
4 |
5 | /// The InMemoryWhatsNewVersionStore
6 | public final class InMemoryWhatsNewVersionStore {
7 |
8 | // MARK: Static-Properties
9 |
10 | /// The shared `InMemoryWhatsNewVersionStore` instance
11 | public static let shared = InMemoryWhatsNewVersionStore()
12 |
13 | // MARK: Properties
14 |
15 | /// The Versions
16 | public var versions: Set
17 |
18 | // MARK: Initializer
19 |
20 | /// Creates a new instance of `InMemoryWhatsNewVersionStore`
21 | public init() {
22 | self.versions = .init()
23 | }
24 |
25 | }
26 |
27 | // MARK: - WriteableWhatsNewVersionStore
28 |
29 | extension InMemoryWhatsNewVersionStore: WriteableWhatsNewVersionStore {
30 |
31 | /// Save presented WhatsNew Version
32 | /// - Parameter version: The presented WhatsNew Version that should be saved
33 | public func save(
34 | presentedVersion version: WhatsNew.Version
35 | ) {
36 | self.versions.insert(version)
37 | }
38 |
39 | }
40 |
41 | // MARK: - ReadableWhatsNewVersionStore
42 |
43 | extension InMemoryWhatsNewVersionStore: ReadableWhatsNewVersionStore {
44 |
45 | /// The WhatsNew Versions that have been already been presented
46 | public var presentedVersions: [WhatsNew.Version] {
47 | .init(self.versions)
48 | }
49 |
50 | }
51 |
52 | // MARK: - Remove
53 |
54 | public extension InMemoryWhatsNewVersionStore {
55 |
56 | /// Remove presented WhatsNew Version
57 | /// - Parameter version: The presented WhatsNew Version that should be removed
58 | func remove(
59 | presentedVersion version: WhatsNew.Version
60 | ) {
61 | self.versions.remove(version)
62 | }
63 |
64 | }
65 |
66 | // MARK: - Remove all
67 |
68 | public extension InMemoryWhatsNewVersionStore {
69 |
70 | /// Remove all presented WhatsNew Versions
71 | func removeAll() {
72 | self.versions.removeAll()
73 | }
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/Store/NSUbiquitousKeyValueWhatsNewVersionStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// The CloudKitWhatsNewVersionStore typealias representing a NSUbiquitousKeyValueWhatsNewVersionStore
4 | public typealias CloudKitWhatsNewVersionStore = NSUbiquitousKeyValueWhatsNewVersionStore
5 |
6 | // MARK: - NSUbiquitousKeyValueWhatsNewVersionStore
7 |
8 | /// A NSUbiquitousKeyValue WhatsNewVersionStore
9 | public struct NSUbiquitousKeyValueWhatsNewVersionStore {
10 |
11 | // MARK: Properties
12 |
13 | /// The NSUbiquitousKeyValueStore
14 | private let ubiquitousKeyValueStore: NSUbiquitousKeyValueStore
15 |
16 | // MARK: Initializer
17 |
18 | /// Creates a new instance of `NSUbiquitousKeyValueWhatsNewVersionStore`
19 | /// - Parameters:
20 | /// - ubiquitousKeyValueStore: The NSUbiquitousKeyValueWhatsNewVersionStore. Default value `.default`
21 | public init(
22 | ubiquitousKeyValueStore: NSUbiquitousKeyValueStore = .default
23 | ) {
24 | self.ubiquitousKeyValueStore = ubiquitousKeyValueStore
25 | }
26 |
27 | }
28 |
29 | // MARK: - WriteableWhatsNewVersionStore
30 |
31 | extension NSUbiquitousKeyValueWhatsNewVersionStore: WriteableWhatsNewVersionStore {
32 |
33 | /// Save presented WhatsNew Version
34 | /// - Parameter version: The presented WhatsNew Version that should be saved
35 | public func save(
36 | presentedVersion version: WhatsNew.Version
37 | ) {
38 | self.ubiquitousKeyValueStore.set(
39 | version.description,
40 | forKey: version.key
41 | )
42 | }
43 |
44 | }
45 |
46 | // MARK: - ReadableWhatsNewVersionStore
47 |
48 | extension NSUbiquitousKeyValueWhatsNewVersionStore: ReadableWhatsNewVersionStore {
49 |
50 | /// The WhatsNew Versions that have been already been presented
51 | public var presentedVersions: [WhatsNew.Version] {
52 | self.ubiquitousKeyValueStore
53 | .dictionaryRepresentation
54 | .filter { $0.key.starts(with: WhatsNew.Version.keyPrefix) }
55 | .compactMap { $0.value as? String }
56 | .map(WhatsNew.Version.init)
57 | }
58 |
59 | }
60 |
61 | // MARK: - Remove
62 |
63 | public extension NSUbiquitousKeyValueWhatsNewVersionStore {
64 |
65 | /// Remove presented WhatsNew Version
66 | /// - Parameter version: The presented WhatsNew Version that should be removed
67 | func remove(
68 | presentedVersion version: WhatsNew.Version
69 | ) {
70 | self.ubiquitousKeyValueStore
71 | .removeObject(forKey: version.key)
72 | }
73 |
74 | }
75 |
76 | // MARK: - Remove all
77 |
78 | public extension NSUbiquitousKeyValueWhatsNewVersionStore {
79 |
80 | /// Remove all presented Versions
81 | func removeAll() {
82 | self.presentedVersions
83 | .map(\.key)
84 | .forEach(self.ubiquitousKeyValueStore.removeObject)
85 | }
86 |
87 | }
88 |
--------------------------------------------------------------------------------
/Sources/Store/UserDefaultsWhatsNewVersionStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - UserDefaultsWhatsNewVersionStore
4 |
5 | /// A UserDefaults WhatsNewVersionStore
6 | public struct UserDefaultsWhatsNewVersionStore {
7 |
8 | // MARK: Properties
9 |
10 | /// The UserDefaults
11 | private let userDefaults: UserDefaults
12 |
13 | // MARK: Initializer
14 |
15 | /// Creates a new instance of `UserDefaultsWhatsNewVersionStore`
16 | /// - Parameters:
17 | /// - userDefaults: The UserDefaults. Default value `.standard`
18 | public init(
19 | userDefaults: UserDefaults = .standard
20 | ) {
21 | self.userDefaults = userDefaults
22 | }
23 |
24 | }
25 |
26 | // MARK: - WriteableWhatsNewVersionStore
27 |
28 | extension UserDefaultsWhatsNewVersionStore: WriteableWhatsNewVersionStore {
29 |
30 | /// Save presented WhatsNew Version
31 | /// - Parameter version: The presented WhatsNew Version that should be saved
32 | public func save(
33 | presentedVersion version: WhatsNew.Version
34 | ) {
35 | self.userDefaults.set(
36 | version.description,
37 | forKey: version.key
38 | )
39 | }
40 |
41 | }
42 |
43 | // MARK: - ReadableWhatsNewVersionStore
44 |
45 | extension UserDefaultsWhatsNewVersionStore: ReadableWhatsNewVersionStore {
46 |
47 | /// The WhatsNew Versions that have been already been presented
48 | public var presentedVersions: [WhatsNew.Version] {
49 | self.userDefaults
50 | .dictionaryRepresentation()
51 | .filter { $0.key.starts(with: WhatsNew.Version.keyPrefix) }
52 | .compactMap { $0.value as? String }
53 | .map(WhatsNew.Version.init)
54 | }
55 |
56 | }
57 |
58 | // MARK: - Remove
59 |
60 | public extension UserDefaultsWhatsNewVersionStore {
61 |
62 | /// Remove presented WhatsNew Version
63 | /// - Parameter version: The presented WhatsNew Version that should be removed
64 | func remove(
65 | presentedVersion version: WhatsNew.Version
66 | ) {
67 | self.userDefaults
68 | .removeObject(forKey: version.key)
69 | }
70 |
71 | }
72 |
73 | // MARK: - Remove all
74 |
75 | public extension UserDefaultsWhatsNewVersionStore {
76 |
77 | /// Remove all presented Versions
78 | func removeAll() {
79 | self.presentedVersions
80 | .map(\.key)
81 | .forEach(self.userDefaults.removeObject)
82 | }
83 |
84 | }
85 |
--------------------------------------------------------------------------------
/Sources/Store/WhatsNewVersionStore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // MARK: - WhatsNewVersionStore
4 |
5 | /// A WhatsNewVersionStore
6 | public typealias WhatsNewVersionStore = WriteableWhatsNewVersionStore & ReadableWhatsNewVersionStore
7 |
8 | // MARK: - WriteableWhatsNewVersionStore
9 |
10 | /// A Writeable WhatsNewVersionStore
11 | public protocol WriteableWhatsNewVersionStore {
12 |
13 | /// Save presented WhatsNew Version
14 | /// - Parameter version: The presented WhatsNew Version that should be saved
15 | func save(
16 | presentedVersion version: WhatsNew.Version
17 | )
18 |
19 | }
20 |
21 | // MARK: - ReadableWhatsNewVersionStore
22 |
23 | /// A Readable WhatsNewVersionStore
24 | public protocol ReadableWhatsNewVersionStore {
25 |
26 | /// The WhatsNew Versions that have been already been presented
27 | var presentedVersions: [WhatsNew.Version] { get }
28 |
29 | }
30 |
31 | // MARK: - ReadableWhatsNewVersionStore+hasPresented
32 |
33 | public extension ReadableWhatsNewVersionStore {
34 |
35 | /// Retrieve a bool value if a given WhatsNew Version has already been presented
36 | /// - Parameter whatsNew: The WhatsNew Version to verify
37 | /// - Returns: A Bool value if the given WhatsNew Version has already been preseted
38 | func hasPresented(
39 | _ version: WhatsNew.Version
40 | ) -> Bool {
41 | self.presentedVersions.contains(version)
42 | }
43 |
44 | /// Retrieve a bool value if a given WhatsNew has already been presented
45 | /// - Parameter whatsNew: The WhatsNew to verify
46 | /// - Returns: A Bool value if the given WhatsNew has already been preseted
47 | func hasPresented(
48 | _ whatsNew: WhatsNew
49 | ) -> Bool {
50 | self.hasPresented(whatsNew.version)
51 | }
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/Sources/View/WhatsNewView+FeaturesPadding.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNewView+FeaturesPadding
4 |
5 | extension WhatsNewView {
6 |
7 | /// The WhatsNewView FeaturesPadding ViewModifier
8 | struct FeaturesPadding {
9 |
10 | #if os(iOS) || os(visionOS)
11 | /// The Horizontal SizeClass
12 | @Environment(\.horizontalSizeClass)
13 | private var horizontalSizeClass
14 |
15 | /// The Vertical SizeClass
16 | @Environment(\.verticalSizeClass)
17 | private var verticalSizeClass
18 | #endif
19 |
20 | }
21 |
22 | }
23 |
24 | // MARK: - ViewModifier
25 |
26 | extension WhatsNewView.FeaturesPadding: ViewModifier {
27 |
28 | /// Gets the current body of the caller.
29 | /// - Parameter content: The Content
30 | func body(
31 | content: Content
32 | ) -> some View {
33 | #if os(macOS)
34 | content.padding(.horizontal)
35 | #else
36 | if self.horizontalSizeClass == .regular {
37 | content.padding(
38 | .init(
39 | top: 0,
40 | leading: 100,
41 | bottom: 0,
42 | trailing: 100
43 | )
44 | )
45 | } else {
46 | content
47 | }
48 | #endif
49 | }
50 |
51 | }
52 |
--------------------------------------------------------------------------------
/Sources/View/WhatsNewView+FooterPadding.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNewView+FooterPadding
4 |
5 | extension WhatsNewView {
6 |
7 | /// The WhatsNewView FooterPadding ViewModifier
8 | struct FooterPadding {
9 |
10 | #if os(iOS) || os(visionOS)
11 | /// The Horizontal SizeClass
12 | @Environment(\.horizontalSizeClass)
13 | private var horizontalSizeClass
14 |
15 | /// The Vertical SizeClass
16 | @Environment(\.verticalSizeClass)
17 | private var verticalSizeClass
18 | #endif
19 |
20 | }
21 |
22 | }
23 |
24 | // MARK: - ViewModifier
25 |
26 | extension WhatsNewView.FooterPadding: ViewModifier {
27 |
28 | /// Gets the current body of the caller.
29 | /// - Parameter content: The Content
30 | func body(
31 | content: Content
32 | ) -> some View {
33 | #if os(macOS)
34 | content.padding(.bottom, 30)
35 | #else
36 | if self.horizontalSizeClass == .regular {
37 | content.padding(
38 | .init(
39 | top: 0,
40 | leading: 150,
41 | bottom: 50,
42 | trailing: 150
43 | )
44 | )
45 | } else if self.verticalSizeClass == .compact {
46 | content.padding(
47 | .init(
48 | top: 0,
49 | leading: 40,
50 | bottom: 35,
51 | trailing: 40
52 | )
53 | )
54 | } else {
55 | content.padding(
56 | .init(
57 | top: 0,
58 | leading: 20,
59 | bottom: 80,
60 | trailing: 20
61 | )
62 | )
63 | }
64 | #endif
65 | }
66 |
67 | }
68 |
--------------------------------------------------------------------------------
/Sources/View/WhatsNewView+PrimaryButtonStyle.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNewView+PrimaryButtonStyle
4 |
5 | extension WhatsNewView {
6 |
7 | /// The WhatsNewView PrimaryButtonStyle
8 | struct PrimaryButtonStyle {
9 |
10 | /// The WhatsNew PrimaryAction
11 | let primaryAction: WhatsNew.PrimaryAction
12 |
13 | /// The WhatsNew Layout
14 | let layout: WhatsNew.Layout
15 |
16 | }
17 |
18 | }
19 |
20 | // MARK: - ButtonStyle
21 |
22 | extension WhatsNewView.PrimaryButtonStyle: ButtonStyle {
23 |
24 | /// Creates a view that represents the body of a button.
25 | /// - Parameter configuration: The properties of the button.
26 | func makeBody(
27 | configuration: Configuration
28 | ) -> some View {
29 | Group {
30 | #if os(iOS)
31 | HStack {
32 | Spacer()
33 | configuration
34 | .label
35 | .font(.headline.weight(.semibold))
36 | .padding(.vertical)
37 | Spacer()
38 | }
39 | #else
40 | configuration
41 | .label
42 | .padding(.horizontal, 60)
43 | .padding(.vertical, 8)
44 | #endif
45 | }
46 | .foregroundColor(self.primaryAction.foregroundColor)
47 | .background(self.primaryAction.backgroundColor)
48 | .cornerRadius(self.layout.footerPrimaryActionButtonCornerRadius)
49 | .opacity(configuration.isPressed ? 0.5 : 1)
50 | }
51 |
52 | }
53 |
54 |
--------------------------------------------------------------------------------
/Sources/View/WhatsNewView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNewView
4 |
5 | /// A WhatsNewView
6 | public struct WhatsNewView {
7 |
8 | // MARK: Properties
9 |
10 | /// The WhatsNew object
11 | private let whatsNew: WhatsNew
12 |
13 | /// The WhatsNewVersionStore
14 | private let whatsNewVersionStore: WhatsNewVersionStore?
15 |
16 | /// The WhatsNew Layout
17 | private let layout: WhatsNew.Layout
18 |
19 | /// The View that is presented by the SecondaryAction
20 | @State
21 | private var secondaryActionPresentedView: WhatsNew.SecondaryAction.Action.PresentedView?
22 |
23 | /// The PresentationMode
24 | @Environment(\.presentationMode)
25 | private var presentationMode
26 |
27 | // MARK: Initializer
28 |
29 | /// Creates a new instance of `WhatsNewView`
30 | /// - Parameters:
31 | /// - whatsNew: The WhatsNew object
32 | /// - versionStore: The optional WhatsNewVersionStore. Default value `nil`
33 | /// - layout: The WhatsNew Layout. Default value `.default`
34 | public init(
35 | whatsNew: WhatsNew,
36 | versionStore: WhatsNewVersionStore? = nil,
37 | layout: WhatsNew.Layout = .default
38 | ) {
39 | self.whatsNew = whatsNew
40 | self.whatsNewVersionStore = versionStore
41 | self.layout = layout
42 | }
43 |
44 | }
45 |
46 | // MARK: - View
47 |
48 | extension WhatsNewView: View {
49 |
50 | /// The content and behavior of the view.
51 | public var body: some View {
52 | ZStack {
53 | // Content ScrollView
54 | ScrollView(
55 | .vertical,
56 | showsIndicators: self.layout.showsScrollViewIndicators
57 | ) {
58 | // Content Stack
59 | VStack(
60 | spacing: self.layout.contentSpacing
61 | ) {
62 | // Title
63 | self.title
64 | // Feature List
65 | VStack(
66 | alignment: .leading,
67 | spacing: self.layout.featureListSpacing
68 | ) {
69 | // Feature
70 | ForEach(
71 | self.whatsNew.features,
72 | id: \.self,
73 | content: self.feature
74 | )
75 | }
76 | .modifier(FeaturesPadding())
77 | .padding(self.layout.featureListPadding)
78 | }
79 | .padding(.horizontal)
80 | .padding(self.layout.contentPadding)
81 | // ScrollView bottom content inset
82 | Color.clear
83 | .padding(
84 | .bottom,
85 | self.layout.scrollViewBottomContentInset
86 | )
87 | }
88 | #if os(iOS)
89 | .alwaysBounceVertical(false)
90 | #endif
91 | // Footer
92 | VStack {
93 | Spacer()
94 | self.footer
95 | .modifier(FooterPadding())
96 | #if os(iOS)
97 | .background(
98 | UIVisualEffectView
99 | .Representable()
100 | .edgesIgnoringSafeArea(.horizontal)
101 | .padding(self.layout.footerVisualEffectViewPadding)
102 | )
103 | #endif
104 | }
105 | .edgesIgnoringSafeArea(.bottom)
106 | }
107 | .sheet(
108 | item: self.$secondaryActionPresentedView,
109 | content: { $0.view }
110 | )
111 | .onDisappear {
112 | // Save presented WhatsNew Version, if available
113 | self.whatsNewVersionStore?.save(
114 | presentedVersion: self.whatsNew.version
115 | )
116 | }
117 | }
118 |
119 | }
120 |
121 | // MARK: - Title
122 |
123 | private extension WhatsNewView {
124 |
125 | /// The Title View
126 | var title: some View {
127 | Text(
128 | whatsNewText: self.whatsNew.title.text
129 | )
130 | .font(.largeTitle.bold())
131 | .multilineTextAlignment(.center)
132 | .fixedSize(horizontal: false, vertical: true)
133 | }
134 |
135 | }
136 |
137 | // MARK: - Feature
138 |
139 | private extension WhatsNewView {
140 |
141 | /// The Feature View
142 | /// - Parameter feature: A WhatsNew Feature
143 | func feature(
144 | _ feature: WhatsNew.Feature
145 | ) -> some View {
146 | HStack(
147 | alignment: self.layout.featureHorizontalAlignment,
148 | spacing: self.layout.featureHorizontalSpacing
149 | ) {
150 | feature
151 | .image
152 | .view()
153 | .frame(width: self.layout.featureImageWidth)
154 | VStack(
155 | alignment: .leading,
156 | spacing: self.layout.featureVerticalSpacing
157 | ) {
158 | Text(
159 | whatsNewText: feature.title
160 | )
161 | .font(.subheadline.weight(.semibold))
162 | .foregroundColor(.primary)
163 | .fixedSize(horizontal: false, vertical: true)
164 | Text(
165 | whatsNewText: feature.subtitle
166 | )
167 | .font(.subheadline)
168 | .foregroundColor(.secondary)
169 | .fixedSize(horizontal: false, vertical: true)
170 | }
171 | .multilineTextAlignment(.leading)
172 | }.accessibilityElement(children: .combine)
173 | }
174 |
175 | }
176 |
177 | // MARK: - Footer
178 |
179 | private extension WhatsNewView {
180 |
181 | /// The Footer View
182 | var footer: some View {
183 | VStack(
184 | spacing: self.layout.footerActionSpacing
185 | ) {
186 | // Check if a secondary action is available
187 | if let secondaryAction = self.whatsNew.secondaryAction {
188 | // Secondary Action Button
189 | Button(
190 | action: {
191 | // Invoke HapticFeedback, if available
192 | secondaryAction.hapticFeedback?()
193 | // Switch on Action
194 | switch secondaryAction.action {
195 | case .present(let view):
196 | // Set secondary action presented view
197 | self.secondaryActionPresentedView = .init(view: view)
198 | case .custom(let action):
199 | // Invoke action with PresentationMode
200 | action(self.presentationMode)
201 | }
202 | }
203 | ) {
204 | Text(
205 | whatsNewText: secondaryAction.title
206 | )
207 | }
208 | #if os(macOS)
209 | .buttonStyle(
210 | PlainButtonStyle()
211 | )
212 | #endif
213 | .foregroundColor(secondaryAction.foregroundColor)
214 | }
215 | // Primary Action Button
216 | Button(
217 | action: {
218 | // Invoke HapticFeedback, if available
219 | self.whatsNew.primaryAction.hapticFeedback?()
220 | // Dismiss
221 | self.presentationMode.wrappedValue.dismiss()
222 | // Invoke on dismiss, if available
223 | self.whatsNew.primaryAction.onDismiss?()
224 | }
225 | ) {
226 | Text(
227 | whatsNewText: self.whatsNew.primaryAction.title
228 | )
229 | }
230 | .buttonStyle(
231 | PrimaryButtonStyle(
232 | primaryAction: self.whatsNew.primaryAction,
233 | layout: self.layout
234 | )
235 | )
236 | #if os(macOS)
237 | .keyboardShortcut(.defaultAction)
238 | #endif
239 | }
240 | }
241 |
242 | }
243 |
--------------------------------------------------------------------------------
/Sources/ViewController/WhatsNewViewController.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | // MARK: - WhatsNewHostingController
4 |
5 | #if os(macOS)
6 | /// A WhatsNewHostingController
7 | public typealias WhatsNewHostingController = NSHostingController
8 | #else
9 | /// A WhatsNewHostingController
10 | public typealias WhatsNewHostingController = UIHostingController
11 | #endif
12 |
13 | // MARK: - WhatsNewViewController
14 |
15 | /// A WhatsNew UIViewController
16 | open class WhatsNewViewController: WhatsNewHostingController {
17 |
18 | /// Creates a new instance of `WhatsNewViewController`
19 | /// - Parameters:
20 | /// - whatsNew: The WhatsNew object
21 | /// - layout: The WhatsNew Layout. Default value `.default`
22 | public init(
23 | whatsNew: WhatsNew,
24 | layout: WhatsNew.Layout = .default
25 | ) {
26 | super.init(
27 | rootView: .init(
28 | whatsNew: whatsNew,
29 | layout: layout
30 | )
31 | )
32 | }
33 |
34 | /// Creates a new instance of `WhatsNewViewController`
35 | /// by using the provided `WhatsNewVersionStore` to verify that the
36 | /// version of the WhatsNew object has not already been presented to the user.
37 | /// If the version is contained in the provided `WhatsNewVersionStore` the initializer
38 | /// will return `nil`
39 | /// - Parameters:
40 | /// - whatsNew: The WhatsNew object
41 | /// - versionStore: The WhatsNewVersionStore
42 | /// - layout: The WhatsNew Layout. Default value `.default`
43 | public init?(
44 | whatsNew: WhatsNew,
45 | versionStore: WhatsNewVersionStore,
46 | layout: WhatsNew.Layout = .default
47 | ) {
48 | // Verify WhatsNew Version has not already been presented
49 | guard !versionStore.hasPresented(whatsNew) else {
50 | // Otherwise return nil as WhatsNew Version
51 | // has already been presented to the user
52 | return nil
53 | }
54 | super.init(
55 | rootView: .init(
56 | whatsNew: whatsNew,
57 | versionStore: versionStore,
58 | layout: layout
59 | )
60 | )
61 | }
62 |
63 | /// Initializer with NSCoder is unavailable.
64 | /// Please use `init(whatsNew:)` or `init?(whatsNew:versionStore:)`
65 | @available(*, unavailable)
66 | public required init?(
67 | coder aDecoder: NSCoder
68 | ) { nil }
69 |
70 | }
71 |
--------------------------------------------------------------------------------
/Tests/WhatsNewEnvironmentTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import WhatsNewKit
3 |
4 | // MARK: - WhatsNewEnvironmentTests
5 |
6 | /// The WhatsNewEnvironmentTests
7 | final class WhatsNewEnvironmentTests: WhatsNewKitTestCase {
8 |
9 | func testInitial() {
10 | let version_1_0_0: WhatsNew.Version = "1.0.0"
11 | let whatsNew_1_0_0 = self.makeWhatsNew(from: version_1_0_0)
12 | let versionStore = InMemoryWhatsNewVersionStore()
13 | let environment = WhatsNewEnvironment(
14 | currentVersion: version_1_0_0,
15 | versionStore: versionStore,
16 | whatsNewCollection: [whatsNew_1_0_0]
17 | )
18 | XCTAssertEqual(
19 | version_1_0_0,
20 | environment.whatsNew()?.version
21 | )
22 | }
23 |
24 | func testInitialReopen() {
25 | let version_1_0_0: WhatsNew.Version = "1.0.0"
26 | let whatsNew_1_0_0 = self.makeWhatsNew(from: version_1_0_0)
27 | let versionStore = InMemoryWhatsNewVersionStore()
28 | versionStore.save(presentedVersion: version_1_0_0)
29 | let environment = WhatsNewEnvironment(
30 | currentVersion: version_1_0_0,
31 | versionStore: versionStore,
32 | whatsNewCollection: [whatsNew_1_0_0]
33 | )
34 | XCTAssertNil(
35 | environment.whatsNew()
36 | )
37 | }
38 |
39 | func testUpdate() {
40 | let version_1_0_0: WhatsNew.Version = "1.0.0"
41 | let whatsNew_1_0_0 = self.makeWhatsNew(from: version_1_0_0)
42 | let version_1_0_1: WhatsNew.Version = "1.0.1"
43 | let whatsNew_1_0_1 = self.makeWhatsNew(from: version_1_0_1)
44 | let versionStore = InMemoryWhatsNewVersionStore()
45 | versionStore.save(presentedVersion: version_1_0_0)
46 | let environment = WhatsNewEnvironment(
47 | currentVersion: version_1_0_1,
48 | versionStore: versionStore,
49 | whatsNewCollection: [
50 | whatsNew_1_0_0,
51 | whatsNew_1_0_1
52 | ]
53 | .shuffled()
54 | )
55 | XCTAssertEqual(
56 | version_1_0_1,
57 | environment.whatsNew()?.version
58 | )
59 | }
60 |
61 | func testInitialAfterUpdates() {
62 | let version_1_0_0: WhatsNew.Version = "1.0.0"
63 | let whatsNew_1_0_0 = self.makeWhatsNew(from: version_1_0_0)
64 | let version_1_1_0: WhatsNew.Version = "1.1.0"
65 | let whatsNew_1_1_0 = self.makeWhatsNew(from: version_1_1_0)
66 | let versionStore = InMemoryWhatsNewVersionStore()
67 | let environment = WhatsNewEnvironment(
68 | currentVersion: "1.1.1",
69 | versionStore: versionStore,
70 | whatsNewCollection: [
71 | whatsNew_1_0_0,
72 | whatsNew_1_1_0
73 | ]
74 | .shuffled()
75 | )
76 | XCTAssertEqual(
77 | version_1_1_0,
78 | environment.whatsNew()?.version
79 | )
80 | }
81 |
82 | func testInitialAfterUpdatesReopen() {
83 | let version_1_0_0: WhatsNew.Version = "1.0.0"
84 | let whatsNew_1_0_0 = self.makeWhatsNew(from: version_1_0_0)
85 | let version_1_1_0: WhatsNew.Version = "1.1.0"
86 | let whatsNew_1_1_0 = self.makeWhatsNew(from: version_1_1_0)
87 | let versionStore = InMemoryWhatsNewVersionStore()
88 | versionStore.save(presentedVersion: version_1_1_0)
89 | let environment = WhatsNewEnvironment(
90 | currentVersion: "1.1.1",
91 | versionStore: versionStore,
92 | whatsNewCollection: [
93 | whatsNew_1_0_0,
94 | whatsNew_1_1_0
95 | ]
96 | .shuffled()
97 | )
98 | XCTAssertNil(
99 | environment.whatsNew()
100 | )
101 | }
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/Tests/WhatsNewKitTestCase.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import WhatsNewKit
3 |
4 | // MARK: - WhatsNewKitTestCase
5 |
6 | /// The WhatsNewKitTestCase
7 | class WhatsNewKitTestCase: XCTestCase {
8 |
9 | // MARK: XCTestCase-Lifecycle
10 |
11 | /// Provides an opportunity to reset state before calling each test method in a test case.
12 | override func setUp() {
13 | super.setUp()
14 | // Disable continueAfterFailure
15 | self.continueAfterFailure = false
16 | }
17 |
18 | // MARK: Convenience Functions
19 |
20 | /// Make a random WhatsNew Version
21 | func makeRandomWhatsNewVersion() -> WhatsNew.Version {
22 | let randomVersionRange = 0...9
23 | return .init(
24 | major: .random(in: randomVersionRange),
25 | minor: .random(in: randomVersionRange),
26 | patch: .random(in: randomVersionRange)
27 | )
28 | }
29 |
30 | /// Create a WhatsNew instance from a given WhatsNew Version
31 | /// - Parameter version: The WhatsNew Version
32 | func makeWhatsNew(
33 | from version: WhatsNew.Version
34 | ) -> WhatsNew {
35 | .init(
36 | version: version,
37 | title: "",
38 | features: .init()
39 | )
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/Tests/WhatsNewVersionStoreTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import WhatsNewKit
3 |
4 | // MARK: - WhatsNewVersionStoreTests
5 |
6 | /// The WhatsNewVersionStoreTests
7 | final class WhatsNewVersionStoreTests: WhatsNewKitTestCase {
8 |
9 | func testInMemoryWhatsNewVersionStore() {
10 | let inMemoryWhatsNewVersionStore = InMemoryWhatsNewVersionStore()
11 | let version = self.executeVersionStoreTest(inMemoryWhatsNewVersionStore)
12 | XCTAssertEqual(
13 | [version],
14 | inMemoryWhatsNewVersionStore.versions
15 | )
16 | inMemoryWhatsNewVersionStore.removeAll()
17 | XCTAssert(
18 | inMemoryWhatsNewVersionStore.presentedVersions.isEmpty
19 | )
20 | XCTAssert(
21 | inMemoryWhatsNewVersionStore.versions.isEmpty
22 | )
23 | }
24 |
25 | func testUserDefaultsWhatsNewVersionStore() {
26 | final class FakeUserDefaults: UserDefaults {
27 | var store: [String: Any] = .init()
28 |
29 | override func set(_ value: Any?, forKey defaultName: String) {
30 | self.store[defaultName] = value
31 | }
32 |
33 | override func dictionaryRepresentation() -> [String: Any] {
34 | self.store
35 | }
36 | }
37 | let fakeUserDefaults = FakeUserDefaults()
38 | let userDefaultsWhatsNewVersionStore = UserDefaultsWhatsNewVersionStore(
39 | userDefaults: fakeUserDefaults
40 | )
41 | let version = self.executeVersionStoreTest(userDefaultsWhatsNewVersionStore)
42 | XCTAssertEqual(
43 | fakeUserDefaults.store.count,
44 | 1
45 | )
46 | XCTAssertEqual(
47 | version,
48 | (fakeUserDefaults.store[version.key] as? String).flatMap(WhatsNew.Version.init)
49 | )
50 | userDefaultsWhatsNewVersionStore.removeAll()
51 | XCTAssert(
52 | userDefaultsWhatsNewVersionStore.presentedVersions.isEmpty
53 | )
54 | XCTAssert(
55 | fakeUserDefaults.store.isEmpty
56 | )
57 | }
58 |
59 | func testNSUbiquitousKeyValueWhatsNewVersionStore() {
60 | final class FakeNSUbiquitousKeyValueStore: NSUbiquitousKeyValueStore {
61 | var store: [String: Any] = .init()
62 |
63 | override var dictionaryRepresentation: [String: Any] {
64 | self.store
65 | }
66 |
67 | override func set(_ value: Any?, forKey defaultName: String) {
68 | self.store[defaultName] = value
69 | }
70 |
71 | override func removeObject(forKey aKey: String) {
72 | self.store.removeValue(forKey: aKey)
73 | }
74 | }
75 | let fakeNSUbiquitousKeyValueStore = FakeNSUbiquitousKeyValueStore()
76 | let ubiquitousKeyValueWhatsNewVersionStore = NSUbiquitousKeyValueWhatsNewVersionStore(
77 | ubiquitousKeyValueStore: fakeNSUbiquitousKeyValueStore
78 | )
79 | let version = self.executeVersionStoreTest(ubiquitousKeyValueWhatsNewVersionStore)
80 | XCTAssertEqual(
81 | fakeNSUbiquitousKeyValueStore.store.count,
82 | 1
83 | )
84 | XCTAssertEqual(
85 | version,
86 | (fakeNSUbiquitousKeyValueStore.store[version.key] as? String).flatMap(WhatsNew.Version.init)
87 | )
88 | ubiquitousKeyValueWhatsNewVersionStore.removeAll()
89 | XCTAssert(
90 | ubiquitousKeyValueWhatsNewVersionStore.presentedVersions.isEmpty
91 | )
92 | XCTAssert(
93 | fakeNSUbiquitousKeyValueStore.store.isEmpty
94 | )
95 | }
96 |
97 | }
98 |
99 | private extension WhatsNewVersionStoreTests {
100 |
101 | func executeVersionStoreTest(
102 | _ versionStore: WhatsNewVersionStore
103 | ) -> WhatsNew.Version {
104 | let version = self.makeRandomWhatsNewVersion()
105 | XCTAssert(versionStore.presentedVersions.isEmpty)
106 | XCTAssertFalse(versionStore.hasPresented(version))
107 | versionStore.save(
108 | presentedVersion: version
109 | )
110 | XCTAssertEqual([version], versionStore.presentedVersions)
111 | XCTAssert(versionStore.hasPresented(version))
112 | return version
113 | }
114 |
115 | }
116 |
--------------------------------------------------------------------------------
/Tests/WhatsNewVersionTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import WhatsNewKit
3 |
4 | // MARK: - WhatsNewVersionTests
5 |
6 | /// The WhatsNewVersionTests
7 | final class WhatsNewVersionTests: WhatsNewKitTestCase {
8 |
9 | func testStringLiteral() {
10 | let whatsNewVersionString = "9.9.9"
11 | let whatsNewVersion = WhatsNew.Version(stringLiteral: whatsNewVersionString)
12 | XCTAssertEqual(
13 | whatsNewVersionString,
14 | whatsNewVersion.description
15 | )
16 | }
17 |
18 | func testBadStringLiteral() {
19 | let whatsNewVersionString = UUID().uuidString
20 | let whatsNewVersion = WhatsNew.Version(stringLiteral: whatsNewVersionString)
21 | XCTAssertEqual(
22 | "0.0.0",
23 | whatsNewVersion.description
24 | )
25 | }
26 |
27 | func testComparable() {
28 | let sortedVersions: [WhatsNew.Version] = [
29 | "1.0.0",
30 | "1.0.1",
31 | "1.1.1",
32 | "1.1.2",
33 | "1.2.0",
34 | "2.0.0",
35 | "2.0.1",
36 | "2.1.0"
37 | ]
38 | XCTAssertEqual(
39 | sortedVersions,
40 | sortedVersions.shuffled().sorted(by: <)
41 | )
42 | }
43 |
44 | func testCurrent() {
45 | class FakeBundle: Bundle {
46 | let shortVersionString: String
47 | init(shortVersionString: String) {
48 | self.shortVersionString = shortVersionString
49 | super.init()
50 | }
51 | override var infoDictionary: [String : Any]? {
52 | [
53 | "CFBundleShortVersionString": self.shortVersionString
54 | ]
55 | }
56 | }
57 | let version = self.makeRandomWhatsNewVersion()
58 | let fakeBundle = FakeBundle(shortVersionString: version.description)
59 | XCTAssertEqual(
60 | version,
61 | WhatsNew.Version.current(in: fakeBundle)
62 | )
63 | let fakeBundleEmptyVersion = FakeBundle(shortVersionString: "")
64 | XCTAssertEqual(
65 | WhatsNew.Version(major: 0, minor: 0, patch: 0),
66 | WhatsNew.Version.current(in: fakeBundleEmptyVersion)
67 | )
68 | }
69 |
70 | func testKeyPrefix() {
71 | XCTAssertEqual(
72 | "WhatsNewKit",
73 | WhatsNew.Version.keyPrefix
74 | )
75 | }
76 |
77 | func testKey() {
78 | let version = self.makeRandomWhatsNewVersion()
79 | XCTAssertEqual(
80 | "WhatsNewKit.\(version.description)",
81 | version.key
82 | )
83 | }
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/Tests/WhatsNewViewControllerTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import WhatsNewKit
3 |
4 | // MARK: - WhatsNewViewControllerTests
5 |
6 | /// The WhatsNewViewControllerTests
7 | final class WhatsNewViewControllerTests: WhatsNewKitTestCase {
8 |
9 | func testInitializer() {
10 | let versionStore = InMemoryWhatsNewVersionStore()
11 | let whatsNew = self.makeWhatsNew(from: self.makeRandomWhatsNewVersion())
12 | XCTAssertNotNil(
13 | WhatsNewViewController(
14 | whatsNew: whatsNew,
15 | versionStore: versionStore
16 | )
17 | )
18 | versionStore.save(presentedVersion: whatsNew.version)
19 | XCTAssertNil(
20 | WhatsNewViewController(
21 | whatsNew: whatsNew,
22 | versionStore: versionStore
23 | )
24 | )
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------