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:
@@ -61,7 +61,10 @@ final class MoodLogger {
|
|||||||
TipsManager.shared.updateStreak(streak)
|
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()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
Shared/Services/ReviewRequestManager.swift
Normal file
148
Shared/Services/ReviewRequestManager.swift
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user