Add native iOS in-app review request system

Implement ReviewRequestManager using StoreKit's SKStoreReviewController
to request reviews at moments of user delight:
- Streak milestones (3, 7, 14, 30, 50, 100 days)
- Every 10th mood entry after minimum usage threshold

Includes frequency limiting (60+ days between requests, minimum 5 entries
before first prompt) and tracks request history via UserDefaults.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-21 10:15:36 -06:00
parent 75921732ea
commit 57eec43942
2 changed files with 152 additions and 1 deletions

View File

@@ -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()
}

View File

@@ -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<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
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
}