diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift index a4621db..25e0929 100644 --- a/Shared/MoodLogger.swift +++ b/Shared/MoodLogger.swift @@ -61,7 +61,10 @@ final class MoodLogger { TipsManager.shared.updateStreak(streak) } - // 6. Reload widgets + // 6. Request app review at moments of delight + ReviewRequestManager.shared.onMoodLogged(streak: streak) + + // 7. Reload widgets WidgetCenter.shared.reloadAllTimelines() } diff --git a/Shared/Services/ReviewRequestManager.swift b/Shared/Services/ReviewRequestManager.swift new file mode 100644 index 0000000..508f40c --- /dev/null +++ b/Shared/Services/ReviewRequestManager.swift @@ -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 = [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 +}