Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,8 @@ extension Container {
self { NetworkAPI(authenticationInterceptor: self.authenticationInterceptor()) }
.singleton
}

public var ratingPromptStorage: Factory<RatingPromptStorageProtocol> {
self { RatingPromptStorage(userDefaultsManager: self.userDefaultsManager()) }.singleton
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Comment thread
thinh2k1310 marked this conversation as resolved.

/// 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
}
}
47 changes: 47 additions & 0 deletions template/Modules/Domain/Sources/Entities/RatingPromptData.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
//
// RatingPromptPresenterProtocol.swift
//

public protocol RatingPromptPresenterProtocol: Sendable {

func show() async -> Bool
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//
// StoreReviewControllerProtocol.swift
//

public protocol StoreReviewControllerProtocol: Sendable {

@MainActor
func requestReview() async -> Bool
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,22 @@ extension Container {
var checkForceUpdateUseCase: Factory<CheckForceUpdateUseCaseProtocol> {
self { CheckForceUpdateUseCase(remoteConfigRepository: self.remoteConfigRepository()) }
}

var shouldShowRatingPromptUseCase: Factory<ShouldShowRatingPromptUseCaseProtocol> {
self { ShouldShowRatingPromptUseCase(storage: self.ratingPromptStorage()) }
}

var requestRatingPromptUseCase: Factory<RequestRatingPromptUseCaseProtocol> {
self {
RequestRatingPromptUseCase(
storage: self.ratingPromptStorage(),
shouldShowRatingPromptUseCase: self.shouldShowRatingPromptUseCase(),
presenter: self.ratingPromptPresenter()
)
}
}

var ratingPromptPresenter: Factory<RatingPromptPresenterProtocol> {
self { DefaultRatingPromptPresenter(storeReviewController: StoreReviewController()) }.singleton
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading