├── .gitignore ├── .swiftlint.yml ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── CONTRIBUTING.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── ReviewKit │ ├── RatingImage.swift │ ├── RatingPosition.swift │ ├── RatingRow.swift │ ├── Resources │ ├── en.lproj │ │ └── Localizable.strings │ └── fi.lproj │ │ └── Localizable.strings │ ├── ReviewKit.swift │ └── ReviewManager.swift └── Tests └── ReviewKitTests └── ReviewKitTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | # .swiftlint.yml 2 | 3 | included: 4 | - Sources 5 | 6 | line_length: 7 | warning: 120 8 | error: 150 9 | 10 | type_body_length: 11 | warning: 200 12 | error: 300 13 | 14 | function_body_length: 15 | warning: 40 16 | error: 50 17 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | // 2 | // CONTRIBUTING.md 3 | // ReviewKit 4 | // 5 | // Created by Jack on 9/27/24. 6 | // 7 | 8 | # Contributing to ReviewKit 9 | 10 | Thank you for your interest in contributing to ReviewKit! Here are the guidelines to help you get started: 11 | 12 | ## How to Contribute 13 | 14 | 1. Fork the repository. 15 | 2. Create a new branch for your changes based off the latest release branch. 16 | 2. Follow project coding standards and best practices for Swift. 17 | 3. Write unit tests where applicable. 18 | 4. Commit your changes with clear, concise messages. 19 | 5. Squash your changes into a single commit. 20 | 6. Rebase to the latest version of the source release branch from this repository. 21 | 7. Submit a pull request targeting the release branch and ensure all checks pass before requesting a review. 22 | 23 | ## Discuss 24 | Join our [Discord](https://discord.gg/N2Mw2zvAnP) to discuss development of ReviewKit. 25 | 26 | ## Code Guidelines 27 | 28 | • Follow the Swift API Design Guidelines. 29 | • Run [SwiftLint](https://github.com/realm/SwiftLint) to check code formatting. 30 | 31 | ## Pull Request Guidelines 32 | 33 | • All PRs should have a related issue. Link the issue using the `closes` keyword in the body of the PR. 34 | • Make sure all unit tests pass and CI checks are green before submitting. 35 | • Be open to feedback and iterate on your changes as needed. 36 | 37 | ## Reporting Issues 38 | 39 | If you encounter bugs or have feature requests, please: 40 | 41 | 1. Open an issue in the GitHub issue tracker. 42 | 2. Provide as much detail as possible, including steps to reproduce, logs, and screenshots where applicable. 43 | 44 | ## Code of Conduct 45 | 46 | Please be respectful and considerate in your interactions with other contributors. We follow the [Contributor Covenant](https://www.contributor-covenant.org)). 47 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "collectionconcurrencykit", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/JohnSundell/CollectionConcurrencyKit.git", 7 | "state" : { 8 | "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", 9 | "version" : "0.2.0" 10 | } 11 | }, 12 | { 13 | "identity" : "cryptoswift", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", 16 | "state" : { 17 | "revision" : "678d442c6f7828def400a70ae15968aef67ef52d", 18 | "version" : "1.8.3" 19 | } 20 | }, 21 | { 22 | "identity" : "sourcekitten", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/jpsim/SourceKitten.git", 25 | "state" : { 26 | "revision" : "fd4df99170f5e9d7cf9aa8312aa8506e0e7a44e7", 27 | "version" : "0.35.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-argument-parser", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/apple/swift-argument-parser.git", 34 | "state" : { 35 | "revision" : "41982a3656a71c768319979febd796c6fd111d5c", 36 | "version" : "1.5.0" 37 | } 38 | }, 39 | { 40 | "identity" : "swift-syntax", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/swiftlang/swift-syntax.git", 43 | "state" : { 44 | "revision" : "cb53fa1bd3219b0b23ded7dfdd3b2baff266fd25", 45 | "version" : "600.0.0" 46 | } 47 | }, 48 | { 49 | "identity" : "swiftlint", 50 | "kind" : "remoteSourceControl", 51 | "location" : "https://github.com/realm/SwiftLint", 52 | "state" : { 53 | "branch" : "main", 54 | "revision" : "63ee1944bd40ed50aaae3d8b1a8e2c60090f119f" 55 | } 56 | }, 57 | { 58 | "identity" : "swiftytexttable", 59 | "kind" : "remoteSourceControl", 60 | "location" : "https://github.com/scottrhoyt/SwiftyTextTable.git", 61 | "state" : { 62 | "revision" : "c6df6cf533d120716bff38f8ff9885e1ce2a4ac3", 63 | "version" : "0.9.0" 64 | } 65 | }, 66 | { 67 | "identity" : "swxmlhash", 68 | "kind" : "remoteSourceControl", 69 | "location" : "https://github.com/drmohundro/SWXMLHash.git", 70 | "state" : { 71 | "revision" : "a853604c9e9a83ad9954c7e3d2a565273982471f", 72 | "version" : "7.0.2" 73 | } 74 | }, 75 | { 76 | "identity" : "yams", 77 | "kind" : "remoteSourceControl", 78 | "location" : "https://github.com/jpsim/Yams.git", 79 | "state" : { 80 | "revision" : "3036ba9d69cf1fd04d433527bc339dc0dc75433d", 81 | "version" : "5.1.3" 82 | } 83 | } 84 | ], 85 | "version" : 2 86 | } 87 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 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: "ReviewKit", 8 | defaultLocalization: "en", 9 | platforms: [ 10 | .macOS(.v10_15), .iOS(.v17), .visionOS(.v1) 11 | ], 12 | products: [ 13 | .library( 14 | name: "ReviewKit", 15 | targets: ["ReviewKit"]), 16 | ], dependencies: [ 17 | .package(url: "https://github.com/realm/SwiftLint", branch: "main") 18 | ], 19 | targets: [ 20 | .target( 21 | name: "ReviewKit", 22 | plugins: [.plugin(name: "SwiftLintBuildToolPlugin", package: "SwiftLint")] 23 | ), 24 | .testTarget( 25 | name: "ReviewKitTests", 26 | dependencies: ["ReviewKit"]) 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReviewKit 2 | ![GitHub Social Image](https://github.com/user-attachments/assets/124d6d31-006e-4231-83eb-70335cd5d4a6) 3 | 4 | 5 | Twitter [@OrdinaryInds](https://www.twitter.com/ordinaryinds) 6 | [Discord](https://discord.com/invite/rVvg4HgfxU) 7 | 8 | ## Contributing 9 | If you're interested in contributing to ReviewKit head over to CONTRIBUTING.md and get started. 10 | 11 | ## Usage 12 | Add a ShapeProgressView in your view code and pass in your app's ID. The view will automatically fetch your app's rating when the view appears. 13 | ``` 14 | var body: some View { 15 | VStack { 16 | ShapeProgressView(appId: "12345678") 17 | } 18 | } 19 | ``` 20 | The default behavior is to display the rating as a number, the rating as a set of stars, and a text line with the count of total reviews the rating is based on. When initalizing `ShapeProgressView` you can modify this behavior by setting the optional property `layout`. Options are `.full`, `.score`, and `.graphical`. 21 | ``` 22 | ShapeProgressView(layout: .full) 23 | ``` 24 | 25 | .full 26 | 27 | image 28 | 29 | 30 | .score 31 | 32 | image 33 | 34 | 35 | .graphical 36 | 37 | image 38 | 39 | 40 | ### Showing/Hiding the text view 41 | You can choose to hide the rating count text by passing an optional property. 42 | ``` 43 | ShapeProgressView(appId: "12345678", showReviewCount: false) 44 | ``` 45 | image 46 | 47 | 48 | ### Changing colors 49 | You can pass a `Color` to the `color` property to modify the tint used for the stars. This is only visible in the `.graphical` and `.full` layout modes. 50 | ``` 51 | ShapeProgressView(appId: "12345678", color: Color.green) 52 | ``` 53 | image 54 | 55 | 56 | If you have any questions reach out on Twitter [@OrdinaryInds](https://www.twitter.com/ordinaryinds) 57 | -------------------------------------------------------------------------------- /Sources/ReviewKit/RatingImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // 4 | // 5 | // Created by Ordinary Industries on 2/4/24. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | import SwiftUI 13 | 14 | struct SizePreferenceKey: PreferenceKey { 15 | static var defaultValue: CGSize = .zero 16 | static func reduce(value: inout CGSize, nextValue: () -> CGSize) {} 17 | } 18 | 19 | struct RatingImage: View { 20 | let imageName: String 21 | let position: RatingPosition 22 | let color: Color 23 | @Binding var value: Double 24 | let index: Int 25 | 26 | @State private var size: CGSize = .zero 27 | 28 | init(imageName: String, position: RatingPosition = .background, color: Color, value: Binding, index: Int) { 29 | self.imageName = imageName 30 | self.position = position 31 | self.color = color 32 | self._value = value 33 | self.index = index 34 | } 35 | 36 | var maskRatio: CGFloat { 37 | let mask = CGFloat(value) - CGFloat(index) 38 | 39 | switch mask { 40 | case 1...: 41 | return 1 42 | case ..<0: 43 | return 0 44 | default: 45 | return mask 46 | } 47 | } 48 | 49 | var calculatedImageName: String { 50 | switch position { 51 | case .foreground: 52 | return "\(imageName).fill" 53 | case .background: 54 | return "\(imageName).fill" 55 | } 56 | } 57 | 58 | var body: some View { 59 | switch position { 60 | case .foreground: 61 | Image(systemName: calculatedImageName) 62 | .foregroundStyle(color) 63 | .mask(alignment: .leading) { 64 | Rectangle() 65 | .frame(width: size.width * maskRatio, height: size.height, alignment: .leading) 66 | } 67 | .background(GeometryReader { reader in 68 | Color.clear 69 | .preference(key: SizePreferenceKey.self, value: reader.size) 70 | }) 71 | .onPreferenceChange(SizePreferenceKey.self) { newSize in 72 | size = newSize 73 | } 74 | case .background: 75 | Image(systemName: calculatedImageName) 76 | .foregroundStyle(color) 77 | .opacity(0.2) 78 | } 79 | } 80 | } 81 | 82 | struct RatingImage_Previews: PreviewProvider { 83 | static var previews: some View { 84 | Group { 85 | HStack { 86 | RatingImage(imageName: "star", position: .foreground, color: .yellow, value: .constant(0.5), index: 0) 87 | RatingImage(imageName: "star", position: .foreground, color: .orange, value: .constant(1.0), index: 0) 88 | RatingImage(imageName: "star", position: .foreground, color: .purple, value: .constant(0.7), index: 0) 89 | } 90 | .previewDisplayName("Foreground(filled)") 91 | 92 | HStack { 93 | RatingImage(imageName: "star", position: .background, color: .yellow, value: .constant(0.5), index: 0) 94 | RatingImage(imageName: "star", position: .background, color: .orange, value: .constant(1.0), index: 0) 95 | RatingImage(imageName: "star", position: .background, color: .purple, value: .constant(0.0), index: 0) 96 | } 97 | .previewDisplayName("Background(unfilled)") 98 | } 99 | .padding() 100 | .previewLayout(.sizeThatFits) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/ReviewKit/RatingPosition.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // 4 | // 5 | // Created by Ordinary Industries on 2/4/24. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | import Foundation 13 | 14 | enum RatingPosition { 15 | case foreground, background 16 | } 17 | -------------------------------------------------------------------------------- /Sources/ReviewKit/RatingRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // 4 | // 5 | // Created by Ordinary Industries on 2/4/24. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | import SwiftUI 13 | 14 | struct RatingRow: View { 15 | let count: Int 16 | let imageName: String 17 | let position: RatingPosition 18 | let color: Color 19 | @Binding var value: Double 20 | 21 | var body: some View { 22 | VStack { 23 | HStack { 24 | ForEach(0.. 0 98 | ? reviewManager.localizedString(forKey: "rating.reviews.countText", 99 | arguments: reviewManager.localizedReviewCount) 100 | : reviewManager.localizedString(forKey: "rating.reviews.noReviews") 101 | 102 | Text(reviewCountText) 103 | .foregroundStyle(ratingCountTextColor) 104 | } 105 | case .score: 106 | HStack { 107 | if showLaurels { 108 | Image(systemName: "laurel.leading") 109 | .foregroundColor(laurelColor) 110 | } 111 | 112 | Text("\(reviewManager.rating, specifier: "%.1f")") 113 | .fontDesign(.rounded) 114 | .foregroundColor(ratingTextColor) 115 | 116 | if showLaurels { 117 | Image(systemName: "laurel.trailing") 118 | .foregroundColor(laurelColor) 119 | } 120 | } 121 | .font(.largeTitle) 122 | .fontWeight(.bold) 123 | 124 | if showReviewCount { 125 | let reviewCountText = reviewManager.reviewCount > 0 126 | ? reviewManager.localizedString(forKey: "rating.reviews.countText", 127 | arguments: reviewManager.localizedReviewCount) 128 | : reviewManager.localizedString(forKey: "rating.reviews.noReviews") 129 | Text(reviewCountText) 130 | .foregroundColor(ratingCountTextColor) 131 | } 132 | 133 | case .graphical: 134 | ZStack { 135 | RatingRow(count: count, 136 | imageName: imageName, 137 | position: .background, 138 | color: color, 139 | value: $reviewManager.rating) 140 | RatingRow(count: count, 141 | imageName: imageName, 142 | position: .foreground, 143 | color: color, 144 | value: $reviewManager.rating) 145 | } 146 | 147 | if showReviewCount { 148 | Text(reviewManager.reviewCount > 0 ? "Based on \(reviewManager.reviewCount, specifier: "%.0f") reviews" : "No reviews yet") 149 | .foregroundColor(ratingCountTextColor) 150 | } 151 | } 152 | } 153 | .task { 154 | do { 155 | try await reviewManager.fetchAppStoreRating() 156 | } catch AppStoreResponseError.invalidData { 157 | print("Trying to fetch App Store rating failed due to invalid data.") 158 | } catch AppStoreResponseError.invalidURL { 159 | print("Trying to fetch App Store rating failed due to invalid URL.") 160 | } catch AppStoreResponseError.invalidResponse { 161 | print("Trying to fetch App Store rating failed due to invalid response.") 162 | } catch { 163 | print("Trying to fetch App Store rating failed due to an unknown error.") 164 | } 165 | } 166 | } 167 | } 168 | 169 | public struct RatingView: View { 170 | let appId: String 171 | let count: Int 172 | let imageName: String 173 | let color: Color 174 | let laurelColor: Color 175 | let ratingTextColor: Color 176 | let ratingCountTextColor: Color 177 | let layout: LayoutType 178 | let showReviewCount: Bool 179 | let showLaurels: Bool 180 | let countryCode: String 181 | 182 | @State private var reviewManager: ReviewManager 183 | 184 | public init(appId: String, 185 | count: Int = 5, 186 | imageName: String = "star", 187 | color: Color = .orange, 188 | laurelColor: Color = .black, 189 | ratingTextColor: Color = .black, 190 | ratingCountTextColor: Color = .black, 191 | layout: LayoutType = .full, 192 | showReviewCount: Bool = true, 193 | showLaurels: Bool = true, 194 | countryCode: String = "US" 195 | ) { 196 | self.appId = appId 197 | self.count = count 198 | self.imageName = imageName 199 | self.color = color 200 | self.ratingCountTextColor = ratingCountTextColor 201 | self.laurelColor = laurelColor 202 | self.ratingTextColor = ratingTextColor 203 | self.layout = layout 204 | self.showReviewCount = showReviewCount 205 | self.showLaurels = showLaurels 206 | self.countryCode = countryCode 207 | self._reviewManager = State(wrappedValue: ReviewManager(appId: appId, countryCode: countryCode)) 208 | } 209 | 210 | public var body: some View { 211 | VStack(spacing: 8) { 212 | if reviewManager.isLoading { 213 | ProgressView() 214 | .progressViewStyle(CircularProgressViewStyle(tint: color)) 215 | .scaleEffect(2.0, anchor: .center) 216 | } 217 | else { 218 | switch layout { 219 | case .full: 220 | HStack { 221 | if showLaurels { 222 | Image(systemName: "laurel.leading") 223 | } 224 | 225 | Text("\(reviewManager.rating, specifier: "%.1f")") 226 | .fontDesign(.rounded) 227 | 228 | if showLaurels { 229 | Image(systemName: "laurel.trailing") 230 | } 231 | } 232 | .font(.largeTitle) 233 | .fontWeight(.bold) 234 | 235 | ZStack { 236 | RatingRow(count: count, imageName: imageName, position: .background, color: color, value: $reviewManager.rating) 237 | RatingRow(count: count, imageName: imageName, position: .foreground, color: color, value: $reviewManager.rating) 238 | } 239 | 240 | if showReviewCount { 241 | Text(reviewManager.reviewCount > 0 ? "Based on \(reviewManager.reviewCount, specifier: "%.0f") reviews" : "No reviews yet") 242 | } 243 | case .score: 244 | HStack { 245 | if showLaurels { 246 | Image(systemName: "laurel.leading") 247 | } 248 | 249 | Text("\(reviewManager.rating, specifier: "%.1f")") 250 | .fontDesign(.rounded) 251 | 252 | if showLaurels { 253 | Image(systemName: "laurel.trailing") 254 | } 255 | } 256 | .font(.largeTitle) 257 | .fontWeight(.bold) 258 | 259 | if showReviewCount { 260 | Text(reviewManager.reviewCount > 0 ? "Based on \(reviewManager.reviewCount, specifier: "%.0f") reviews" : "No reviews yet") 261 | } 262 | case .graphical: 263 | ZStack { 264 | RatingRow(count: count, imageName: imageName, position: .background, color: color, value: $reviewManager.rating) 265 | RatingRow(count: count, imageName: imageName, position: .foreground, color: color, value: $reviewManager.rating) 266 | } 267 | 268 | if showReviewCount { 269 | let reviewCountText = reviewManager.reviewCount > 0 270 | ? reviewManager.localizedString(forKey: "rating.reviews.countText", arguments: reviewManager.localizedReviewCount) 271 | : reviewManager.localizedString(forKey: "rating.reviews.noReviews") 272 | 273 | Text(reviewCountText) 274 | } 275 | } 276 | } 277 | } 278 | .task { 279 | do { 280 | try await reviewManager.fetchAppStoreRating() 281 | } catch AppStoreResponseError.invalidData { 282 | print("Trying to fetch App Store rating failed due to invalid data.") 283 | } catch AppStoreResponseError.invalidURL { 284 | print("Trying to fetch App Store rating failed due to invalid URL.") 285 | } catch AppStoreResponseError.invalidResponse { 286 | print("Trying to fetch App Store rating failed due to invalid response.") 287 | } catch { 288 | print("Trying to fetch App Store rating failed due to an unknown error.") 289 | } 290 | } 291 | } 292 | } 293 | 294 | struct ReviewKit_Previews: PreviewProvider { 295 | static var previews: some View { 296 | Group { 297 | 298 | RatingView(appId: "1586351368", layout: .full) 299 | .previewDisplayName("Full Layout") 300 | 301 | RatingView(appId: "389801252", layout: .full,showReviewCount: false) 302 | .previewDisplayName("Layout without Review Count") 303 | 304 | RatingView(appId: "389801252", layout: .score) 305 | .previewDisplayName("Score Layout") 306 | 307 | RatingView(appId: "389801252", layout: .graphical) 308 | .previewDisplayName("Graphical Layout") 309 | 310 | RatingView(appId: "389801252", count: 5, imageName: "heart", color: .red, layout: .full,showReviewCount: false) 311 | .previewDisplayName("Custom Style with hearts") 312 | } 313 | .padding() 314 | .previewLayout(.sizeThatFits) 315 | } 316 | } 317 | -------------------------------------------------------------------------------- /Sources/ReviewKit/ReviewManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // 3 | // 4 | // 5 | // Created by Ordinary Industries on 2/5/24. 6 | // Copyright (c) 2023 Ordinary Industries. All rights reserved. 7 | // 8 | // Twitter: @OrdinaryInds 9 | // TikTok: @OrdinaryInds 10 | // 11 | 12 | import Foundation 13 | 14 | struct AppStoreResponse: Codable { 15 | let results: [AppData] 16 | } 17 | 18 | struct AppData: Codable { 19 | let isGameCenterEnabled: Bool 20 | let screenshotUrls: [String] 21 | let artworkUrl512: String 22 | let artworkUrl100: String 23 | let ipadScreenshotUrls: [String] 24 | let artistViewUrl: String 25 | let appletvScreenshotUrls: [String] 26 | let artworkUrl60: String 27 | let supportedDevices: [String] 28 | let advisories: [String] 29 | let features: [String] 30 | let kind: String 31 | let minimumOsVersion: String 32 | let trackViewUrl: String 33 | let trackCensoredName: String 34 | let languageCodesISO2A: [String] 35 | let fileSizeBytes: String 36 | let formattedPrice: String 37 | let contentAdvisoryRating: String 38 | let averageUserRatingForCurrentVersion: Double 39 | let userRatingCountForCurrentVersion: Double 40 | let averageUserRating: Double 41 | let trackContentRating: String 42 | let trackId: Double 43 | let trackName: String 44 | let genreIds: [String] 45 | let description: String 46 | let currency: String 47 | let sellerName: String 48 | let primaryGenreName: String 49 | let primaryGenreId: Double 50 | let isVppDeviceBasedLicensingEnabled: Bool 51 | let bundleId: String 52 | let currentVersionReleaseDate: String 53 | let releaseDate: String 54 | let artistId: Double 55 | let artistName: String 56 | let genres: [String] 57 | let price: Double 58 | let version: String 59 | let wrapperType: String 60 | let userRatingCount: Double 61 | } 62 | 63 | public enum AppStoreResponseError: Error { 64 | case invalidURL 65 | case invalidResponse 66 | case invalidData 67 | } 68 | 69 | @Observable 70 | class ReviewManager { 71 | var rating: Double 72 | var reviewCount: Double 73 | var isLoading: Bool 74 | let appId: String 75 | let countryCode: String 76 | 77 | public init(rating: Double = 0, 78 | reviewCount: Double = 0, 79 | isLoading: Bool = false, 80 | appId: String, 81 | countryCode: String) { 82 | self.rating = rating 83 | self.reviewCount = reviewCount 84 | self.isLoading = isLoading 85 | self.appId = appId 86 | self.countryCode = countryCode 87 | } 88 | 89 | var roundedRating: Double { 90 | let remainder = rating.truncatingRemainder(dividingBy: 0.5) 91 | return rating - remainder 92 | } 93 | 94 | func localizedString(forKey key: String, arguments: CVarArg...) -> String { 95 | let template = NSLocalizedString(key, bundle: Bundle.module, comment: "") 96 | return String(format: template, arguments: arguments) 97 | } 98 | 99 | var localizedReviewCount: String { 100 | let numberFormatter = NumberFormatter() 101 | numberFormatter.numberStyle = .decimal 102 | return numberFormatter.string(from: NSNumber(value: reviewCount)) ?? "\(Int(reviewCount))" 103 | } 104 | 105 | func fetchAppStoreRating() async throws { 106 | print("Fetching App Store rating.") 107 | DispatchQueue.main.async { self.isLoading = true } 108 | 109 | // The URL for the api endpoint. 110 | let endpoint = "http://itunes.apple.com/lookup?id=\(appId)&country=\(countryCode)" 111 | 112 | // Try creating a URL object with the endpoint url. If we cannot make the URL return. 113 | guard let url = URL(string: endpoint) else { 114 | DispatchQueue.main.async { self.isLoading = false } 115 | throw AppStoreResponseError.invalidURL 116 | } 117 | 118 | // Fetch the data. 119 | let (data, response) = try await URLSession.shared.data(from: url) 120 | 121 | // Ensure the call was successful by checking the HTTP status code. 122 | guard let response = response as? HTTPURLResponse, response.statusCode == 200 else { 123 | DispatchQueue.main.async { self.isLoading = false } 124 | throw AppStoreResponseError.invalidResponse 125 | } 126 | 127 | do { 128 | let decoder = JSONDecoder() 129 | let responseData = try decoder.decode(AppStoreResponse.self, from: data) 130 | if let appData = responseData.results.first { 131 | print(appData.averageUserRating) 132 | DispatchQueue.main.async { 133 | self.rating = appData.averageUserRatingForCurrentVersion 134 | self.reviewCount = appData.userRatingCountForCurrentVersion 135 | } 136 | print("Fetched App Store rating.") 137 | } 138 | } catch { 139 | DispatchQueue.main.async { self.isLoading = false } 140 | throw AppStoreResponseError.invalidData 141 | } 142 | DispatchQueue.main.async { self.isLoading = false } 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /Tests/ReviewKitTests/ReviewKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ReviewKit 3 | 4 | final class ReviewKitTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | } 13 | --------------------------------------------------------------------------------