// // ReviewRequestManager.swift // Reflect // // 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 = 14 /// Streak milestones that trigger review consideration private let streakMilestones: Set = [3, 7, 14, 30, 50, 100] // MARK: - UserDefaults Keys private enum Keys: String { case lastReviewRequestDate = "reflect_lastReviewRequestDate" case totalReviewRequests = "reflect_totalReviewRequests" case totalMoodEntriesLogged = "reflect_totalMoodEntriesLogged" case lastReviewRequestVersion = "reflect_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 AppStore.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 }