├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── ReleaseNotesKit
│ ├── Extensions
│ ├── Bundle+.swift
│ └── UIApplication+.swift
│ ├── Model
│ ├── ITunesLookup.swift
│ └── ReleaseNotesError.swift
│ ├── ReleaseNotesKit+.swift
│ ├── ReleaseNotesKit.swift
│ └── View
│ └── ReleaseNotesView.swift
├── Tests
└── ReleaseNotesKitTests
│ └── ReleaseNotesKitTests.swift
└── rnk-hero.png
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/config/registries.json
8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
9 | .netrc
10 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.6
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "ReleaseNotesKit",
8 | platforms: [
9 | .iOS(.v10),
10 | .macOS(.v11)
11 | ],
12 | products: [
13 | .library(
14 | name: "ReleaseNotesKit",
15 | targets: ["ReleaseNotesKit"]),
16 | ],
17 | targets: [
18 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
19 | // Targets can depend on other targets in this package, and on products in packages this package depends on.
20 | .target(
21 | name: "ReleaseNotesKit",
22 | dependencies: []),
23 | .testTarget(
24 | name: "ReleaseNotesKitTests",
25 | dependencies: ["ReleaseNotesKit"]),
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ReleaseNotesKit
2 |
3 |
4 |
5 |
6 |
7 | This is ReleaseNotesKit, a brand new, elegant, and extremely simple way to present the recent version’s release notes to your users. `ReleaseNotesKit` uses the iTunesSearchAPI to access information about the app. It has methods for caching data, presenting once on a version change, accessing just the data, and presenting the sheet without any preconditions.
8 |
9 | ## Configuration
10 | `ReleaseNotesKit` can be initialized using:
11 |
12 | ```swift
13 | ReleaseNotesKit.shared.setApp(with: "1548193451") //Replace with your app's ID
14 | ```
15 | Ideally, you'd like to set this once per app launch. Therefore, a good place to set this code would be in your App's `AppDelegate` file.
16 |
17 | ## Usage
18 | > Note: Before accessing any of `ReleaseNotesKit`’s methods, you have to initialize the shared instance with the app ID. Failure to do this will throw an assertion failure during DEBUG and will do nothing during PROD.
19 |
20 | `ReleaseNotesKit` can provide you both the data in a Swift Struct and also present a sheet with the data in a pre-styled format.
21 |
22 | ### Just the data
23 | To access just the data call `parseCacheOrFetchNewData`. This method has a default parameter `precondition` that is set to `false` by default. For simply accessing the data, precondition can remain false. This check is useful for our subsequent usage types.
24 |
25 | ```swift
26 | ReleaseNotesKit.shared.parseCacheOrFetchNewData { result in
27 | switch result {
28 | case .success(let response):
29 | print(response.releaseNotes)
30 | case .failure(let error):
31 | print(error.rawValue)
32 | }
33 | }
34 | ```
35 |
36 | The completion returns a Swift `Result` type with `ITunesLookupResult` for the success case and `ReleaseNotesError` in case of failure. `ReleaseNotesError` is defined in the following way:
37 | ```swift
38 | enum ReleaseNotesError: String, Error {
39 | case malformedURL
40 | case malformedData
41 | case parsingFailure
42 | case noResults
43 | }
44 | ```
45 | Let’s quickly go over each of these cases and what they mean so that it’ll be easy for you to handle it in your code:
46 |
47 | * `malformedURL`: The iTunesSearchAPI’s URL failed to get constructed. This will never happen if a properly formatted App ID is passed in the singleton’s `setApp` method.
48 | * `malformedData`: The data that is returned from the iTunesSearchAPI is corrupted or not readable.
49 | * `parsingFailure`: JSONDecoder failed to parse the data into `ITunesLookup`.
50 | * `noResults`: There was no available results returned for this particular appID. Please check if the appID is correct or if the app is brand new, please wait for a few hours for AppStore to index your app.
51 |
52 | ### Presenting the ReleaseNotesView for the first time
53 | `ReleaseNotesKit` can present the `ReleaseNotesView ` when the version changes. To present the sheet once per version update, you can call `presentReleaseNotesViewOnVersionChange`.
54 | ```swift
55 | ReleaseNotesKit.shared.presentReleaseNotesViewOnVersionChange()
56 | ```
57 | There’s two checks that happen in this method:
58 |
59 | ```swift
60 | guard let lastVersionSheetShownFor = UserDefaults.standard.string(forKey: "lastVersionSheetShownFor") else { ... }
61 | ```
62 | In this first case, we check if the UserDefaults string for `lastVersionSheetShownFor` is nil which can happen when the user has installed the app for the first time.
63 |
64 | ```swift
65 | result.currentVersion != Bundle.main.releaseVersionNumber || String(result.appID ?? 0) != self.appID
66 | ```
67 | * Current version stored in the cached response != The installed app's version
68 | * The cached lookup's appID is different than the set app ID.
69 |
70 | ### Presenting `ReleaseNotesView` without Preconditions
71 | It is possible to present the `ReleaseNotesView` without any version check preconditions. To call this, simply call `presentReleaseNotesView`. You may choose to pass a `controller: UIViewController` or let it be nil and the framework will access the UIApplication’s top view controller and present the `ReleaseNotesView` on that top controller.
72 |
73 | ```swift
74 | ReleaseNotesKit.shared.presentReleaseNotesView(in: self)
75 | ```
76 | Or, without the controller to present.
77 | ```swift
78 | ReleaseNotesKit.shared.presentReleaseNotesView()
79 | ```
80 |
81 | ## Testing
82 | There has been some manual testing done by myself. However, I am looking for contributions that will add a good testing suite. If you’re willing, please feel free to open a PR!
83 |
84 | ## Like the framework?
85 | If you like `ReleaseNotesKit` please consider buying me a coffee 🥰
86 |
87 |
88 |
89 | ## Contribution
90 | Contributions are always welcome. Please follow the following convention if you’re contributing:
91 |
92 | NameOfFile: Changes Made
93 | One commit per feature
94 | For issue fixes: #IssueNumber NameOfFile: ChangesMade
95 |
96 | ## License
97 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/SwapnanilDhol/ReleaseNotesKit/blob/main/Resources/LICENSE.md) file for details
98 |
99 | ## Apps using ReleaseNotesKit
100 | * [Neon: Color Picker & Social](https://apps.apple.com/us/app/neon-real-time-color-picker/id1480273650?ls=1)
101 | * [Sticker Card](https://apps.apple.com/us/app/sticker-cards/id1522226018)
102 |
103 | If you’re using `ReleaseNotesKit` in your app please open a PR to edit this Readme. I’ll be happy to include you in this list :D
104 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/Extensions/Bundle+.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * Bundle+.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import UIKit
11 |
12 | public extension Bundle {
13 | var releaseVersionNumber: String? {
14 | return infoDictionary?["CFBundleShortVersionString"] as? String
15 | }
16 | var buildVersionNumber: String? {
17 | return infoDictionary?["CFBundleVersion"] as? String
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/Extensions/UIApplication+.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * UIApplication+.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import UIKit
11 |
12 | public extension UIApplication {
13 | class func topViewController(controller: UIViewController? = UIApplication.shared.windows.first { $0.isKeyWindow }?.rootViewController) -> UIViewController? {
14 | if let navigationController = controller as? UINavigationController {
15 | return topViewController(controller: navigationController.visibleViewController)
16 | }
17 | if let tabController = controller as? UITabBarController {
18 | if let selected = tabController.selectedViewController {
19 | return topViewController(controller: selected)
20 | }
21 | }
22 | if let presented = controller?.presentedViewController {
23 | return topViewController(controller: presented)
24 | }
25 | return controller
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/Model/ITunesLookup.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * ITunesLookup.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import Foundation
11 |
12 | public struct ITunesLookup: Codable {
13 | public let resultCount: Int
14 | public let results: [ITunesLookupResult]
15 | }
16 |
17 | public struct ITunesLookupResult: Codable {
18 | public let appID: Int?
19 | public let appIconURL: String?
20 | public let appName: String?
21 | public let appURL: String?
22 | public let appDescription: String?
23 | public let sellerName: String?
24 | public let minimumOSVersion: String?
25 | public let currentVersion: String?
26 | public let currentVersionReleaseDate: String?
27 | public let releaseNotes: String?
28 |
29 | enum CodingKeys: String, CodingKey {
30 | case appID = "artistId"
31 | case appIconURL = "artworkUrl60"
32 | case appName = "trackCensoredName"
33 | case appURL = "artistViewUrl"
34 | case minimumOSVersion = "minimumOsVersion"
35 | case appDescription = "description"
36 | case currentVersion = "version"
37 | case currentVersionReleaseDate
38 | case sellerName
39 | case releaseNotes
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/Model/ReleaseNotesError.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * ReleaseNotesError.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import Foundation
11 |
12 | public enum ReleaseNotesError: String, Error {
13 | case malformedURL
14 | case malformedData
15 | case parsingFailure
16 | case noResults
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/ReleaseNotesKit+.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * ReleaseNotesKit+.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import SwiftUI
11 | import UIKit
12 |
13 | // MARK: - Presentors
14 | @available(iOS 13, *)
15 | extension ReleaseNotesKit {
16 |
17 | /// Presents the Release Notes sheet when
18 | /// 1) The current app's version number doesn't match the version number of the data stored in cache.
19 | /// 2) When there is no value in the `lastVersionSheetShownFor` key in UserDefaults.
20 | public func presentReleaseNotesViewOnVersionChange() {
21 | guard let lastVersionSheetShownFor = UserDefaults.standard.string(forKey: "lastVersionSheetShownFor"),
22 | lastVersionSheetShownFor == Bundle.main.releaseVersionNumber else {
23 | presentReleaseNotesView(precondition: true)
24 | return
25 | }
26 | }
27 |
28 | /// Presents ReleaseNotesView without any preconditions.
29 | /// - Parameter controller: The controller on which the sheet should be presented. If none is provided, it uses the top view controller.
30 | public func presentReleaseNotesView(precondition: Bool = false, in controller: UIViewController? = nil) {
31 | parseCacheOrFetchNewData(precondition: precondition) { result in
32 | switch result {
33 | case .success(let lookup):
34 | if precondition {
35 | guard lookup.currentVersion == Bundle.main.releaseVersionNumber else {
36 | //Here, we check if the response's version is the same as our app's installed version.
37 | //It might happen that the current installed version isn't the same as the latest available version.
38 | //Probably should ask the user to update the app.
39 | //I should make "PleaseUpdateKit" :D
40 | return
41 | }
42 | }
43 |
44 | DispatchQueue.main.async {
45 | let releaseNotesView = ReleaseNotesView(
46 | currentVersion: lookup.currentVersion ?? "",
47 | releaseDateString: lookup.currentVersionReleaseDate ?? "",
48 | releaseNotes: lookup.releaseNotes ?? ""
49 | )
50 | let hostingController = UIHostingController(rootView: releaseNotesView)
51 | UserDefaults.standard.set(Bundle.main.releaseVersionNumber, forKey: "lastVersionSheetShownFor")
52 | if let controller = controller {
53 | controller.present(hostingController, animated: true)
54 | } else {
55 | UIApplication.topViewController()?.present(hostingController, animated: true)
56 | }
57 | }
58 | case .failure(let error):
59 | assertionFailure(error.rawValue)
60 | }
61 | }
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/ReleaseNotesKit.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * ReleaseNotesKit.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import SwiftUI
11 | import UIKit
12 |
13 | public final class ReleaseNotesKit {
14 |
15 | public static let shared = ReleaseNotesKit()
16 | private var appID: String?
17 | private init() { }
18 |
19 | // MARK: - Configuration
20 |
21 | public func setApp(with appID: String) {
22 | self.appID = appID
23 | // Prepares data for App ID
24 | parseCacheOrFetchNewData { _ in }
25 | }
26 |
27 | // MARK: - Data Fetchers
28 |
29 | /// Attempts to parse cached data. If cached data is unavailable, it fetches new data.
30 | /// This method is kept public if you'd want to access just the lookup data without presenting it in a sheet view.
31 | /// - Parameter completion: ResultType: Success: ItunesLookupResult, Error: ReleaseNotesError
32 | public func parseCacheOrFetchNewData(
33 | precondition: Bool = true,
34 | completion: @escaping(Result) -> Void
35 | ) {
36 | //First, checking if there's any cached data
37 | guard let cachedLookupData = UserDefaults.standard.data(forKey: "cachedLookupData") else {
38 | // If there is no cache, we fetch from ITunesSearchAPI
39 | fetchReleaseNotes { result in completion(result) }
40 | return
41 | }
42 | //Cache is found. Trying to parse into ITunesLookup model.
43 | guard let response = try? JSONDecoder().decode(ITunesLookup.self, from: cachedLookupData),
44 | let result = response.results.first else {
45 | //If parsing fails, or if the results were none in the cache, we fetch again.
46 | fetchReleaseNotes { result in completion(result) }
47 | return
48 | }
49 | if !precondition {
50 | // If there was no preconditon for fetch, we return this cached result
51 | completion(.success(result))
52 | } else {
53 | // If there was a preconditon, we check the preconditions.
54 | // 1) Current version stored in the cached response != The installed app's version
55 | // 2) The cached lookup's appID is different than the set app ID.
56 | if result.currentVersion != Bundle.main.releaseVersionNumber ||
57 | String(result.appID ?? 0) != self.appID {
58 | fetchReleaseNotes { result in completion(result) }
59 | } else {
60 | completion(.success(result))
61 | }
62 | }
63 | }
64 |
65 | /// Fetches ITunes lookup for the provided app ID when it's not available in cache.
66 | /// This should not be called from anywhere except from `parseCacheOrFetchNewData`.
67 | /// ITunes API is rate limited by IP and so accessing from cache should our first priority.
68 | /// - Parameter completion: ResultType: Success: ItunesLookupResult, Error: ReleaseNotesError
69 | private func fetchReleaseNotes(
70 | completion: @escaping(Result) -> Void
71 | ) {
72 | guard let appID = appID else {
73 | assertionFailure("\(#file): App ID is nil. Please configure by calling setApp(with appID) before accessing this singelton's methods.")
74 | return
75 | }
76 | var urlComponents = URLComponents()
77 | urlComponents.scheme = "https"
78 | urlComponents.host = "itunes.apple.com"
79 | urlComponents.path = "/lookup"
80 | urlComponents.queryItems = [
81 | URLQueryItem(name: "id", value: appID)
82 | ]
83 | guard let url = urlComponents.url else {
84 | completion(.failure(.malformedURL))
85 | return
86 | }
87 | URLSession.shared.dataTask(with: url) { data, _, _ in
88 | guard let data = data else {
89 | completion(.failure(.malformedData))
90 | return
91 | }
92 | UserDefaults.standard.set(data, forKey: "cachedLookupData")
93 | guard let response = try? JSONDecoder().decode(ITunesLookup.self, from: data) else {
94 | completion(.failure(.parsingFailure))
95 | return
96 | }
97 | guard let result = response.results.first else {
98 | completion(.failure(.noResults))
99 | return
100 | }
101 | completion(.success(result))
102 | }.resume()
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Sources/ReleaseNotesKit/View/ReleaseNotesView.swift:
--------------------------------------------------------------------------------
1 | /*****************************************************************************
2 | * ReleaseNotesView.swift
3 | * ReleaseNotesKit
4 | *****************************************************************************
5 | * Authors: Swapnanil Dhol
6 | *
7 | * Refer to the COPYING file of the official project for license.
8 | *****************************************************************************/
9 |
10 | import SwiftUI
11 |
12 | @available(iOS 13, *)
13 | public struct ReleaseNotesView: View {
14 |
15 | private let title: String
16 | private let currentVersion: String
17 | private let releaseDateString: String
18 | private let releaseNotes: String
19 | private let topController: UIViewController?
20 | private let dismissButtonTitle: String
21 | private let dismissButtonColor: Color
22 | private let dateFormatter = DateFormatter()
23 |
24 | // MARK: - Init
25 |
26 | public init(
27 | title: String = "What's New",
28 | currentVersion: String = Bundle.main.releaseVersionNumber ?? "",
29 | releaseDateString: String,
30 | releaseNotes: String,
31 | topController: UIViewController? = UIApplication.topViewController(),
32 | dismissButtonTitle: String = "Dismiss",
33 | dismissButtonColor: Color = .blue
34 | ) {
35 | self.title = title
36 | self.currentVersion = currentVersion
37 | self.releaseDateString = releaseDateString
38 | self.releaseNotes = releaseNotes
39 | self.topController = topController
40 | self.dismissButtonTitle = dismissButtonTitle
41 | self.dismissButtonColor = dismissButtonColor
42 | }
43 |
44 | // MARK: - View
45 |
46 | public var body: some View {
47 | NavigationView {
48 | VStack(alignment: .leading) {
49 | VStack(alignment: .leading) {
50 | Text("Version: \(currentVersion)")
51 | .foregroundColor(.secondary)
52 | .multilineTextAlignment(.leading)
53 | .font(.subheadline)
54 | Text("Released on \(releaseDateFormattedString)")
55 | .foregroundColor(.secondary)
56 | .font(.subheadline)
57 | .multilineTextAlignment(.leading)
58 | }
59 | .padding(.bottom)
60 | Divider()
61 | .padding(.bottom)
62 | ScrollView {
63 | Text(releaseNotes)
64 | .font(.headline)
65 | }
66 | Button(action:{
67 | topController?.dismiss(animated: true)
68 | }) {
69 | RoundedRectangle(cornerRadius: 10)
70 | .frame(height: 55)
71 | .overlay(
72 | Text(dismissButtonTitle)
73 | .font(.headline)
74 | .fontWeight(.bold)
75 | .foregroundColor(.white),
76 | alignment: .center
77 | )
78 | }
79 | }.padding()
80 | .navigationBarTitle(Text(title))
81 | }
82 | }
83 |
84 | private var releaseDateFormattedString: String {
85 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
86 | guard let date = dateFormatter.date(from: releaseDateString) else { return "" }
87 | dateFormatter.dateFormat = "EEEE, MMM d, yyyy"
88 | return dateFormatter.string(from: date)
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/Tests/ReleaseNotesKitTests/ReleaseNotesKitTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ReleaseNotesKit
3 |
4 | final class ReleaseNotesKitTests: XCTestCase {
5 | func testExample() throws {
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/rnk-hero.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/SwapnanilDhol/ReleaseNotesKit/06111087ac656410a25f8960bbb9e8386591ea30/rnk-hero.png
--------------------------------------------------------------------------------