Skip to content

Commit 81e4258

Browse files
committed
[#698] Add a foundation for Rating Prompt feature
1 parent da5d9e5 commit 81e4258

14 files changed

Lines changed: 995 additions & 0 deletions

File tree

template/Modules/Data/Sources/Extensions/Container+Data.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,17 @@ extension Container {
4646
self { NetworkAPI(authenticationInterceptor: self.authenticationInterceptor()) }
4747
.singleton
4848
}
49+
50+
public var ratingPromptStorage: Factory<RatingPromptStorageProtocol> {
51+
self { RatingPromptStorage(userDefaultsManager: self.userDefaultsManager()) }.singleton
52+
}
53+
54+
public var ratingPromptService: Factory<RatingPromptServiceProtocol> {
55+
self {
56+
RatingPromptService(
57+
storage: self.ratingPromptStorage(),
58+
shouldShowUseCase: self.shouldShowRatingPromptUseCase()
59+
)
60+
}.singleton
61+
}
4962
}

template/Modules/Data/Sources/Managers/UserDefaultsManager/UserDefaultsKey.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,10 @@ import Foundation
33
public enum UserDefaultsKey: String, CaseIterable, Sendable {
44

55
case isOnboardingShowed = "UD_IS_ONBOARDING_SHOWED"
6+
7+
// Rating Prompt
8+
case ratingPromptAppLaunchCount = "UD_RATING_PROMPT_APP_LAUNCH_COUNT"
9+
case ratingPromptFirstLaunchDate = "UD_RATING_PROMPT_FIRST_LAUNCH_DATE"
10+
case ratingPromptLastPromptedVersion = "UD_RATING_PROMPT_LAST_PROMPTED_VERSION"
11+
case ratingPromptSignificantEventCount = "UD_RATING_PROMPT_SIGNIFICANT_EVENT_COUNT"
612
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import Domain
2+
import Foundation
3+
4+
final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendable {
5+
6+
private let userDefaultsManager: UserDefaultsManagerProtocol
7+
8+
init(userDefaultsManager: UserDefaultsManagerProtocol) {
9+
self.userDefaultsManager = userDefaultsManager
10+
}
11+
12+
func getRatingPromptData() -> RatingPromptData {
13+
let appLaunchCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
14+
let firstLaunchDateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
15+
let lastPromptedVersion = userDefaultsManager.getStringValue(for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue)
16+
let significantEventCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
17+
18+
var firstLaunchDate: Date?
19+
if let dateData = firstLaunchDateData {
20+
firstLaunchDate = try? JSONDecoder().decode(Date.self, from: dateData)
21+
}
22+
23+
return RatingPromptData(
24+
appLaunchCount: appLaunchCount,
25+
firstLaunchDate: firstLaunchDate,
26+
lastPromptedVersion: lastPromptedVersion,
27+
significantEventCount: significantEventCount
28+
)
29+
}
30+
31+
func recordAppLaunch() {
32+
// Increment launch count
33+
let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
34+
userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
35+
36+
// Set first launch date if not already set
37+
if userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) == nil {
38+
let now = Date()
39+
if let dateData = try? JSONEncoder().encode(now) {
40+
userDefaultsManager.set(dateData, for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
41+
}
42+
}
43+
44+
userDefaultsManager.synchronize()
45+
}
46+
47+
func recordSignificantEvent() {
48+
let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
49+
userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
50+
userDefaultsManager.synchronize()
51+
}
52+
53+
func recordPromptShown(for appVersion: String) {
54+
userDefaultsManager.set(appVersion, for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue)
55+
userDefaultsManager.synchronize()
56+
}
57+
58+
func resetCounters() {
59+
userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
60+
userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
61+
userDefaultsManager.synchronize()
62+
}
63+
64+
func clearAllData() {
65+
let keys = [
66+
UserDefaultsKey.ratingPromptAppLaunchCount.rawValue,
67+
UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue,
68+
UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue,
69+
UserDefaultsKey.ratingPromptSignificantEventCount.rawValue
70+
]
71+
userDefaultsManager.clearData(forKeys: keys)
72+
userDefaultsManager.synchronize()
73+
}
74+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import Domain
2+
import Foundation
3+
import StoreKit
4+
import UIKit
5+
6+
/// Protocol for rating prompt service
7+
public protocol RatingPromptServiceProtocol: Sendable {
8+
9+
/// Evaluates whether to show rating prompt and shows it if eligible
10+
/// - Parameter configuration: Configuration rules for showing prompt
11+
/// - Returns: True if prompt was shown
12+
@MainActor
13+
func evaluateAndShowRatingPrompt(configuration: RatingPromptConfiguration) async -> Bool
14+
15+
/// Records an app launch event
16+
func recordAppLaunch()
17+
18+
/// Records a significant user event
19+
func recordSignificantEvent()
20+
21+
/// Shows rating prompt unconditionally (for testing/manual triggering)
22+
@MainActor
23+
func showRatingPrompt() async
24+
}
25+
26+
public final class RatingPromptService: RatingPromptServiceProtocol, @unchecked Sendable {
27+
28+
private let storage: any RatingPromptStorageProtocol
29+
private let shouldShowUseCase: any ShouldShowRatingPromptUseCaseProtocol
30+
private let storeReviewController: StoreReviewControllerProtocol
31+
32+
public init(
33+
storage: any RatingPromptStorageProtocol,
34+
shouldShowUseCase: any ShouldShowRatingPromptUseCaseProtocol,
35+
storeReviewController: StoreReviewControllerProtocol = DefaultStoreReviewController()
36+
) {
37+
self.storage = storage
38+
self.shouldShowUseCase = shouldShowUseCase
39+
self.storeReviewController = storeReviewController
40+
}
41+
42+
@MainActor
43+
public func evaluateAndShowRatingPrompt(configuration: RatingPromptConfiguration) async -> Bool {
44+
guard shouldShowUseCase(configuration: configuration) else {
45+
return false
46+
}
47+
48+
await showRatingPrompt()
49+
50+
// Record that prompt was shown for current version
51+
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
52+
storage.recordPromptShown(for: currentVersion)
53+
54+
// Reset counters if configured to do so
55+
if configuration.resetCounterAfterPrompt {
56+
storage.resetCounters()
57+
}
58+
59+
return true
60+
}
61+
62+
public func recordAppLaunch() {
63+
storage.recordAppLaunch()
64+
}
65+
66+
public func recordSignificantEvent() {
67+
storage.recordSignificantEvent()
68+
}
69+
70+
@MainActor
71+
public func showRatingPrompt() async {
72+
await storeReviewController.requestReview()
73+
}
74+
}
75+
76+
// MARK: - Store Review Controller Protocol
77+
78+
/// Protocol wrapper for SKStoreReviewController to enable testing
79+
public protocol StoreReviewControllerProtocol: Sendable {
80+
@MainActor
81+
func requestReview() async
82+
}
83+
84+
/// Default implementation using SKStoreReviewController
85+
public struct DefaultStoreReviewController: StoreReviewControllerProtocol, Sendable {
86+
87+
public init() {}
88+
89+
@MainActor
90+
public func requestReview() async {
91+
// Get the current window scene
92+
guard let windowScene = UIApplication.shared.connectedScenes
93+
.compactMap({ $0 as? UIWindowScene })
94+
.first(where: { $0.activationState == .foregroundActive })
95+
else {
96+
return
97+
}
98+
99+
SKStoreReviewController.requestReview(in: windowScene)
100+
}
101+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import Domain
2+
import Nimble
3+
import Quick
4+
import Foundation
5+
@testable import Data
6+
7+
final class RatingPromptStorageSpec: QuickSpec {
8+
9+
override class func spec() {
10+
describe("RatingPromptStorage") {
11+
var mockUserDefaultsManager: MockUserDefaultsManager!
12+
var storage: RatingPromptStorage!
13+
14+
beforeEach {
15+
mockUserDefaultsManager = MockUserDefaultsManager()
16+
storage = RatingPromptStorage(userDefaultsManager: mockUserDefaultsManager)
17+
}
18+
19+
describe("getRatingPromptData") {
20+
context("when no data exists") {
21+
it("returns default data") {
22+
let data = storage.getRatingPromptData()
23+
24+
expect(data.appLaunchCount) == 0
25+
expect(data.firstLaunchDate).to(beNil())
26+
expect(data.lastPromptedVersion).to(beNil())
27+
expect(data.significantEventCount) == 0
28+
}
29+
}
30+
31+
context("when data exists") {
32+
beforeEach {
33+
let now = Date()
34+
let dateData = try! JSONEncoder().encode(now)
35+
36+
mockUserDefaultsManager.intValues[UserDefaultsKey.ratingPromptAppLaunchCount.rawValue] = 5
37+
mockUserDefaultsManager.dataValues[UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue] = dateData
38+
mockUserDefaultsManager.stringValues[UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue] = "1.0.0"
39+
mockUserDefaultsManager.intValues[UserDefaultsKey.ratingPromptSignificantEventCount.rawValue] = 3
40+
}
41+
42+
it("returns correct data") {
43+
let data = storage.getRatingPromptData()
44+
45+
expect(data.appLaunchCount) == 5
46+
expect(data.firstLaunchDate).toNot(beNil())
47+
expect(data.lastPromptedVersion) == "1.0.0"
48+
expect(data.significantEventCount) == 3
49+
}
50+
}
51+
}
52+
53+
describe("recordAppLaunch") {
54+
context("when first launch") {
55+
it("sets launch count to 1 and records first launch date") {
56+
storage.recordAppLaunch()
57+
58+
expect(mockUserDefaultsManager.setCallCount) == 2 // launch count + first launch date
59+
expect(mockUserDefaultsManager.synchronizeCallCount) == 1
60+
}
61+
}
62+
63+
context("when subsequent launch") {
64+
beforeEach {
65+
mockUserDefaultsManager.intValues[UserDefaultsKey.ratingPromptAppLaunchCount.rawValue] = 5
66+
let dateData = try! JSONEncoder().encode(Date())
67+
mockUserDefaultsManager.dataValues[UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue] = dateData
68+
}
69+
70+
it("increments launch count but doesn't change first launch date") {
71+
storage.recordAppLaunch()
72+
73+
expect(mockUserDefaultsManager.setCallCount) == 1 // only launch count
74+
expect(mockUserDefaultsManager.synchronizeCallCount) == 1
75+
}
76+
}
77+
}
78+
79+
describe("recordSignificantEvent") {
80+
it("increments significant event count") {
81+
storage.recordSignificantEvent()
82+
83+
expect(mockUserDefaultsManager.setCallCount) == 1
84+
expect(mockUserDefaultsManager.synchronizeCallCount) == 1
85+
}
86+
}
87+
88+
describe("recordPromptShown") {
89+
it("records the app version") {
90+
storage.recordPromptShown(for: "1.2.0")
91+
92+
expect(mockUserDefaultsManager.setCallCount) == 1
93+
expect(mockUserDefaultsManager.synchronizeCallCount) == 1
94+
}
95+
}
96+
97+
describe("resetCounters") {
98+
it("resets launch and event counters") {
99+
storage.resetCounters()
100+
101+
expect(mockUserDefaultsManager.setCallCount) == 2 // launch count + event count
102+
expect(mockUserDefaultsManager.synchronizeCallCount) == 1
103+
}
104+
}
105+
106+
describe("clearAllData") {
107+
it("clears all rating prompt data") {
108+
storage.clearAllData()
109+
110+
expect(mockUserDefaultsManager.clearDataCallCount) == 1
111+
expect(mockUserDefaultsManager.synchronizeCallCount) == 1
112+
}
113+
}
114+
}
115+
}
116+
}
117+
118+
// MARK: - Mock
119+
120+
final class MockUserDefaultsManager: UserDefaultsManagerProtocol, @unchecked Sendable {
121+
var intValues: [String: Int] = [:]
122+
var stringValues: [String: String] = [:]
123+
var dataValues: [String: Data] = [:]
124+
var boolValues: [String: Bool] = [:]
125+
126+
var setCallCount = 0
127+
var clearDataCallCount = 0
128+
var synchronizeCallCount = 0
129+
130+
func set(_ value: Any?, for key: String) {
131+
setCallCount += 1
132+
}
133+
134+
func setObject<T: Codable>(_ value: T?, key: String) {
135+
setCallCount += 1
136+
}
137+
138+
func getStringValue(for key: String) -> String? {
139+
return stringValues[key]
140+
}
141+
142+
func getBooleanValue(for key: String) -> Bool {
143+
return boolValues[key] ?? false
144+
}
145+
146+
func getIntValue(for key: String) -> Int {
147+
return intValues[key] ?? 0
148+
}
149+
150+
func getArray(for key: String) -> [Any]? {
151+
return nil
152+
}
153+
154+
func getDataValue(for key: String) -> Data? {
155+
return dataValues[key]
156+
}
157+
158+
func getObject<T: Codable>(ofType: T.Type, key: String) -> T? {
159+
return nil
160+
}
161+
162+
func getValue(for key: String) -> Any? {
163+
return nil
164+
}
165+
166+
func getAllKeys(withPrefix prefix: String) -> [String] {
167+
return []
168+
}
169+
170+
func clearData(forKeys keys: [String]) {
171+
clearDataCallCount += 1
172+
}
173+
174+
func clearDataForCommonKeys() {
175+
// Not implemented for this mock
176+
}
177+
178+
func synchronize() {
179+
synchronizeCallCount += 1
180+
}
181+
}

0 commit comments

Comments
 (0)