├── .gitignore
├── LICENSE
├── Package.swift
├── README.md
├── SUPABASE-GUIDE.md
└── Sources
└── RatingsKit
├── Extensions
├── Collection+SafeSubscript.swift
├── View+ListSectionSpacing.swift
└── View+RedactedWhen.swift
├── Media.xcassets
├── Contents.json
├── Person1.imageset
│ ├── Contents.json
│ └── Person=Mattew, Skin Tone=White, Posture=1 Happy.png
├── Person2.imageset
│ ├── Contents.json
│ └── Person=Justin, Skin Tone=Black, Posture=1 Happy.png
├── Person3.imageset
│ ├── Contents.json
│ └── Person=Ed, Skin Tone=White, Posture=1 Happy.png
├── Person4.imageset
│ ├── Contents.json
│ └── Person=Donald, Skin Tone=Black, Posture=1 Happy.png
├── Person5.imageset
│ ├── Contents.json
│ └── Person=Mattew, Skin Tone=Black, Posture=1 Happy.png
├── Person6.imageset
│ ├── Contents.json
│ └── Person=Justin, Skin Tone=White, Posture=1 Happy.png
├── Person7.imageset
│ ├── Contents.json
│ └── Person=Ed, Skin Tone=Black, Posture=1 Happy.png
└── Person8.imageset
│ ├── Contents.json
│ └── Person=Donald, Skin Tone=White, Posture=1 Happy.png
├── Models
├── AppRatingProviding.swift
├── AppRatingResponse.swift
├── MockAppRatingProvider.swift
├── RatingScreenConfiguration.swift
├── Review.swift
└── ViewState.swift
├── RatingRequest
├── RatingRequestScreen+Previews.swift
├── RatingRequestScreen+View.swift
├── RatingRequestScreen.swift
└── Subviews
│ ├── MemojisStack.swift
│ ├── NoReviewsView.swift
│ ├── RatingView.swift
│ ├── ReviewCard.swift
│ └── TryAgainView.swift
└── Resources
├── Memojis
└── Memojis.swift
├── Strings
├── Date+RelativeTime.swift
├── LocalizedStringKey+.swift
└── LocalizedStringKey+RelativeTime.swift
├── Symbols
├── ContentUnavailableView+SFSymbol.swift
├── Image+SFSymbol.swift
└── Label+SFSymbol.swift
└── URL+Constants.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 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Sedlacek Solutions
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: 6.0
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: "RatingsKit",
8 | defaultLocalization: "en",
9 | platforms: [.iOS(.v17), .macOS(.v14)],
10 | products: [
11 | .library(
12 | name: "RatingsKit",
13 | targets: ["RatingsKit"]),
14 | ],
15 | targets: [
16 | .target(
17 | name: "RatingsKit",
18 | resources: [
19 | .process("Media.xcassets")
20 | ]
21 | )
22 | ]
23 | )
24 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RatingsKit
2 |
3 | A Swift Package that provides an elegant and customizable ratings interface for your iOS and macOS applications.
4 |
5 |
6 |
7 |
8 | ## Table of Contents
9 |
10 | - [Overview](#overview)
11 | - [Features](#features)
12 | - [Requirements](#requirements)
13 | - [Installation](#installation)
14 | - [Swift Package Manager](#swift-package-manager)
15 | - [Usage](#usage)
16 | - [Basic Implementation](#basic-implementation)
17 | - [Creating a Custom Rating Provider](#creating-a-custom-rating-provider)
18 | - [Using the Mock Provider](#using-the-mock-provider)
19 | - [Using with Supabase and App Store Connect API](#using-with-supabase-and-app-store-connect-api)
20 | - [Customization](#customization)
21 | - [Contributing](#contributing)
22 |
23 | ## Overview
24 |
25 | RatingsKit makes it easy to add a ratings and review system to your application. It offers a complete UI for displaying app ratings and reviews, and provides a streamlined way for users to submit their ratings to the App Store.
26 |
27 | ## Features
28 |
29 | - **Rating Request Screen**: A polished UI that displays app ratings, reviews, and encourages users to rate your app
30 | - **Customizable Provider**: Easy integration with any data source through the `AppRatingProviding` protocol
31 | - **Mock Implementation**: Ready-to-use mock data provider for development and preview purposes
32 | - **Error Handling**: Built-in error states with customizable error handling
33 | - **SwiftUI Design**: Modern UI built with SwiftUI for iOS 17+ and macOS 14+
34 |
35 | ## Requirements
36 |
37 | - iOS 17.0+ / macOS 14.0+
38 | - Swift 6.0+
39 | - Xcode 15+
40 |
41 | ## Installation
42 |
43 | ### Swift Package Manager
44 |
45 | To integrate RatingsKit into your Xcode project using Swift Package Manager, add it to your `Package.swift` file:
46 |
47 | ```swift
48 | dependencies: [
49 | .package(url: "https://github.com/Sedlacek-Solutions/RatingsKit.git", from: "1.0.1")
50 | ]
51 | ```
52 |
53 | Or add it directly via Xcode's Package Manager integration.
54 |
55 | ## Usage
56 |
57 | ### Basic Implementation
58 |
59 | ```swift
60 | import SwiftUI
61 | import RatingsKit
62 |
63 | struct RatingsView: View {
64 | var body: some View {
65 | RatingRequestScreen(
66 | appId: "YOUR_APP_ID",
67 | appRatingProvider: YourAppRatingProvider(),
68 | primaryButtonAction: {
69 | // Handle when user has requested to leave a rating
70 | print("User tapped to leave a rating")
71 | },
72 | secondaryButtonAction: {
73 | // Handle when user decides to rate later
74 | print("User will rate later")
75 | },
76 | onError: { error in
77 | // Handle any errors that occur
78 | print("Error occurred: \(error)")
79 | }
80 | )
81 | }
82 | }
83 | ```
84 |
85 | ### Creating a Custom Rating Provider
86 |
87 | Implement the `AppRatingProviding` protocol to fetch ratings and reviews from your backend service:
88 |
89 | ```swift
90 | struct YourAppRatingProvider: AppRatingProviding {
91 | func fetch() async throws -> AppRatingResponse {
92 | // Implement your logic to fetch ratings from your backend
93 | // For example:
94 | let (data, _) = try await URLSession.shared.data(from: yourBackendURL)
95 | return try JSONDecoder().decode(AppRatingResponse.self, from: data)
96 | }
97 | }
98 | ```
99 |
100 | ### Using the Mock Provider
101 |
102 | RatingsKit comes with a `MockAppRatingProvider` for testing and UI development:
103 |
104 | ```swift
105 | RatingRequestScreen(
106 | appId: "YOUR_APP_ID",
107 | appRatingProvider: MockAppRatingProvider.withMockReviews
108 | )
109 | ```
110 |
111 | Available mock configurations:
112 | - `.withMockReviews`: Provides a set of sample reviews
113 | - `.noReviews`: Shows ratings without reviews
114 | - `.noRatingsOrReviews`: Empty state with no ratings or reviews
115 | - `.throwsError`: Simulates an error state
116 |
117 | ### Using with Supabase and App Store Connect API
118 |
119 | For detailed instructions on how to create a Supabase Edge Function that fetches data from the App Store Connect API for use with RatingsKit, see our [Supabase Implementation Guide](SUPABASE-GUIDE.md).
120 |
121 | ## Customization
122 |
123 | RatingsKit provides a flexible way to tailor the ratings interface through two main customizers: the configuration settings and the screen initializer.
124 |
125 | 1. RatingScreenConfiguration
126 | This struct allows you to customize the visual and textual components of the rating screen. Its initializer provides the following properties:
127 |
128 | • screenTitle – The main title displayed at the top of the screen (default is localized as "Help Us Grow").
129 | • primaryButtonTitle – The label for the primary action button (default is "Give a Rating").
130 | • secondaryButtonTitle – The label for the secondary action button (default is "Maybe Later").
131 | • memojis – An array of SwiftUI Images that are shown on the screen. A set of default memojis is provided (via .defaultMemojis).
132 |
133 | Example usage:
134 | ```swift
135 | // Basic customization using RatingScreenConfiguration
136 | let config = RatingScreenConfiguration(
137 | screenTitle: "Rate Our App",
138 | primaryButtonTitle: "Rate Now",
139 | secondaryButtonTitle: "Maybe Later",
140 | memojis: [Image("happy"), Image("excited")]
141 | )
142 | ```
143 | 2. RatingRequestScreen Initializer
144 | This view combines the configuration with data fetching and user actions. Its initializer accepts:
145 |
146 | • configuration – An instance of RatingScreenConfiguration to dictate UI appearance.
147 | • appId – The App Store identifier of your app.
148 | • appRatingProvider – An object conforming to AppRatingProviding that fetches ratings data.
149 | • primaryButtonAction – A closure executed when the primary button is tapped (for instance, to open the App Store review page).
150 | • secondaryButtonAction – An optional closure for handling the secondary button tap (typically for deferring the rating process).
151 | • onError – An optional error handler closure for managing any issues during data fetching.
152 |
153 | Example initialization:
154 | ```swift
155 | RatingRequestScreen(
156 | configuration: config,
157 | appId: "YOUR_APP_ID",
158 | appRatingProvider: YourAppRatingProvider(),
159 | primaryButtonAction: {
160 | // Action when the primary button is tapped
161 | print("User started rating process")
162 | },
163 | secondaryButtonAction: {
164 | // Action when the secondary button is tapped
165 | print("User chose to rate later")
166 | },
167 | onError: { error in
168 | // Handle any errors that occur
169 | print("Error encountered: \(error)")
170 | }
171 | )
172 | ```
173 | Both initializers come with default values for common settings, making it easy to either use the standard configuration or fully customize the experience to match your app's style and workflow.
174 |
175 | 3. Advanced Button Styling with .tint
176 | Since RatingRequestScreen is built on SwiftUI, you can further customize the appearance of the buttons by applying SwiftUI modifiers. For example, use the .tint modifier to change the color of the primary and secondary buttons:
177 |
178 | ```swift
179 | // Applying tint to the RatingRequestScreen view
180 | RatingRequestScreen(
181 | configuration: config,
182 | appId: "YOUR_APP_ID",
183 | appRatingProvider: YourAppRatingProvider()
184 | )
185 | .tint(.blue) // This changes the accent color for buttons and interactive elements
186 | ```
187 |
188 | You can replace .blue with any Color value to match your app's theme.
189 |
190 | ## Contributing
191 |
192 | Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
193 |
--------------------------------------------------------------------------------
/SUPABASE-GUIDE.md:
--------------------------------------------------------------------------------
1 | # Using RatingsKit with Supabase and App Store Connect API
2 |
3 | This guide explains how to create a Supabase Edge Function that fetches app ratings and reviews from the App Store Connect API and formats the data for use with RatingsKit.
4 |
5 | ## Prerequisites
6 |
7 | - A Supabase project (free tier works fine)
8 | - App Store Connect API access
9 | - An app published on the App Store
10 |
11 | ## Setting Up App Store Connect API Access
12 |
13 | 1. Log in to [App Store Connect](https://appstoreconnect.apple.com/)
14 | 2. Go to Users and Access > Keys
15 | 3. Create a new API Key with the "Customer Reviews" permission
16 | 4. Note down the Key ID, Issuer ID, and download the private key file
17 |
18 | ## Creating the Supabase Edge Function
19 |
20 | 1. Install Supabase CLI and log in to your account
21 | 2. Initialize edge functions in your project
22 | 3. Create a new edge function called `app_ratings`:
23 |
24 | ```typescript
25 | // app_ratings.ts - Supabase Edge Function
26 | import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
27 | import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
28 | import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts'
29 |
30 | serve(async (req) => {
31 | try {
32 | // Parse the request body
33 | const { appId } = await req.json()
34 |
35 | if (!appId) {
36 | return new Response(JSON.stringify({ error: 'App ID is required' }), {
37 | headers: { 'Content-Type': 'application/json' },
38 | status: 400,
39 | })
40 | }
41 |
42 | // Get App Store Connect API credentials from environment variables
43 | const { APPSTORE_ISSUER_ID, APPSTORE_KEY_ID, APPSTORE_PRIVATE_KEY } = Deno.env.toObject()
44 |
45 | // Generate JWT token for App Store Connect API
46 | const privateKey = APPSTORE_PRIVATE_KEY.replace(/\\n/g, '\n')
47 |
48 | const jwt = await new jose.SignJWT({})
49 | .setProtectedHeader({ alg: 'ES256', kid: APPSTORE_KEY_ID, typ: 'JWT' })
50 | .setIssuedAt()
51 | .setIssuer(APPSTORE_ISSUER_ID)
52 | .setExpirationTime('1h')
53 | .setAudience('appstoreconnect-v1')
54 | .sign(await jose.importPKCS8(privateKey, 'ES256'))
55 |
56 | // Fetch ratings and reviews from App Store Connect API
57 | const url = `https://api.appstoreconnect.apple.com/v1/apps/${appId}/customerReviews`
58 | const response = await fetch(url, {
59 | headers: {
60 | Authorization: `Bearer ${jwt}`,
61 | 'Content-Type': 'application/json',
62 | },
63 | })
64 |
65 | const appStoreData = await response.json()
66 |
67 | // Transform the data to match RatingsKit's expected format
68 | const reviews = appStoreData.data.map(review => ({
69 | title: review.attributes.title,
70 | content: review.attributes.body,
71 | rating: review.attributes.rating,
72 | author: review.attributes.reviewerNickname || 'Anonymous',
73 | date: new Date(review.attributes.createdDate).toISOString()
74 | }))
75 |
76 | // Calculate average rating
77 | const totalRatings = reviews.length
78 | const averageRating = totalRatings > 0
79 | ? reviews.reduce((sum, review) => sum + review.rating, 0) / totalRatings
80 | : 0
81 |
82 | // Return the data in the format expected by RatingsKit
83 | return new Response(JSON.stringify({
84 | averageRating,
85 | totalRatings,
86 | reviews
87 | }), {
88 | headers: { 'Content-Type': 'application/json' },
89 | })
90 | } catch (error) {
91 | console.error('Error fetching app ratings:', error)
92 | return new Response(JSON.stringify({ error: error.message }), {
93 | headers: { 'Content-Type': 'application/json' },
94 | status: 500,
95 | })
96 | }
97 | })
98 | ```
99 |
100 | ## Setting Environment Variables
101 |
102 | In your Supabase dashboard, set the following secrets:
103 |
104 | - `APPSTORE_ISSUER_ID`: Your App Store Connect Issuer ID
105 | - `APPSTORE_KEY_ID`: Your App Store Connect Key ID
106 | - `APPSTORE_PRIVATE_KEY`: Your App Store Connect API private key (paste the entire contents)
107 |
108 | ## Deploy the Function
109 |
110 | ```bash
111 | supabase functions deploy app_ratings
112 | ```
113 |
114 | ## Creating a Swift Provider for the Supabase Function
115 |
116 | Now in your app, implement an `AppRatingProviding` class that uses the Supabase function:
117 |
118 | ```swift
119 | struct SupabaseAppRatingProvider: AppRatingProviding {
120 | let supabaseUrl: URL
121 | let supabaseKey: String
122 | let appId: String
123 |
124 | init(supabaseUrl: URL, supabaseKey: String, appId: String) {
125 | self.supabaseUrl = supabaseUrl
126 | self.supabaseKey = supabaseKey
127 | self.appId = appId
128 | }
129 |
130 | func fetch() async throws -> AppRatingResponse {
131 | var request = URLRequest(url: URL(string: "\(supabaseUrl)/functions/v1/app_ratings")!)
132 | request.httpMethod = "POST"
133 | request.addValue("application/json", forHTTPHeaderField: "Content-Type")
134 | request.addValue(supabaseKey, forHTTPHeaderField: "Authorization")
135 |
136 | let payload = ["appId": appId]
137 | request.httpBody = try JSONSerialization.data(withJSONObject: payload)
138 |
139 | let (data, _) = try await URLSession.shared.data(for: request)
140 | let decoder = JSONDecoder()
141 | decoder.dateDecodingStrategy = .iso8601
142 |
143 | return try decoder.decode(AppRatingResponse.self, from: data)
144 | }
145 | }
146 | ```
147 |
148 | ## Using the Supabase Provider with RatingsKit
149 |
150 | ```swift
151 | let provider = SupabaseAppRatingProvider(
152 | supabaseUrl: URL(string: "https://yourproject.supabase.co")!,
153 | supabaseKey: "your-supabase-anon-key",
154 | appId: "your-app-store-id"
155 | )
156 |
157 | RatingRequestScreen(
158 | appId: "your-app-store-id",
159 | appRatingProvider: provider
160 | )
161 | ```
162 |
163 | ## Security Considerations
164 |
165 | - The Supabase anon key is public, but the App Store Connect API credentials are securely stored as environment variables
166 | - Consider adding rate limiting to your function
167 | - For production use, you may want to cache responses to reduce API calls
168 |
169 | ## Troubleshooting
170 |
171 | - If you receive a 401 error, check that your JWT is being generated correctly
172 | - If you receive a 403 error, verify that your API key has the correct permissions
173 | - Use Supabase logs to debug any issues with the function execution
174 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Extensions/Collection+SafeSubscript.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Collection+SafeSubscript.swift
3 | //
4 | // Created by James Sedlacek on 3/14/25.
5 | //
6 |
7 | /// A safe subscript for accessing elements in a collection.
8 | extension Collection {
9 | subscript(safe index: Index) -> Element? {
10 | indices.contains(index) ? self[index] : nil
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Extensions/View+ListSectionSpacing.swift:
--------------------------------------------------------------------------------
1 | //
2 | // View+ListSectionSpacing.swift
3 | //
4 | // Created by James Sedlacek on 3/8/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension View {
10 | func listSectionSpacingIfAvailable(_ spacing: CGFloat = 8) -> some View {
11 | #if os(iOS)
12 | if #available(iOS 17.0, *) {
13 | return listSectionSpacing(spacing)
14 | } else {
15 | return self
16 | }
17 | #else
18 | return self
19 | #endif
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Extensions/View+RedactedWhen.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | /// Applies redaction to a view based on a conditional statement.
5 | ///
6 | /// This modifier provides a more convenient way to conditionally redact content
7 | /// compared to the standard redacted modifier which requires separate view branches.
8 | ///
9 | /// - Parameters:
10 | /// - reason: The redaction reason to apply when the condition is true (default is `.placeholder`).
11 | /// - condition: A Boolean value that determines whether redaction should be applied.
12 | /// - Returns: A view that is redacted when the condition is true.
13 | func redacted(
14 | reason: RedactionReasons = .placeholder,
15 | when condition: Bool
16 | ) -> some View {
17 | redacted(reason: condition ? reason : [])
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person1.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Mattew, Skin Tone=White, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person1.imageset/Person=Mattew, Skin Tone=White, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person1.imageset/Person=Mattew, Skin Tone=White, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person2.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Justin, Skin Tone=Black, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person2.imageset/Person=Justin, Skin Tone=Black, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person2.imageset/Person=Justin, Skin Tone=Black, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person3.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Ed, Skin Tone=White, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person3.imageset/Person=Ed, Skin Tone=White, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person3.imageset/Person=Ed, Skin Tone=White, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person4.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Donald, Skin Tone=Black, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person4.imageset/Person=Donald, Skin Tone=Black, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person4.imageset/Person=Donald, Skin Tone=Black, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person5.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Mattew, Skin Tone=Black, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person5.imageset/Person=Mattew, Skin Tone=Black, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person5.imageset/Person=Mattew, Skin Tone=Black, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person6.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Justin, Skin Tone=White, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person6.imageset/Person=Justin, Skin Tone=White, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person6.imageset/Person=Justin, Skin Tone=White, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person7.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Ed, Skin Tone=Black, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person7.imageset/Person=Ed, Skin Tone=Black, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person7.imageset/Person=Ed, Skin Tone=Black, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person8.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Person=Donald, Skin Tone=White, Posture=1 Happy.png",
5 | "idiom" : "universal"
6 | }
7 | ],
8 | "info" : {
9 | "author" : "xcode",
10 | "version" : 1
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Media.xcassets/Person8.imageset/Person=Donald, Skin Tone=White, Posture=1 Happy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Sedlacek-Solutions/RatingsKit/ea8eabf4998cb4aed2ea169897074f1c60691fa3/Sources/RatingsKit/Media.xcassets/Person8.imageset/Person=Donald, Skin Tone=White, Posture=1 Happy.png
--------------------------------------------------------------------------------
/Sources/RatingsKit/Models/AppRatingProviding.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A protocol defining the requirements for an app rating data provider.
4 | ///
5 | /// Implementers of this protocol are responsible for fetching app rating and review data
6 | /// from various sources such as app stores, databases, or mock data providers.
7 | public protocol AppRatingProviding: Sendable {
8 | /// Fetches the app rating data asynchronously.
9 | ///
10 | /// - Returns: An `AppRatingResponse` containing the app rating information and reviews.
11 | /// - Throws: An error if the data cannot be fetched successfully.
12 | func fetch() async throws -> AppRatingResponse
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Models/AppRatingResponse.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A model representing the response containing app rating information and reviews.
4 | ///
5 | /// This structure encapsulates all the data related to an app's ratings and reviews,
6 | /// including the average rating score, the total number of ratings received,
7 | /// and a collection of individual reviews.
8 | public struct AppRatingResponse: Decodable, Sendable {
9 | /// The average rating score of the app (typically on a scale of 1.0-5.0).
10 | public let averageRating: Double
11 |
12 | /// The total number of ratings the app has received.
13 | public let totalRatings: Int
14 |
15 | /// A collection of individual user reviews.
16 | public let reviews: [Review]
17 |
18 | /// Creates a new app rating response with the specified details.
19 | ///
20 | /// - Parameters:
21 | /// - averageRating: The average rating score of the app.
22 | /// - totalRatings: The total number of ratings the app has received.
23 | /// - reviews: A collection of individual user reviews.
24 | public init(
25 | averageRating: Double,
26 | totalRatings: Int,
27 | reviews: [Review]
28 | ) {
29 | self.averageRating = averageRating
30 | self.totalRatings = totalRatings
31 | self.reviews = reviews
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Models/MockAppRatingProvider.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A mock implementation of the `AppRatingProviding` protocol for testing and preview purposes.
4 | ///
5 | /// This provider returns predefined app rating data and can simulate network delays and errors.
6 | public struct MockAppRatingProvider: AppRatingProviding {
7 | /// The predefined response that will be returned by the `fetch` method.
8 | private let response: AppRatingResponse
9 |
10 | /// A flag indicating whether the `fetch` method should throw an error.
11 | private let shouldThrowError: Bool
12 |
13 | /// Errors that can be thrown by the mock provider.
14 | enum MockError: LocalizedError {
15 | /// A generic error used for testing error handling.
16 | case genericError
17 |
18 | /// A localized description of the error.
19 | public var errorDescription: String? {
20 | "A generic error occurred while fetching the app rating."
21 | }
22 | }
23 |
24 | /// Creates a new mock app rating provider with specified behavior.
25 | ///
26 | /// - Parameters:
27 | /// - response: The predefined response that will be returned by the `fetch` method.
28 | /// - shouldThrowError: A flag indicating whether the `fetch` method should throw an error.
29 | public init(
30 | response: AppRatingResponse,
31 | shouldThrowError: Bool = false
32 | ) {
33 | self.response = response
34 | self.shouldThrowError = shouldThrowError
35 | }
36 |
37 | /// Fetches the mock app rating data with a simulated delay.
38 | ///
39 | /// - Returns: The predefined `AppRatingResponse`.
40 | /// - Throws: `MockError.genericError` if `shouldThrowError` is `true`.
41 | public func fetch() async throws -> AppRatingResponse {
42 | // Simulating network delay
43 | try? await Task.sleep(nanoseconds: 3_000_000_000)
44 |
45 | if shouldThrowError {
46 | throw MockError.genericError
47 | }
48 |
49 | return response
50 | }
51 | }
52 |
53 | extension MockAppRatingProvider {
54 | /// A provider that returns an app with ratings but no reviews.
55 | public static let noReviews = MockAppRatingProvider(response: .init(
56 | averageRating: 4.8,
57 | totalRatings: 15,
58 | reviews: []
59 | ))
60 |
61 | /// A provider that returns an app with no ratings and no reviews.
62 | public static let noRatingsOrReviews = MockAppRatingProvider(response: .init(
63 | averageRating: 0.0,
64 | totalRatings: 0,
65 | reviews: []
66 | ))
67 |
68 | /// A provider that returns an app with ratings and a collection of mock reviews.
69 | public static let withMockReviews = MockAppRatingProvider(response: .init(
70 | averageRating: 4.2,
71 | totalRatings: 1250,
72 | reviews: .mock()
73 | ))
74 |
75 | /// A provider that simulates a network error when trying to fetch ratings.
76 | public static let throwsError = MockAppRatingProvider(
77 | response: .init(
78 | averageRating: 0.0,
79 | totalRatings: 0,
80 | reviews: []
81 | ),
82 | shouldThrowError: true
83 | )
84 | }
85 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Models/RatingScreenConfiguration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingScreenConfiguration.swift
3 | // RatingsKit
4 | //
5 | // Created by Alpay Calalli on 11.03.25.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Configuration for customizing the appearance and content of the rating screen.
11 | ///
12 | /// Use this struct to customize various aspects of the rating screen, including:
13 | /// - The screen title
14 | /// - Button titles
15 | /// - Memoji images displayed
16 | ///
17 | /// Example usage:
18 | /// ```swift
19 | /// let config = RatingScreenConfiguration(
20 | /// screenTitle: .init("Rate Our App"),
21 | /// primaryButtonTitle: .init("Rate Now"),
22 | /// secondaryButtonTitle: .init("Not Now")
23 | /// )
24 | /// ```
25 | @MainActor
26 | public struct RatingScreenConfiguration {
27 | /// The main title displayed at the top of the rating screen.
28 | let screenTitle: LocalizedStringKey
29 |
30 | /// The title for the primary action button that initiates the rating process.
31 | let primaryButtonTitle: LocalizedStringKey
32 |
33 | /// The title for the secondary action button that allows users to postpone rating.
34 | let secondaryButtonTitle: LocalizedStringKey
35 |
36 | /// An array of memoji images to be displayed in the rating screen.
37 | let memojis: [Image]
38 |
39 | /// Creates a new rating screen configuration.
40 | ///
41 | /// - Parameters:
42 | /// - screenTitle: The title shown at the top of the rating screen. Defaults to "Help Us Grow".
43 | /// - primaryButtonTitle: The text for the main rating button. Defaults to "Give a Rating".
44 | /// - secondaryButtonTitle: The text for the postpone button. Defaults to "Maybe Later".
45 | /// - memojis: An array of images to be displayed as memojis. Defaults to a predefined set of memojis.
46 | public init(
47 | screenTitle: LocalizedStringKey = .helpUsGrow,
48 | primaryButtonTitle: LocalizedStringKey = .giveARating,
49 | secondaryButtonTitle: LocalizedStringKey = .maybeLater,
50 | memojis: [Image] = .defaultMemojis
51 | ) {
52 | self.screenTitle = screenTitle
53 | self.primaryButtonTitle = primaryButtonTitle
54 | self.secondaryButtonTitle = secondaryButtonTitle
55 | self.memojis = memojis
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Models/Review.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A model representing a user review with rating and metadata.
4 | ///
5 | /// This struct encapsulates all information related to a single review, including
6 | /// the review title, content, rating score, author information, and date.
7 | public struct Review: Decodable, Sendable, Hashable {
8 | /// The title or headline of the review.
9 | public let title: String
10 |
11 | /// The main content or body text of the review.
12 | public let content: String
13 |
14 | /// The numerical rating given by the reviewer (typically on a scale of 1-5).
15 | public let rating: Int
16 |
17 | /// The name of the person who wrote the review.
18 | public let author: String
19 |
20 | /// The date when the review was submitted.
21 | public let date: Date
22 |
23 | /// Creates a new review with the specified details.
24 | ///
25 | /// - Parameters:
26 | /// - title: The title or headline of the review.
27 | /// - content: The main content or body text of the review.
28 | /// - rating: The numerical rating given by the reviewer (typically 1-5).
29 | /// - author: The name of the person who wrote the review.
30 | /// - date: The date when the review was submitted.
31 | public init(
32 | title: String,
33 | content: String,
34 | rating: Int,
35 | author: String,
36 | date: Date
37 | ) {
38 | self.title = title
39 | self.content = content
40 | self.rating = rating
41 | self.author = author
42 | self.date = date
43 | }
44 | }
45 |
46 | extension Review {
47 | /// Creates a mock review for testing and preview purposes.
48 | ///
49 | /// - Parameter rating: The rating value to assign to the mock review (defaults to 4).
50 | /// - Returns: A fully populated mock `Review` instance.
51 | static func mock(rating: Int = 4) -> Review {
52 | Review(
53 | title: "Absolutely Love This App!",
54 | content: "This is an incredible app that has completely transformed how I work. The interface is intuitive, and the features are exactly what I needed. Highly recommend to everyone!",
55 | rating: rating,
56 | author: "John Appleseed",
57 | date: Date()
58 | )
59 | }
60 | }
61 |
62 | extension [Review] {
63 | /// Creates an array of mock reviews with different ratings and dates for testing and preview purposes.
64 | ///
65 | /// - Returns: An array containing 6 different mock review instances with varying content.
66 | static func mock() -> Self {
67 | [
68 | .mock(rating: 5),
69 | Review(
70 | title: "Great Potential",
71 | content: "Very promising app with some really useful features. Looking forward to future updates!",
72 | rating: 4,
73 | author: "Sarah Wilson",
74 | date: Date().addingTimeInterval(-86400) // Yesterday
75 | ),
76 | Review(
77 | title: "Needs Improvement",
78 | content: "Good concept but needs some work on performance.",
79 | rating: 3,
80 | author: "Mike Thompson",
81 | date: Date().addingTimeInterval(-172800) // 2 days ago
82 | ),
83 | Review(
84 | title: "Life Changing App",
85 | content: "I've been using this app for months now and it has completely changed how I organize my work. The recent updates make it even better!",
86 | rating: 5,
87 | author: "Emily Chen",
88 | date: Date().addingTimeInterval(-7776000) // 3 months ago
89 | ),
90 | Review(
91 | title: "Could Be Better",
92 | content: "The app is okay but crashes sometimes. Hope this gets fixed soon.",
93 | rating: 2,
94 | author: "David Brown",
95 | date: Date().addingTimeInterval(-15552000) // 6 months ago
96 | ),
97 | Review(
98 | title: "Basic Functionality",
99 | content: "It does what it promises but nothing extraordinary. Would like to see more features.",
100 | rating: 3,
101 | author: "Lisa Martinez",
102 | date: Date().addingTimeInterval(-31536000) // 1 year ago
103 | )
104 | ]
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Models/ViewState.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewState.swift
3 | //
4 | // Created by James Sedlacek on 3/8/25.
5 | //
6 |
7 | /// A generic enum representing the different states of a view that loads data asynchronously.
8 | ///
9 | /// This enum is used to handle the three common states of data-driven views:
10 | /// - Loading: Data is being fetched or processed
11 | /// - Loaded: Data has been successfully retrieved
12 | /// - Error: An error occurred during data fetching or processing
13 | public enum ViewState {
14 | /// Indicates that data is currently being loaded.
15 | case loading
16 |
17 | /// Indicates that data has been successfully loaded.
18 | /// - Parameter T: The loaded data.
19 | case loaded(T)
20 |
21 | /// Indicates that an error occurred during loading.
22 | /// - Parameter String: A description of the error.
23 | case error(String)
24 |
25 | /// The successfully loaded value, if available.
26 | ///
27 | /// Returns `nil` if the state is not `.loaded`.
28 | var value: T? {
29 | if case .loaded(let value) = self {
30 | return value
31 | }
32 | return nil
33 | }
34 |
35 | /// A Boolean value indicating whether the state is `.loading`.
36 | var isLoading: Bool {
37 | if case .loading = self {
38 | return true
39 | }
40 | return false
41 | }
42 |
43 | /// The error message if the state is `.error`, otherwise `nil`.
44 | var errorMessage: String? {
45 | if case .error(let message) = self {
46 | return message
47 | }
48 | return nil
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/RatingRequestScreen+Previews.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingRequestScreen+Previews.swift
3 | //
4 | // Created by James Sedlacek on 3/10/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | #Preview("Mock Reviews") {
10 | RatingRequestScreen(
11 | appId: "1658216708",
12 | appRatingProvider: MockAppRatingProvider.withMockReviews,
13 | primaryButtonAction: {
14 | print("Rating Requested")
15 | },
16 | secondaryButtonAction: {
17 | print("Maybe later tapped")
18 | }
19 | )
20 | .tint(.accentColor)
21 | #if os(macOS)
22 | .frame(width: 400, height: 600)
23 | #endif
24 | }
25 |
26 | #Preview("No Reviews") {
27 | RatingRequestScreen(
28 | appId: "1658216708",
29 | appRatingProvider: MockAppRatingProvider.noReviews,
30 | primaryButtonAction: {
31 | print("Rating Requested")
32 | },
33 | secondaryButtonAction: {
34 | print("Maybe later tapped")
35 | }
36 | )
37 | #if os(macOS)
38 | .frame(width: 400, height: 600)
39 | #endif
40 | }
41 |
42 | #Preview("No Ratings or Reviews") {
43 | RatingRequestScreen(
44 | appId: "1658216708",
45 | appRatingProvider: MockAppRatingProvider.noRatingsOrReviews,
46 | primaryButtonAction: {
47 | print("Rating Requested")
48 | },
49 | secondaryButtonAction: {
50 | print("Maybe later tapped")
51 | }
52 | )
53 | #if os(macOS)
54 | .frame(width: 400, height: 600)
55 | #endif
56 | }
57 |
58 | #Preview("Error State") {
59 | RatingRequestScreen(
60 | appId: "1658216708",
61 | appRatingProvider: MockAppRatingProvider.throwsError,
62 | primaryButtonAction: {
63 | print("Rating Requested")
64 | },
65 | secondaryButtonAction: {
66 | print("Maybe later tapped")
67 | },
68 | onError: { error in
69 | print("Error occurred: \(error)")
70 | }
71 | )
72 | #if os(macOS)
73 | .frame(width: 400, height: 600)
74 | #endif
75 | }
76 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/RatingRequestScreen+View.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingRequestScreen+View.swift
3 | //
4 | // Created by James Sedlacek on 3/8/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension RatingRequestScreen: View {
10 | public var body: some View {
11 | VStack(spacing: 16) {
12 | headerSection
13 | reviewList
14 | .safeAreaInset(
15 | edge: .bottom,
16 | content: callToActionSection
17 | )
18 | }
19 | .padding(.vertical)
20 | .background(.background)
21 | .overlay(content: errorStateView)
22 | .task(fetchData)
23 | }
24 | }
25 |
26 | // MARK: Header Section
27 | extension RatingRequestScreen {
28 | private var headerSection: some View {
29 | VStack(alignment: .center, spacing: 12) {
30 | titleView
31 | averageRatingView
32 | totalRatingsView
33 | }
34 | }
35 |
36 | private var titleView: some View {
37 | Text(configuration.screenTitle)
38 | .font(.largeTitle.bold())
39 | }
40 |
41 | private var averageRatingView: some View {
42 | RatingView(rating: averageRating)
43 | .redacted(when: state.isLoading)
44 | }
45 |
46 | @ViewBuilder
47 | private var totalRatingsView: some View {
48 | if isShowingNoRatings {
49 | Text(.noRatingsYet)
50 | } else {
51 | HStack {
52 | MemojisStack(memojis: configuration.memojis)
53 | Text(.ratings(totalRatings))
54 | .font(.body.weight(.medium))
55 | .redacted(when: state.isLoading)
56 | }
57 | }
58 | }
59 | }
60 |
61 | // MARK: Review List
62 | extension RatingRequestScreen {
63 | private var reviewList: some View {
64 | List {
65 | if state.isLoading {
66 | loadingReviewCards
67 | } else {
68 | reviewCards
69 | }
70 | }
71 | .scrollContentBackground(.hidden)
72 | .listSectionSeparator(.hidden)
73 | .listSectionSpacingIfAvailable()
74 | .listStyle(.plain)
75 | .overlay(noReviewsView)
76 | }
77 |
78 | private var loadingReviewCards: some View {
79 | ForEach(0..<5, id: \.self) { _ in
80 | ReviewCard(review: .mock(), memoji: Image(.person1))
81 | .redacted(reason: .placeholder)
82 | .listRowSeparator(.hidden)
83 | }
84 | }
85 |
86 | private var reviewCards: some View {
87 | ForEach(reviews.indices, id: \.self) { index in
88 | if let review = reviews[safe: index],
89 | let memoji = configuration.memojis[safe: index] {
90 | ReviewCard(review: review, memoji: memoji)
91 | .listRowSeparator(.hidden)
92 | }
93 | }
94 | }
95 |
96 | @ViewBuilder
97 | private var noReviewsView: some View {
98 | if isShowingNoReviews {
99 | NoReviewsView()
100 | }
101 | }
102 | }
103 |
104 | // MARK: Call to Action Section
105 | extension RatingRequestScreen {
106 | @ViewBuilder
107 | private func callToActionSection() -> some View {
108 | if !state.isLoading {
109 | VStack(spacing: .zero) {
110 | primaryButton
111 | secondaryButton
112 | }
113 | .background(.background)
114 | .transition(.move(edge: .bottom))
115 | }
116 | }
117 |
118 | private var primaryButton: some View {
119 | Button(
120 | action: ratingRequestAction,
121 | label: {
122 | Text(configuration.primaryButtonTitle)
123 | .frame(maxWidth: .infinity)
124 | .font(.headline.weight(.semibold))
125 | .frame(height: 42)
126 | }
127 | )
128 | .buttonStyle(.borderedProminent)
129 | .buttonBorderShape(.roundedRectangle)
130 | .padding()
131 | }
132 |
133 | @ViewBuilder
134 | private var secondaryButton: some View {
135 | if let secondaryButtonAction {
136 | Button(
137 | action: secondaryButtonAction,
138 | label: {
139 | Text(configuration.secondaryButtonTitle)
140 | .font(.subheadline.weight(.medium))
141 | }
142 | )
143 | .buttonStyle(.borderless)
144 | }
145 | }
146 | }
147 |
148 | // MARK: Error State
149 | extension RatingRequestScreen {
150 | @ViewBuilder
151 | private func errorStateView() -> some View {
152 | if let errorMessage = state.errorMessage {
153 | TryAgainView(
154 | errorMessage: errorMessage,
155 | tryAgainAction: tryAgainAction
156 | )
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/RatingRequestScreen.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingRequestScreen.swift
3 | //
4 | // Created by James Sedlacek on 3/7/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// An app rating request screen.
10 | ///
11 | /// This structure handles fetching app ratings and reviews, managing the view state,
12 | /// and providing actions for user interactions like submitting a rating or deferring.
13 | @MainActor
14 | public struct RatingRequestScreen {
15 | /// Action to perform when the user taps on the secondary button.
16 | let secondaryButtonAction: (() -> Void)?
17 |
18 | /// Configuration for the rating request screen.
19 | let configuration: RatingScreenConfiguration
20 |
21 | /// The App Store ID of the app for which ratings are being requested.
22 | private let appId: String
23 |
24 | /// The provider used to fetch app rating data.
25 | private let appRatingProvider: any AppRatingProviding
26 |
27 | /// Action to perform when the user taps on the primary button.
28 | private let primaryButtonAction: () -> Void
29 |
30 | /// Handler for errors that occur during data fetching.
31 | private let onError: ((Error) -> Void)?
32 |
33 | /// Environment value used to open URLs (like App Store review links).
34 | @Environment(\.openURL) private var openURL
35 |
36 | /// The current state of the view (loading, loaded with data, or error).
37 | @State var state: ViewState = .loading
38 |
39 | /// A subset of reviews to display in the UI.
40 | ///
41 | /// Returns at most 7 reviews from the loaded data, or an empty array if no data is available.
42 | var reviews: [Review] {
43 | let allReviews = state.value?.reviews ?? []
44 | return Array(allReviews.prefix(configuration.memojis.count))
45 | }
46 |
47 | /// The average rating of the app.
48 | ///
49 | /// Returns the loaded average rating or 5.0 if no data is available.
50 | var averageRating: Double {
51 | state.value?.averageRating ?? 5.0
52 | }
53 |
54 | /// The total number of ratings for the app.
55 | ///
56 | /// Returns the loaded total ratings count or 0 if no data is available.
57 | var totalRatings: Int {
58 | state.value?.totalRatings ?? 0
59 | }
60 |
61 | /// Indicates whether the view should show a "no ratings" state.
62 | var isShowingNoRatings: Bool {
63 | state.value?.totalRatings == 0 && !state.isLoading
64 | }
65 |
66 | /// Indicates whether the view should show a "no reviews" state.
67 | var isShowingNoReviews: Bool {
68 | reviews.isEmpty && !state.isLoading
69 | }
70 |
71 | /// Creates a new rating request screen view.
72 | ///
73 | /// - Parameters:
74 | ///
75 | /// - appId: The App Store ID of the app.
76 | /// - appRatingProvider: The provider used to fetch app rating data.
77 | /// - primaryButtonAction: Action to perform when the user taps on the primary button.
78 | /// - secondaryButtonAction: Optional action to perform when the user taps on the secondary button.
79 | /// - onError: Optional handler for errors that occur during data fetching.
80 | public init(
81 | configuration: RatingScreenConfiguration = .init(),
82 | appId: String,
83 | appRatingProvider: any AppRatingProviding,
84 | primaryButtonAction: @escaping () -> Void = {},
85 | secondaryButtonAction: (() -> Void)? = nil,
86 | onError: ((Error) -> Void)? = nil
87 | ) {
88 | self.configuration = configuration
89 | self.appId = appId
90 | self.appRatingProvider = appRatingProvider
91 | self.primaryButtonAction = primaryButtonAction
92 | self.secondaryButtonAction = secondaryButtonAction
93 | self.onError = onError
94 | }
95 |
96 | /// Opens the App Store review page and performs the requested rating action.
97 | func ratingRequestAction() {
98 | guard let url = URL?.reviewRequest(for: appId) else { return }
99 |
100 | openURL(url)
101 |
102 | primaryButtonAction()
103 | }
104 |
105 | /// Fetches app rating data asynchronously.
106 | ///
107 | /// Updates the view state based on the result (loading, loaded, or error).
108 | @Sendable func fetchData() async {
109 | state = .loading
110 | do {
111 | let response = try await appRatingProvider.fetch()
112 | withAnimation {
113 | state = .loaded(response)
114 | }
115 | } catch {
116 | state = .error(error.localizedDescription)
117 | onError?(error)
118 | }
119 | }
120 |
121 | /// Retries fetching app rating data after an error occurs.
122 | func tryAgainAction() {
123 | Task {
124 | await fetchData()
125 | }
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/Subviews/MemojisStack.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MemojisStack.swift
3 | //
4 | // Created by James Sedlacek on 3/7/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct MemojisStack: View {
10 | let memojis: [Image]
11 | var body: some View {
12 | HStack(alignment: .center, spacing: -10) {
13 | memoji(at: 1)
14 | .zIndex(3)
15 |
16 | memoji(at: 2)
17 | .zIndex(2)
18 |
19 | memoji(at: 3)
20 | .zIndex(1)
21 | }
22 | }
23 |
24 | @ViewBuilder
25 | private func memoji(at index: Int) -> some View {
26 | if let memoji = memojis[safe: index] {
27 | memoji
28 | .resizable()
29 | .frame(width: 40, height: 40)
30 | .background(.background.secondary)
31 | .clipShape(.circle)
32 | .background(
33 | Circle()
34 | .stroke(.background, lineWidth: 4)
35 | )
36 | }
37 | }
38 | }
39 |
40 | #Preview {
41 | MemojisStack(memojis: .defaultMemojis)
42 | .padding()
43 | }
44 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/Subviews/NoReviewsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NoReviewsView.swift
3 | //
4 | // Created by James Sedlacek on 3/14/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct NoReviewsView: View {
10 | var body: some View {
11 | ContentUnavailableView(.noReviewsYet, symbol: .squareAndPencil)
12 | .aspectRatio(1, contentMode: .fit)
13 | .frame(maxWidth: .infinity, alignment: .center)
14 | .background(
15 | .background.secondary,
16 | in: .rect(cornerRadius: 20)
17 | )
18 | .padding(20)
19 | }
20 | }
21 |
22 | #Preview {
23 | NoReviewsView()
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/Subviews/RatingView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RatingView.swift
3 | //
4 | // Created by James Sedlacek on 3/7/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct RatingView: View {
10 | private let rating: Double
11 | private let starCount: Int = 5
12 | private let spacing: CGFloat = 4
13 |
14 | init(rating: Double) {
15 | self.rating = rating
16 | }
17 |
18 | var body: some View {
19 | VStack(alignment: .center, spacing: 12) {
20 | ratingWithLaurels
21 | starsView
22 | }
23 | .font(.title.bold())
24 | }
25 |
26 | private var ratingWithLaurels: some View {
27 | HStack(spacing: spacing) {
28 | Image(.laurelLeading)
29 |
30 | Text(rating.formatted(
31 | .number.precision(.fractionLength(1))
32 | ))
33 | .monospaced()
34 |
35 | Image(.laurelTrailing)
36 | }
37 | }
38 |
39 | private var starsView: some View {
40 | HStack(spacing: spacing) {
41 | ForEach(1...starCount, id: \.self) { position in
42 | starView(for: position)
43 | }
44 | }
45 | }
46 |
47 | private func starView(for position: Int) -> some View {
48 | Image(.star)
49 | .symbolVariant(.fill)
50 | .imageScale(.small)
51 | .foregroundStyle(starGradient(for: position))
52 | }
53 |
54 | private func starGradient(for position: Int) -> LinearGradient {
55 | LinearGradient(
56 | stops: [
57 | .init(color: .orange, location: getFillRatio(for: position)),
58 | .init(color: .secondary, location: getFillRatio(for: position))
59 | ],
60 | startPoint: .leading,
61 | endPoint: .trailing
62 | )
63 | }
64 |
65 | private func getFillRatio(for position: Int) -> CGFloat {
66 | let fill = Double(position) - rating
67 | return min(max(1 - fill, 0), 1)
68 | }
69 | }
70 |
71 | #Preview {
72 | @Previewable @State var rating: Double = 5.0
73 |
74 | VStack {
75 | RatingView(rating: rating)
76 |
77 | Slider(value: $rating, in: 0...5)
78 | }
79 | .padding(24)
80 | }
81 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/Subviews/ReviewCard.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ReviewCard.swift
3 | //
4 | // Created by James Sedlacek on 3/7/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | struct ReviewCard: View {
10 | let review: Review
11 | let memoji: Image
12 |
13 | init(
14 | review: Review,
15 | memoji: Image
16 | ) {
17 | self.review = review
18 | self.memoji = memoji
19 | }
20 |
21 | var body: some View {
22 | VStack(alignment: .leading, spacing: .zero) {
23 | VStack(alignment: .leading, spacing: 6) {
24 | Text(review.title)
25 | .font(.headline.weight(.semibold))
26 | Text(review.content)
27 | .font(.body)
28 | Spacer(minLength: .zero)
29 | }
30 | HStack(spacing: 12) {
31 | memoji
32 | .resizable()
33 | .frame(width: 40, height: 40)
34 | .background(.background.secondary)
35 | .clipShape(.circle)
36 |
37 | VStack(alignment: .leading, spacing: 6) {
38 | starRatingView
39 |
40 | HStack(spacing: .zero) {
41 | Text(review.author)
42 | .foregroundStyle(.primary)
43 | Text(" • ") + Text(review.date.relativeTime)
44 | .foregroundStyle(.secondary)
45 | }
46 | .font(.caption.weight(.medium))
47 | }
48 | }
49 | }
50 | .padding()
51 | .frame(maxWidth: .infinity, alignment: .leading)
52 | .background(
53 | .background.secondary,
54 | in: .rect(cornerRadius: 12)
55 | )
56 | }
57 |
58 | private var starRatingView: some View {
59 | HStack(spacing: 1.5) {
60 | ForEach(1...5, id: \.self) { index in
61 | Image(.star)
62 | .symbolVariant(index <= review.rating ? .fill : .none)
63 | .imageScale(.small)
64 | .foregroundColor(.orange)
65 | }
66 | }
67 | }
68 | }
69 |
70 | #Preview {
71 | List {
72 | ReviewCard(
73 | review: .mock(),
74 | memoji: Image(.person1)
75 | )
76 | .listRowSeparator(.hidden)
77 |
78 | ReviewCard(
79 | review: .mock(),
80 | memoji: Image(.person1)
81 | )
82 | .redacted(reason: .placeholder)
83 | .listRowSeparator(.hidden)
84 | }
85 | .scrollContentBackground(.hidden)
86 | .listSectionSeparator(.hidden)
87 | .listSectionSpacingIfAvailable()
88 | .listStyle(.plain)
89 | .padding()
90 | }
91 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/RatingRequest/Subviews/TryAgainView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TryAgainView.swift
3 | //
4 | // Created by James Sedlacek on 3/14/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | /// A view that displays an error state with a try again button.
10 | struct TryAgainView: View {
11 | let errorMessage: String
12 | let tryAgainAction: () -> Void
13 |
14 | var body: some View {
15 | ContentUnavailableView {
16 | Label(.networkError, symbol: .exclamationmarkTriangle)
17 | } description: {
18 | Text(errorMessage)
19 | } actions: {
20 | Button(
21 | action: tryAgainAction,
22 | label: {
23 | Text(.tryAgain)
24 | .font(.headline.weight(.semibold))
25 | .frame(height: 42)
26 | .padding(.horizontal)
27 | }
28 | )
29 | .buttonStyle(.bordered)
30 | .buttonBorderShape(.roundedRectangle)
31 | }
32 | .frame(maxWidth: .infinity, maxHeight: .infinity)
33 | .background(.background)
34 | }
35 | }
36 |
37 | #Preview {
38 | TryAgainView(
39 | errorMessage: "Failed to load content",
40 | tryAgainAction: {}
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Memojis/Memojis.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Memojis.swift
3 | //
4 | // Created by James Sedlacek on 3/14/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension [Image] {
10 | /// A collection of default Memoji images.
11 | public static let defaultMemojis: Self = [
12 | Image(.person1),
13 | Image(.person2),
14 | Image(.person3),
15 | Image(.person4),
16 | Image(.person5),
17 | Image(.person6),
18 | Image(.person7),
19 | Image(.person8)
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Strings/Date+RelativeTime.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Date+RelativeTime.swift
3 | //
4 | // Created by James Sedlacek on 3/8/25.
5 | //
6 |
7 | import Foundation
8 | import SwiftUICore
9 |
10 | extension Date {
11 | /// Returns a localized relative time string using the LocalizedStringKey extension
12 | @MainActor
13 | public var relativeTime: LocalizedStringKey {
14 | let calendar = Calendar.current
15 | let now = Date()
16 | let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: self, to: now)
17 |
18 | if let years = components.year, years > 0 {
19 | return years == 1 ? .oneYearAgo : .yearsAgo(years)
20 | }
21 | if let months = components.month, months > 0 {
22 | return months == 1 ? .oneMonthAgo : .monthsAgo(months)
23 | }
24 | if let days = components.day, days > 0 {
25 | return days == 1 ? .oneDayAgo : .daysAgo(days)
26 | }
27 | if let hours = components.hour, hours > 0 {
28 | return hours == 1 ? .oneHourAgo : .hoursAgo(hours)
29 | }
30 | if let minutes = components.minute, minutes > 0 {
31 | return minutes == 1 ? .oneMinuteAgo : .minutesAgo(minutes)
32 | }
33 | return .justNow
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Strings/LocalizedStringKey+.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalizedStringKey+.swift
3 | //
4 | // Created by James Sedlacek on 2/1/25.
5 | //
6 |
7 | import SwiftUICore
8 |
9 | @MainActor
10 | extension LocalizedStringKey {
11 | public static let giveARating = LocalizedStringKey("Give a Rating")
12 | public static let helpUsGrow = LocalizedStringKey("Help Us Grow!")
13 | public static let maybeLater = LocalizedStringKey("Maybe Later")
14 | public static let networkError = LocalizedStringKey("Network Error!")
15 | public static let noRatingsYet = LocalizedStringKey("Be the first to rate us!")
16 | public static let noReviewsYet = LocalizedStringKey("No Reviews Yet")
17 | public static let tryAgain = LocalizedStringKey("Try Again")
18 |
19 | public static func ratings(_ count: Int) -> LocalizedStringKey {
20 | .init("\(count) ratings")
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Strings/LocalizedStringKey+RelativeTime.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LocalizedStringKey+RelativeTime.swift
3 | //
4 | // Created by James Sedlacek on 3/8/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | @MainActor
10 | extension LocalizedStringKey {
11 | // Relative time strings
12 | static let oneYearAgo = LocalizedStringKey("1 year ago")
13 | static func yearsAgo(_ years: Int) -> LocalizedStringKey {
14 | LocalizedStringKey("\(years) years ago")
15 | }
16 |
17 | static let oneMonthAgo = LocalizedStringKey("1 month ago")
18 | static func monthsAgo(_ months: Int) -> LocalizedStringKey {
19 | LocalizedStringKey("\(months) months ago")
20 | }
21 |
22 | static let oneDayAgo = LocalizedStringKey("1 day ago")
23 | static func daysAgo(_ days: Int) -> LocalizedStringKey {
24 | LocalizedStringKey("\(days) days ago")
25 | }
26 |
27 | static let oneHourAgo = LocalizedStringKey("1 hour ago")
28 | static func hoursAgo(_ hours: Int) -> LocalizedStringKey {
29 | LocalizedStringKey("\(hours) hours ago")
30 | }
31 |
32 | static let oneMinuteAgo = LocalizedStringKey("1 minute ago")
33 | static func minutesAgo(_ minutes: Int) -> LocalizedStringKey {
34 | LocalizedStringKey("\(minutes) minutes ago")
35 | }
36 |
37 | static let justNow = LocalizedStringKey("Just now")
38 | }
39 |
40 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Symbols/ContentUnavailableView+SFSymbol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentUnavailableView+SFSymbol.swift
3 | //
4 | // Created by James Sedlacek on 3/10/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension ContentUnavailableView where Label == SwiftUI.Label, Description == Text? {
10 | package nonisolated init(
11 | _ titleKey: LocalizedStringKey,
12 | symbol: Image.SFSymbol,
13 | description: LocalizedStringKey? = nil,
14 | @ViewBuilder actions: () -> Actions = { EmptyView() }
15 | ) {
16 | self.init(
17 | label: {
18 | Label(titleKey, systemImage: symbol.rawValue)
19 | },
20 | description: {
21 | if let description {
22 | Text(description)
23 | }
24 | },
25 | actions: actions
26 | )
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Symbols/Image+SFSymbol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Image+SFSymbol.swift
3 | //
4 | // Created by James Sedlacek on 3/10/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension Image {
10 | package enum SFSymbol: String {
11 | case exclamationmarkTriangle = "exclamationmark.triangle"
12 | case laurelLeading = "laurel.leading"
13 | case laurelTrailing = "laurel.trailing"
14 | case squareAndPencil = "square.and.pencil"
15 | case star
16 | }
17 |
18 | package init(_ symbol: SFSymbol) {
19 | self.init(systemName: symbol.rawValue)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/Symbols/Label+SFSymbol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Label+SFSymbol.swift
3 | //
4 | // Created by James Sedlacek on 3/10/25.
5 | //
6 |
7 | import SwiftUI
8 |
9 | extension Label where Title == Text, Icon == Image {
10 | package nonisolated init(
11 | _ titleKey: LocalizedStringKey,
12 | symbol: Image.SFSymbol
13 | ) {
14 | self.init(titleKey, systemImage: symbol.rawValue)
15 | }
16 |
17 | package nonisolated init(
18 | _ titleKey: String,
19 | symbol: Image.SFSymbol
20 | ) {
21 | self.init(titleKey, systemImage: symbol.rawValue)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/Sources/RatingsKit/Resources/URL+Constants.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URL+Constants.swift
3 | //
4 | // Created by James Sedlacek on 3/10/25.
5 | //
6 |
7 | import Foundation
8 |
9 | extension URL? {
10 | static func reviewRequest(for appId: String) -> URL? {
11 | URL(string: "https://apps.apple.com/app/id\(appId)?action=write-review")
12 | }
13 | }
14 |
--------------------------------------------------------------------------------