├── .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 | 
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 |
28 |
29 |
30 | .score
31 |
32 |
33 |
34 |
35 | .graphical
36 |
37 |
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 |
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 |
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 |
--------------------------------------------------------------------------------