Add native iOS in-app review request system
Implement ReviewRequestManager using StoreKit's SKStoreReviewController to request reviews at moments of user delight: - Streak milestones (3, 7, 14, 30, 50, 100 days) - Every 10th mood entry after minimum usage threshold Includes frequency limiting (60+ days between requests, minimum 5 entries before first prompt) and tracks request history via UserDefaults. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
148
Shared/Services/ReviewRequestManager.swift
Normal file
148
Shared/Services/ReviewRequestManager.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// ReviewRequestManager.swift
|
||||
// Feels
|
||||
//
|
||||
// Manages in-app review requests using StoreKit.
|
||||
// Follows Apple's guidelines: requests at moments of delight,
|
||||
// limits frequency, and never interrupts critical flows.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
@MainActor
|
||||
final class ReviewRequestManager {
|
||||
static let shared = ReviewRequestManager()
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Minimum mood entries before first review request
|
||||
private let minimumEntriesForReview = 5
|
||||
|
||||
/// Minimum days between review requests
|
||||
private let minimumDaysBetweenRequests = 60
|
||||
|
||||
/// Streak milestones that trigger review consideration
|
||||
private let streakMilestones: Set<Int> = [3, 7, 14, 30, 50, 100]
|
||||
|
||||
// MARK: - UserDefaults Keys
|
||||
|
||||
private enum Keys: String {
|
||||
case lastReviewRequestDate = "feels_lastReviewRequestDate"
|
||||
case totalReviewRequests = "feels_totalReviewRequests"
|
||||
case totalMoodEntriesLogged = "feels_totalMoodEntriesLogged"
|
||||
case lastReviewRequestVersion = "feels_lastReviewRequestVersion"
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Call after a successful mood entry to potentially request a review.
|
||||
/// The manager decides internally whether conditions are met.
|
||||
func onMoodLogged(streak: Int) {
|
||||
incrementEntryCount()
|
||||
|
||||
guard shouldRequestReview(streak: streak) else { return }
|
||||
|
||||
requestReview()
|
||||
}
|
||||
|
||||
/// Force a review request check (e.g., after completing onboarding).
|
||||
/// Still respects frequency limits.
|
||||
func checkForReviewOpportunity() {
|
||||
guard hasMetMinimumUsage() && hasEnoughTimePassed() else { return }
|
||||
requestReview()
|
||||
}
|
||||
|
||||
// MARK: - Private Logic
|
||||
|
||||
private func shouldRequestReview(streak: Int) -> Bool {
|
||||
// Must have minimum entries
|
||||
guard hasMetMinimumUsage() else { return false }
|
||||
|
||||
// Must have enough time since last request
|
||||
guard hasEnoughTimePassed() else { return false }
|
||||
|
||||
// Check if this is a "moment of delight"
|
||||
guard isMomentOfDelight(streak: streak) else { return false }
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func hasMetMinimumUsage() -> Bool {
|
||||
let totalEntries = UserDefaults.standard.integer(forKey: Keys.totalMoodEntriesLogged.rawValue)
|
||||
return totalEntries >= minimumEntriesForReview
|
||||
}
|
||||
|
||||
private func hasEnoughTimePassed() -> Bool {
|
||||
guard let lastRequest = UserDefaults.standard.object(forKey: Keys.lastReviewRequestDate.rawValue) as? Date else {
|
||||
// Never requested before
|
||||
return true
|
||||
}
|
||||
|
||||
let daysSinceLastRequest = Calendar.current.dateComponents([.day], from: lastRequest, to: Date()).day ?? 0
|
||||
return daysSinceLastRequest >= minimumDaysBetweenRequests
|
||||
}
|
||||
|
||||
private func isMomentOfDelight(streak: Int) -> Bool {
|
||||
// Streak milestones are moments of delight
|
||||
if streakMilestones.contains(streak) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Every 10th entry after the first 5 is also a good moment
|
||||
let totalEntries = UserDefaults.standard.integer(forKey: Keys.totalMoodEntriesLogged.rawValue)
|
||||
if totalEntries >= minimumEntriesForReview && totalEntries % 10 == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func incrementEntryCount() {
|
||||
let current = UserDefaults.standard.integer(forKey: Keys.totalMoodEntriesLogged.rawValue)
|
||||
UserDefaults.standard.set(current + 1, forKey: Keys.totalMoodEntriesLogged.rawValue)
|
||||
}
|
||||
|
||||
private func requestReview() {
|
||||
// Get the current window scene
|
||||
guard let windowScene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Record that we're requesting
|
||||
UserDefaults.standard.set(Date(), forKey: Keys.lastReviewRequestDate.rawValue)
|
||||
|
||||
let totalRequests = UserDefaults.standard.integer(forKey: Keys.totalReviewRequests.rawValue)
|
||||
UserDefaults.standard.set(totalRequests + 1, forKey: Keys.totalReviewRequests.rawValue)
|
||||
|
||||
// Store current app version
|
||||
if let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
|
||||
UserDefaults.standard.set(version, forKey: Keys.lastReviewRequestVersion.rawValue)
|
||||
}
|
||||
|
||||
// Request the review - iOS decides whether to actually show it
|
||||
SKStoreReviewController.requestReview(in: windowScene)
|
||||
}
|
||||
|
||||
// MARK: - Debug / Testing
|
||||
|
||||
#if DEBUG
|
||||
func resetForTesting() {
|
||||
UserDefaults.standard.removeObject(forKey: Keys.lastReviewRequestDate.rawValue)
|
||||
UserDefaults.standard.removeObject(forKey: Keys.totalReviewRequests.rawValue)
|
||||
UserDefaults.standard.removeObject(forKey: Keys.totalMoodEntriesLogged.rawValue)
|
||||
UserDefaults.standard.removeObject(forKey: Keys.lastReviewRequestVersion.rawValue)
|
||||
}
|
||||
|
||||
var debugInfo: String {
|
||||
let entries = UserDefaults.standard.integer(forKey: Keys.totalMoodEntriesLogged.rawValue)
|
||||
let requests = UserDefaults.standard.integer(forKey: Keys.totalReviewRequests.rawValue)
|
||||
let lastDate = UserDefaults.standard.object(forKey: Keys.lastReviewRequestDate.rawValue) as? Date
|
||||
return "Entries: \(entries), Requests: \(requests), Last: \(lastDate?.description ?? "never")"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user