diff --git a/template/Modules/Data/Sources/Extensions/Container+Data.swift b/template/Modules/Data/Sources/Extensions/Container+Data.swift index 6d34a6b5..7b2430f7 100644 --- a/template/Modules/Data/Sources/Extensions/Container+Data.swift +++ b/template/Modules/Data/Sources/Extensions/Container+Data.swift @@ -46,4 +46,8 @@ extension Container { self { NetworkAPI(authenticationInterceptor: self.authenticationInterceptor()) } .singleton } + + public var ratingPromptStorage: Factory { + self { RatingPromptStorage(userDefaultsManager: self.userDefaultsManager()) }.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..5f831e90 --- /dev/null +++ b/template/Modules/Data/Sources/Repositories/RatingPromptStorage.swift @@ -0,0 +1,72 @@ +import Domain +import Foundation + +actor RatingPromptStorage: RatingPromptStorageProtocol { + + private let userDefaultsManager: UserDefaultsManagerProtocol + + init(userDefaultsManager: UserDefaultsManagerProtocol) { + self.userDefaultsManager = userDefaultsManager + } + + 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) + 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() async { + let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + + 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() async { + let currentCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + userDefaultsManager.set(currentCount + 1, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + userDefaultsManager.synchronize() + } + + func recordPromptShown(for appVersion: String) async { + userDefaultsManager.set(appVersion, for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) + userDefaultsManager.synchronize() + } + + func resetCounters() async { + userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.set(0, for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + userDefaultsManager.synchronize() + } + + func clearAllData() async { + 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..ded06cf0 --- /dev/null +++ b/template/Modules/Data/Sources/Services/DefaultRatingPromptPresenter.swift @@ -0,0 +1,21 @@ +// +// DefaultRatingPromptPresenter.swift +// + +import Domain +import StoreKit + +// MARK: - DefaultRatingPromptPresenter + +public actor DefaultRatingPromptPresenter: RatingPromptPresenterProtocol { + + private let storeReviewController: any StoreReviewControllerProtocol + + public init(storeReviewController: any StoreReviewControllerProtocol) { + self.storeReviewController = storeReviewController + } + + public func show() async -> Bool { + return await storeReviewController.requestReview() + } +} 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..a8dd0900 --- /dev/null +++ b/template/Modules/Domain/Sources/Interfaces/RatingPromptPresenterProtocol.swift @@ -0,0 +1,8 @@ +// +// RatingPromptPresenterProtocol.swift +// + +public protocol RatingPromptPresenterProtocol: Sendable { + + func show() async -> Bool +} diff --git a/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift b/template/Modules/Domain/Sources/Interfaces/RatingPromptStorageProtocol.swift new file mode 100644 index 00000000..947838f2 --- /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() async -> RatingPromptData + + /// Increments app launch count and sets first launch date if needed + func recordAppLaunch() async + + /// Increments significant event count + func recordSignificantEvent() async + + /// Records that user was prompted for rating on current version + func recordPromptShown(for appVersion: String) async + + /// Resets tracking counters (typically called after prompt is shown) + func resetCounters() async + + /// Clears all rating prompt related data + func clearAllData() async +} 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/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..46ab692f --- /dev/null +++ b/template/Modules/Domain/Sources/UseCases/RequestRatingPromptUseCase.swift @@ -0,0 +1,51 @@ +// +// 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 await shouldShowRatingPromptUseCase(configuration: configuration) else { return false } + + let didRequestPrompt = await presenter.show() + guard didRequestPrompt else { return false } + + await storage.recordPromptShown(for: currentVersion()) + + if configuration.resetCounterAfterPrompt { + await 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..e0c299dc --- /dev/null +++ b/template/Modules/Domain/Sources/UseCases/ShouldShowRatingPromptUseCase.swift @@ -0,0 +1,51 @@ +// +// 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) async -> 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) async -> Bool { + let data = await storage.getRatingPromptData() + + guard !data.hasBeenPromptedForCurrentVersion(currentVersion) else { + return false + } + + guard data.daysSinceFirstLaunch >= configuration.minimumDaysSinceFirstLaunch else { + return false + } + + let hasEnoughAppLaunches = data.appLaunchCount >= configuration.minimumAppLaunches + let hasEnoughSignificantEvents = data.significantEventCount >= configuration.minimumSignificantEvents + + return hasEnoughAppLaunches || hasEnoughSignificantEvents + } +} + +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..61f7ebe9 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,22 @@ 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() + ) + } + } + + 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 + } +} 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 {}