Files
Reflect/Shared/Services/ReviewRequestManager.swift
2026-02-25 19:23:57 -06:00

149 lines
5.3 KiB
Swift

//
// 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 = 14
/// 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
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
}