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