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>
149 lines
5.3 KiB
Swift
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
|
|
}
|