From 27533b8622877e1a8f0d040d76ebdc5b281b795d Mon Sep 17 00:00:00 2001 From: Thinh Truong Date: Tue, 21 Apr 2026 11:33:25 +0700 Subject: [PATCH 1/3] [#698] Add a foundation for Rating Prompt feature --- .../Sources/Extensions/Container+Data.swift | 9 +++ .../UserDefaultsManager/UserDefaultsKey.swift | 6 ++ .../Repositories/RatingPromptStorage.swift | 74 +++++++++++++++++++ .../DefaultRatingPromptPresenter.swift | 49 ++++++++++++ .../Entities/RatingPromptConfiguration.swift | 29 ++++++++ .../Sources/Entities/RatingPromptData.swift | 47 ++++++++++++ .../RatingPromptPresenterProtocol.swift | 9 +++ .../RatingPromptStorageProtocol.swift | 23 ++++++ .../UseCases/CheckForceUpdateUseCase.swift | 4 +- .../UseCases/RequestRatingPromptUseCase.swift | 50 +++++++++++++ .../ShouldShowRatingPromptUseCase.swift | 57 ++++++++++++++ .../Dependencies/Container+Application.swift | 14 ++++ .../Sources/Constants/Constants.swift | 2 + 13 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift create mode 100644 template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift create mode 100644 template/Modules/Domain/Sources/Entities/RatingPromptConfiguration.swift create mode 100644 template/Modules/Domain/Sources/Entities/RatingPromptData.swift create mode 100644 template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift create mode 100644 template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift create mode 100644 template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift create mode 100644 template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift diff --git a/template/Modules/Data/Sources/Extensions/Container+Data.swift b/template/Modules/Data/Sources/Extensions/Container+Data.swift index 6d34a6b5..cdebc198 100644 --- a/template/Modules/Data/Sources/Extensions/Container+Data.swift +++ b/template/Modules/Data/Sources/Extensions/Container+Data.swift @@ -46,4 +46,13 @@ extension Container { self { NetworkAPI(authenticationInterceptor: self.authenticationInterceptor()) } .singleton } + + public var ratingPromptStorage: Factory { + self { RatingPromptStorage(userDefaultsManager: self.userDefaultsManager()) }.singleton + } + + public var ratingPromptPresenter: Factory { + self { DefaultRatingPromptPresenter() }.singleton + } + } diff --git a/template/Modules/Data/Sources/Managers/UserDefaultsManager/UserDefaultsKey.swift b/template/Modules/Data/Sources/Managers/UserDefaultsManager/UserDefaultsKey.swift index e6ed32fd..7fad84d1 100644 --- a/template/Modules/Data/Sources/Managers/UserDefaultsManager/UserDefaultsKey.swift +++ b/template/Modules/Data/Sources/Managers/UserDefaultsManager/UserDefaultsKey.swift @@ -3,4 +3,10 @@ import Foundation public enum UserDefaultsKey: String, CaseIterable, Sendable { case isOnboardingShowed = "UD_IS_ONBOARDING_SHOWED" + + // Rating Prompt + case ratingPromptAppLaunchCount = "UD_RATING_PROMPT_APP_LAUNCH_COUNT" + case ratingPromptFirstLaunchDate = "UD_RATING_PROMPT_FIRST_LAUNCH_DATE" + case ratingPromptLastPromptedVersion = "UD_RATING_PROMPT_LAST_PROMPTED_VERSION" + case ratingPromptSignificantEventCount = "UD_RATING_PROMPT_SIGNIFICANT_EVENT_COUNT" } diff --git a/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift new file mode 100644 index 00000000..f9f9d4ed --- /dev/null +++ b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift @@ -0,0 +1,74 @@ +import Domain +import Foundation + +final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendable { + + private let userDefaultsManager: UserDefaultsManagerProtocol + + init(userDefaultsManager: UserDefaultsManagerProtocol) { + self.userDefaultsManager = userDefaultsManager + } + + func getRatingPromptData() -> RatingPromptData { + let appLaunchCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + let firstLaunchDateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + let lastPromptedVersion = userDefaultsManager.getStringValue(for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) + let significantEventCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + + var firstLaunchDate: Date? + if let dateData = firstLaunchDateData { + firstLaunchDate = try? JSONDecoder().decode(Date.self, from: dateData) + } + + return RatingPromptData( + appLaunchCount: appLaunchCount, + firstLaunchDate: firstLaunchDate, + lastPromptedVersion: lastPromptedVersion, + significantEventCount: significantEventCount + ) + } + + func recordAppLaunch() { + // Increment launch count + let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + + // Set first launch date if not already set + if userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) == nil { + let now = Date() + if let dateData = try? JSONEncoder().encode(now) { + userDefaultsManager.set(dateData, for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + } + } + + userDefaultsManager.synchronize() + } + + func recordSignificantEvent() { + let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + userDefaultsManager.synchronize() + } + + func recordPromptShown(for appVersion: String) { + userDefaultsManager.set(appVersion, for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) + userDefaultsManager.synchronize() + } + + func resetCounters() { + userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + userDefaultsManager.synchronize() + } + + func clearAllData() { + let keys = [ + UserDefaultsKey.ratingPromptAppLaunchCount.rawValue, + UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue, + UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue, + UserDefaultsKey.ratingPromptSignificantEventCount.rawValue + ] + userDefaultsManager.clearData(forKeys: keys) + userDefaultsManager.synchronize() + } +} diff --git a/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift new file mode 100644 index 00000000..785d14b8 --- /dev/null +++ b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift @@ -0,0 +1,49 @@ +// +// DefaultRatingPromptPresenter.swift +// + +import Domain +import StoreKit +import UIKit + +// MARK: - DefaultRatingPromptPresenter + +public final class DefaultRatingPromptPresenter: RatingPromptPresenterProtocol, @unchecked Sendable { + + private let storeReviewController: any StoreReviewControllerProtocol + + public convenience init() { + self.init(storeReviewController: DefaultStoreReviewController()) + } + + init(storeReviewController: any StoreReviewControllerProtocol) { + self.storeReviewController = storeReviewController + } + + @MainActor + public func show() async { + await storeReviewController.requestReview() + } +} + +// MARK: - StoreReviewControllerProtocol + +protocol StoreReviewControllerProtocol: Sendable { + + @MainActor + func requestReview() async +} + +struct DefaultStoreReviewController: StoreReviewControllerProtocol, Sendable { + + @MainActor + func requestReview() async { + guard let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + else { + return + } + SKStoreReviewController.requestReview(in: windowScene) + } +} diff --git a/template/Modules/Domain/Sources/Entities/RatingPromptConfiguration.swift b/template/Modules/Domain/Sources/Entities/RatingPromptConfiguration.swift new file mode 100644 index 00000000..827898e7 --- /dev/null +++ b/template/Modules/Domain/Sources/Entities/RatingPromptConfiguration.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Configuration for rating prompt eligibility rules +public struct RatingPromptConfiguration: Sendable { + + /// Minimum number of days since first app launch before showing prompt + public let minimumDaysSinceFirstLaunch: Int + + /// Minimum number of app launches before showing prompt + public let minimumAppLaunches: Int + + /// Minimum number of significant events before showing prompt + public let minimumSignificantEvents: Int + + /// Whether to reset counter after showing prompt in new version + public let resetCounterAfterPrompt: Bool + + public init( + minimumDaysSinceFirstLaunch: Int = 7, + minimumAppLaunches: Int = 10, + minimumSignificantEvents: Int = 5, + resetCounterAfterPrompt: Bool = true + ) { + self.minimumDaysSinceFirstLaunch = minimumDaysSinceFirstLaunch + self.minimumAppLaunches = minimumAppLaunches + self.minimumSignificantEvents = minimumSignificantEvents + self.resetCounterAfterPrompt = resetCounterAfterPrompt + } +} diff --git a/template/Modules/Domain/Sources/Entities/RatingPromptData.swift b/template/Modules/Domain/Sources/Entities/RatingPromptData.swift new file mode 100644 index 00000000..fa681eec --- /dev/null +++ b/template/Modules/Domain/Sources/Entities/RatingPromptData.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Data model representing rating prompt tracking information +public struct RatingPromptData: Codable, Sendable { + + /// Number of times the app has been launched + public let appLaunchCount: Int + + /// Date when the app was first launched + public let firstLaunchDate: Date? + + /// App version when user was last prompted for rating + public let lastPromptedVersion: String? + + /// Number of significant events tracked + public let significantEventCount: Int + + public init( + appLaunchCount: Int = 0, + firstLaunchDate: Date? = nil, + lastPromptedVersion: String? = nil, + significantEventCount: Int = 0 + ) { + self.appLaunchCount = appLaunchCount + self.firstLaunchDate = firstLaunchDate + self.lastPromptedVersion = lastPromptedVersion + self.significantEventCount = significantEventCount + } +} + +// MARK: - Helper Methods + +public extension RatingPromptData { + + var daysSinceFirstLaunch: Int { + guard let firstLaunchDate else { return 0 } + let calendar = Calendar.current + let now = Date() + let components = calendar.dateComponents([.day], from: firstLaunchDate, to: now) + return components.day ?? 0 + } + + func hasBeenPromptedForCurrentVersion(_ currentVersion: String) -> Bool { + guard let lastPromptedVersion else { return false } + return lastPromptedVersion == currentVersion + } +} diff --git a/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift new file mode 100644 index 00000000..ae2a6a4a --- /dev/null +++ b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift @@ -0,0 +1,9 @@ +// +// RatingPromptPresenterProtocol.swift +// + +public protocol RatingPromptPresenterProtocol: Sendable { + + @MainActor + func show() async +} diff --git a/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift b/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift new file mode 100644 index 00000000..c8f03150 --- /dev/null +++ b/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Protocol for managing rating prompt data persistence +public protocol RatingPromptStorageProtocol: Sendable { + + /// Retrieves current rating prompt data + func getRatingPromptData() -> RatingPromptData + + /// Increments app launch count and sets first launch date if needed + func recordAppLaunch() + + /// Increments significant event count + func recordSignificantEvent() + + /// Records that user was prompted for rating on current version + func recordPromptShown(for appVersion: String) + + /// Resets tracking counters (typically called after prompt is shown) + func resetCounters() + + /// Clears all rating prompt related data + func clearAllData() +} diff --git a/template/Modules/Domain/Sources/UseCases/CheckForceUpdateUseCase.swift b/template/Modules/Domain/Sources/UseCases/CheckForceUpdateUseCase.swift index e443e7e1..6db7aff2 100644 --- a/template/Modules/Domain/Sources/UseCases/CheckForceUpdateUseCase.swift +++ b/template/Modules/Domain/Sources/UseCases/CheckForceUpdateUseCase.swift @@ -30,9 +30,9 @@ public struct CheckForceUpdateUseCase: CheckForceUpdateUseCaseProtocol, Sendable } } -private extension CheckForceUpdateUseCase { +extension CheckForceUpdateUseCase { - static func defaultCurrentVersion() -> AppVersion { + public static func defaultCurrentVersion() -> AppVersion { let string = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" return AppVersion(string: string) ?? AppVersion(major: 1, minor: 0, patch: 0) } diff --git a/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift new file mode 100644 index 00000000..9c32c468 --- /dev/null +++ b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift @@ -0,0 +1,50 @@ +// +// RequestRatingPromptUseCase.swift +// + +import Foundation + +public protocol RequestRatingPromptUseCaseProtocol: Sendable { + + /// Evaluates eligibility and, if eligible, shows the rating prompt. + /// - Returns: `true` if the prompt was shown. + @MainActor + func callAsFunction(configuration: RatingPromptConfiguration) async -> Bool +} + +public struct RequestRatingPromptUseCase: RequestRatingPromptUseCaseProtocol { + + private let storage: any RatingPromptStorageProtocol + private let shouldShowRatingPromptUseCase: any ShouldShowRatingPromptUseCaseProtocol + private let presenter: any RatingPromptPresenterProtocol + private let currentVersion: @Sendable () -> String + + public init( + storage: any RatingPromptStorageProtocol, + shouldShowRatingPromptUseCase: any ShouldShowRatingPromptUseCaseProtocol, + presenter: any RatingPromptPresenterProtocol, + currentVersion: @Sendable @escaping () -> String = { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } + ) { + self.storage = storage + self.shouldShowRatingPromptUseCase = shouldShowRatingPromptUseCase + self.presenter = presenter + self.currentVersion = currentVersion + } + + @MainActor + public func callAsFunction(configuration: RatingPromptConfiguration) async -> Bool { + guard shouldShowRatingPromptUseCase(configuration: configuration) else { return false } + + await presenter.show() + + storage.recordPromptShown(for: currentVersion()) + + if configuration.resetCounterAfterPrompt { + storage.resetCounters() + } + + return true + } +} diff --git a/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift new file mode 100644 index 00000000..1459355b --- /dev/null +++ b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift @@ -0,0 +1,57 @@ +// +// ShouldShowRatingPromptUseCase.swift +// + +import Foundation + +public protocol ShouldShowRatingPromptUseCaseProtocol: Sendable { + + /// Determines if rating prompt should be shown based on configuration rules + /// - Parameter configuration: Rules for determining eligibility + /// - Returns: True if prompt should be shown + func callAsFunction(configuration: RatingPromptConfiguration) -> Bool +} + +public struct ShouldShowRatingPromptUseCase: ShouldShowRatingPromptUseCaseProtocol, Sendable { + + private let storage: any RatingPromptStorageProtocol + private let currentVersion: String + + public init( + storage: any RatingPromptStorageProtocol, + currentVersion: String = Self.defaultCurrentVersion() + ) { + self.storage = storage + self.currentVersion = currentVersion + } + + public func callAsFunction(configuration: RatingPromptConfiguration) -> Bool { + let data = storage.getRatingPromptData() + + if data.hasBeenPromptedForCurrentVersion(currentVersion) { + return false + } + + let daysSinceFirstLaunch = data.daysSinceFirstLaunch + guard daysSinceFirstLaunch >= configuration.minimumDaysSinceFirstLaunch else { + return false + } + + guard data.appLaunchCount >= configuration.minimumAppLaunches else { + return false + } + + guard data.significantEventCount >= configuration.minimumSignificantEvents else { + return false + } + + return true + } +} + +extension ShouldShowRatingPromptUseCase { + + public static func defaultCurrentVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + } +} diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift index 6fcd4a2c..ad6cbc5d 100644 --- a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift @@ -11,4 +11,18 @@ extension Container { var checkForceUpdateUseCase: Factory { self { CheckForceUpdateUseCase(remoteConfigRepository: self.remoteConfigRepository()) } } + + var shouldShowRatingPromptUseCase: Factory { + self { ShouldShowRatingPromptUseCase(storage: self.ratingPromptStorage()) } + } + + var requestRatingPromptUseCase: Factory { + self { + RequestRatingPromptUseCase( + storage: self.ratingPromptStorage(), + shouldShowRatingPromptUseCase: self.shouldShowRatingPromptUseCase(), + presenter: self.ratingPromptPresenter() + ) + } + } } diff --git a/template/{PROJECT_NAME}/Sources/Constants/Constants.swift b/template/{PROJECT_NAME}/Sources/Constants/Constants.swift index 7aa89fe6..70cffe9a 100644 --- a/template/{PROJECT_NAME}/Sources/Constants/Constants.swift +++ b/template/{PROJECT_NAME}/Sources/Constants/Constants.swift @@ -2,6 +2,8 @@ // Constants.swift // +import Foundation + enum Constants { enum API {} From c9a3fbe0677e3e2b60f49ed8eb74ad53e432f97b Mon Sep 17 00:00:00 2001 From: Thinh Truong Date: Tue, 21 Apr 2026 22:26:30 +0700 Subject: [PATCH 2/3] [698] Resolve comments --- .../Sources/Extensions/Container+Data.swift | 5 --- .../Repositories/RatingPromptStorage.swift | 9 +++-- .../DefaultRatingPromptPresenter.swift | 33 ++----------------- .../RatingPromptPresenterProtocol.swift | 2 +- .../StoreReviewControllerProtocol.swift | 9 +++++ .../UseCases/RequestRatingPromptUseCase.swift | 3 +- .../ShouldShowRatingPromptUseCase.swift | 16 +++------ .../Dependencies/Container+Application.swift | 4 +++ .../Services/StoreReviewController.swift | 22 +++++++++++++ 9 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 template/Modules/Domain/Sources/Interfaces/StoreReviewControllerProtocol.swift create mode 100644 template/Tuist/Interfaces/SwiftUI/Sources/Application/Services/StoreReviewController.swift diff --git a/template/Modules/Data/Sources/Extensions/Container+Data.swift b/template/Modules/Data/Sources/Extensions/Container+Data.swift index cdebc198..7b2430f7 100644 --- a/template/Modules/Data/Sources/Extensions/Container+Data.swift +++ b/template/Modules/Data/Sources/Extensions/Container+Data.swift @@ -50,9 +50,4 @@ extension Container { public var ratingPromptStorage: Factory { self { RatingPromptStorage(userDefaultsManager: self.userDefaultsManager()) }.singleton } - - public var ratingPromptPresenter: Factory { - self { DefaultRatingPromptPresenter() }.singleton - } - } diff --git a/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift index f9f9d4ed..7fc52cdd 100644 --- a/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift +++ b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift @@ -4,6 +4,7 @@ import Foundation final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendable { private let userDefaultsManager: UserDefaultsManagerProtocol + private let lock = NSLock() init(userDefaultsManager: UserDefaultsManagerProtocol) { self.userDefaultsManager = userDefaultsManager @@ -29,11 +30,12 @@ final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendabl } func recordAppLaunch() { - // Increment launch count + lock.lock() + defer { lock.unlock() } + let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) - // Set first launch date if not already set if userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) == nil { let now = Date() if let dateData = try? JSONEncoder().encode(now) { @@ -45,6 +47,9 @@ final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendabl } func recordSignificantEvent() { + lock.lock() + defer { lock.unlock() } + let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) userDefaultsManager.synchronize() diff --git a/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift index 785d14b8..d4465571 100644 --- a/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift +++ b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift @@ -4,7 +4,6 @@ import Domain import StoreKit -import UIKit // MARK: - DefaultRatingPromptPresenter @@ -12,38 +11,12 @@ public final class DefaultRatingPromptPresenter: RatingPromptPresenterProtocol, private let storeReviewController: any StoreReviewControllerProtocol - public convenience init() { - self.init(storeReviewController: DefaultStoreReviewController()) - } - - init(storeReviewController: any StoreReviewControllerProtocol) { + public init(storeReviewController: any StoreReviewControllerProtocol) { self.storeReviewController = storeReviewController } @MainActor - public func show() async { - await storeReviewController.requestReview() - } -} - -// MARK: - StoreReviewControllerProtocol - -protocol StoreReviewControllerProtocol: Sendable { - - @MainActor - func requestReview() async -} - -struct DefaultStoreReviewController: StoreReviewControllerProtocol, Sendable { - - @MainActor - func requestReview() async { - guard let windowScene = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first(where: { $0.activationState == .foregroundActive }) - else { - return - } - SKStoreReviewController.requestReview(in: windowScene) + public func show() async -> Bool { + return await storeReviewController.requestReview() } } diff --git a/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift index ae2a6a4a..9286432a 100644 --- a/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift +++ b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift @@ -5,5 +5,5 @@ public protocol RatingPromptPresenterProtocol: Sendable { @MainActor - func show() async + func show() async -> Bool } diff --git a/template/Modules/Domain/Sources/Interfaces/StoreReviewControllerProtocol.swift b/template/Modules/Domain/Sources/Interfaces/StoreReviewControllerProtocol.swift new file mode 100644 index 00000000..f2144735 --- /dev/null +++ b/template/Modules/Domain/Sources/Interfaces/StoreReviewControllerProtocol.swift @@ -0,0 +1,9 @@ +// +// StoreReviewControllerProtocol.swift +// + +public protocol StoreReviewControllerProtocol: Sendable { + + @MainActor + func requestReview() async -> Bool +} diff --git a/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift index 9c32c468..d4eaa4c5 100644 --- a/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift +++ b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift @@ -37,7 +37,8 @@ public struct RequestRatingPromptUseCase: RequestRatingPromptUseCaseProtocol { public func callAsFunction(configuration: RatingPromptConfiguration) async -> Bool { guard shouldShowRatingPromptUseCase(configuration: configuration) else { return false } - await presenter.show() + let didRequestPrompt = await presenter.show() + guard didRequestPrompt else { return false } storage.recordPromptShown(for: currentVersion()) diff --git a/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift index 1459355b..2e7ef2b3 100644 --- a/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift +++ b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift @@ -28,24 +28,18 @@ public struct ShouldShowRatingPromptUseCase: ShouldShowRatingPromptUseCaseProtoc public func callAsFunction(configuration: RatingPromptConfiguration) -> Bool { let data = storage.getRatingPromptData() - if data.hasBeenPromptedForCurrentVersion(currentVersion) { + guard !data.hasBeenPromptedForCurrentVersion(currentVersion) else { return false } - let daysSinceFirstLaunch = data.daysSinceFirstLaunch - guard daysSinceFirstLaunch >= configuration.minimumDaysSinceFirstLaunch else { + guard data.daysSinceFirstLaunch >= configuration.minimumDaysSinceFirstLaunch else { return false } - guard data.appLaunchCount >= configuration.minimumAppLaunches else { - return false - } - - guard data.significantEventCount >= configuration.minimumSignificantEvents else { - return false - } + let hasEnoughAppLaunches = data.appLaunchCount >= configuration.minimumAppLaunches + let hasEnoughSignificantEvents = data.significantEventCount >= configuration.minimumSignificantEvents - return true + return hasEnoughAppLaunches || hasEnoughSignificantEvents } } diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift index ad6cbc5d..61f7ebe9 100644 --- a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Dependencies/Container+Application.swift @@ -25,4 +25,8 @@ extension Container { ) } } + + var ratingPromptPresenter: Factory { + self { DefaultRatingPromptPresenter(storeReviewController: StoreReviewController()) }.singleton + } } diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Application/Services/StoreReviewController.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Services/StoreReviewController.swift new file mode 100644 index 00000000..ef811c2b --- /dev/null +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Application/Services/StoreReviewController.swift @@ -0,0 +1,22 @@ +// +// StoreReviewController.swift +// + +import Domain +import StoreKit +import UIKit + +final class StoreReviewController: StoreReviewControllerProtocol, Sendable { + + @MainActor + func requestReview() async -> Bool { + guard let windowScene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }) + else { + return false + } + SKStoreReviewController.requestReview(in: windowScene) + return true + } +} From b6241aa79f8b4d539aeb1f921b6ebb610794ddaf Mon Sep 17 00:00:00 2001 From: Thinh Truong Date: Thu, 23 Apr 2026 16:22:28 +0700 Subject: [PATCH 3/3] [#698] Resolve comment --- .../Repositories/RatingPromptStorage.swift | 21 +++++++------------ .../DefaultRatingPromptPresenter.swift | 3 +-- .../RatingPromptPresenterProtocol.swift | 1 - .../RatingPromptStorageProtocol.swift | 12 +++++------ .../UseCases/RequestRatingPromptUseCase.swift | 6 +++--- .../ShouldShowRatingPromptUseCase.swift | 6 +++--- 6 files changed, 20 insertions(+), 29 deletions(-) diff --git a/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift index 7fc52cdd..5f831e90 100644 --- a/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift +++ b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift @@ -1,16 +1,15 @@ import Domain import Foundation -final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendable { +actor RatingPromptStorage: RatingPromptStorageProtocol { private let userDefaultsManager: UserDefaultsManagerProtocol - private let lock = NSLock() init(userDefaultsManager: UserDefaultsManagerProtocol) { self.userDefaultsManager = userDefaultsManager } - func getRatingPromptData() -> RatingPromptData { + func getRatingPromptData() async -> RatingPromptData { let appLaunchCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) let firstLaunchDateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) let lastPromptedVersion = userDefaultsManager.getStringValue(for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) @@ -29,10 +28,7 @@ final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendabl ) } - func recordAppLaunch() { - lock.lock() - defer { lock.unlock() } - + func recordAppLaunch() async { let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) @@ -46,27 +42,24 @@ final class RatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendabl userDefaultsManager.synchronize() } - func recordSignificantEvent() { - lock.lock() - defer { lock.unlock() } - + func recordSignificantEvent() async { let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) userDefaultsManager.synchronize() } - func recordPromptShown(for appVersion: String) { + func recordPromptShown(for appVersion: String) async { userDefaultsManager.set(appVersion, for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) userDefaultsManager.synchronize() } - func resetCounters() { + func resetCounters() async { userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) userDefaultsManager.synchronize() } - func clearAllData() { + func clearAllData() async { let keys = [ UserDefaultsKey.ratingPromptAppLaunchCount.rawValue, UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue, diff --git a/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift index d4465571..ded06cf0 100644 --- a/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift +++ b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift @@ -7,7 +7,7 @@ import StoreKit // MARK: - DefaultRatingPromptPresenter -public final class DefaultRatingPromptPresenter: RatingPromptPresenterProtocol, @unchecked Sendable { +public actor DefaultRatingPromptPresenter: RatingPromptPresenterProtocol { private let storeReviewController: any StoreReviewControllerProtocol @@ -15,7 +15,6 @@ public final class DefaultRatingPromptPresenter: RatingPromptPresenterProtocol, self.storeReviewController = storeReviewController } - @MainActor public func show() async -> Bool { return await storeReviewController.requestReview() } diff --git a/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift index 9286432a..a8dd0900 100644 --- a/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift +++ b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift @@ -4,6 +4,5 @@ public protocol RatingPromptPresenterProtocol: Sendable { - @MainActor func show() async -> Bool } diff --git a/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift b/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift index c8f03150..947838f2 100644 --- a/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift +++ b/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift @@ -4,20 +4,20 @@ import Foundation public protocol RatingPromptStorageProtocol: Sendable { /// Retrieves current rating prompt data - func getRatingPromptData() -> RatingPromptData + func getRatingPromptData() async -> RatingPromptData /// Increments app launch count and sets first launch date if needed - func recordAppLaunch() + func recordAppLaunch() async /// Increments significant event count - func recordSignificantEvent() + func recordSignificantEvent() async /// Records that user was prompted for rating on current version - func recordPromptShown(for appVersion: String) + func recordPromptShown(for appVersion: String) async /// Resets tracking counters (typically called after prompt is shown) - func resetCounters() + func resetCounters() async /// Clears all rating prompt related data - func clearAllData() + func clearAllData() async } diff --git a/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift index d4eaa4c5..46ab692f 100644 --- a/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift +++ b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift @@ -35,15 +35,15 @@ public struct RequestRatingPromptUseCase: RequestRatingPromptUseCaseProtocol { @MainActor public func callAsFunction(configuration: RatingPromptConfiguration) async -> Bool { - guard shouldShowRatingPromptUseCase(configuration: configuration) else { return false } + guard await shouldShowRatingPromptUseCase(configuration: configuration) else { return false } let didRequestPrompt = await presenter.show() guard didRequestPrompt else { return false } - storage.recordPromptShown(for: currentVersion()) + await storage.recordPromptShown(for: currentVersion()) if configuration.resetCounterAfterPrompt { - storage.resetCounters() + await storage.resetCounters() } return true diff --git a/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift index 2e7ef2b3..e0c299dc 100644 --- a/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift +++ b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift @@ -9,7 +9,7 @@ public protocol ShouldShowRatingPromptUseCaseProtocol: Sendable { /// Determines if rating prompt should be shown based on configuration rules /// - Parameter configuration: Rules for determining eligibility /// - Returns: True if prompt should be shown - func callAsFunction(configuration: RatingPromptConfiguration) -> Bool + func callAsFunction(configuration: RatingPromptConfiguration) async -> Bool } public struct ShouldShowRatingPromptUseCase: ShouldShowRatingPromptUseCaseProtocol, Sendable { @@ -25,8 +25,8 @@ public struct ShouldShowRatingPromptUseCase: ShouldShowRatingPromptUseCaseProtoc self.currentVersion = currentVersion } - public func callAsFunction(configuration: RatingPromptConfiguration) -> Bool { - let data = storage.getRatingPromptData() + public func callAsFunction(configuration: RatingPromptConfiguration) async -> Bool { + let data = await storage.getRatingPromptData() guard !data.hasBeenPromptedForCurrentVersion(currentVersion) else { return false