Files
Reflect/Shared/Services/ReviewRequestManager.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

149 lines
5.3 KiB
Swift

//
// 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<Int> = [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
}