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