Files
Termind/Frameworks/TermindApplication/FeatureModels/AppReviewController.swift
T

178 lines
6.5 KiB
Swift

import Foundation
/// One invitation budget for every app surface. StoreKit reports neither
/// presentation nor completion, so persisted records describe attempts, never ratings.
@MainActor
public final class AppReviewController {
public static let reviewURL = URL(string: "https://apps.apple.com/app/id6805856690?action=write-review")!
public enum Location: Equatable {
case workspace(UUID)
case reviewPage(String)
case other
var isWorkspace: Bool {
if case .workspace = self { return true }
return false
}
var isReviewPage: Bool {
if case .reviewPage = self { return true }
return false
}
}
public struct Context: Equatable {
public var location: Location
public var isActive: Bool
public var isBlocked: Bool
public init(location: Location, isActive: Bool, isBlocked: Bool = false) {
self.location = location
self.isActive = isActive
self.isBlocked = isBlocked
}
var canRequest: Bool { isActive && !isBlocked && location.isReviewPage }
}
private struct Pending {
let surfaceID: UUID
let deadline: TimeInterval
}
private let defaults: UserDefaults?
private let version: String
private let now: () -> Date
private let uptime: () -> TimeInterval
private let calendar: Calendar
public let isEnabled: Bool
private var contexts: [UUID: Context] = [:]
private var activeSurfaceID: UUID?
private var lastTick: TimeInterval?
private var workspaceSeconds: TimeInterval = 0
private var pending: Pending?
public init(
defaults: UserDefaults? = nil, version: String = "",
isEnabled: Bool = false, now: @escaping () -> Date = Date.init,
uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime },
calendar: Calendar = .autoupdatingCurrent
) {
self.defaults = defaults
self.version = version
self.isEnabled = isEnabled
self.now = now
self.uptime = uptime
self.calendar = calendar
}
public static func live() -> AppReviewController {
let isTesting = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
return AppReviewController(
defaults: isTesting ? nil : .standard,
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
isEnabled: !isTesting
)
}
/// Called on navigation, native activity changes and foreground timer ticks.
/// Only a workspace-to-list transition can arm a request; becoming eligible,
/// dismissing a sheet, or regaining focus alone never does.
public func update(surfaceID: UUID, context: Context) {
guard isEnabled else { return }
accountForWorkspaceTime()
let previous = contexts[surfaceID]
contexts[surfaceID] = context
if context.isActive {
if activeSurfaceID != surfaceID {
pending = nil
activeSurfaceID = surfaceID
}
recordActiveDay()
} else if activeSurfaceID == surfaceID {
activeSurfaceID = nil
}
if pending?.surfaceID == surfaceID,
!context.canRequest || previous?.location != context.location
{
pending = nil
}
if previous?.isActive == true, previous?.location.isWorkspace == true,
context.canRequest, isEligible
{
pending = Pending(surfaceID: surfaceID, deadline: uptime() + 3)
}
}
public func recordInteraction(surfaceID: UUID) {
accountForWorkspaceTime()
pending = nil
if contexts[surfaceID]?.isActive == true { activeSurfaceID = surfaceID }
}
public func removeSurface(_ surfaceID: UUID) {
accountForWorkspaceTime()
contexts.removeValue(forKey: surfaceID)
if activeSurfaceID == surfaceID { activeSurfaceID = nil }
recordInteraction(surfaceID: surfaceID)
}
public func requestIfReady(surfaceID: UUID, request: () -> Void) {
guard let pending, pending.surfaceID == surfaceID,
uptime() >= pending.deadline, activeSurfaceID == surfaceID,
contexts[surfaceID]?.canRequest == true, isEligible
else { return }
self.pending = nil
var versions = defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []
versions.append(version)
defaults?.set(versions, forKey: SettingsKey.reviewRequestedVersions)
defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt)
request()
}
public func recordManualReview() {
pending = nil
defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt)
}
private var isEligible: Bool {
guard isEnabled, !version.isEmpty, workspaceSeconds >= 300,
let firstUse = defaults?.object(forKey: SettingsKey.reviewFirstUse) as? Date,
now().timeIntervalSince(firstUse) >= 7 * 86_400,
(defaults?.stringArray(forKey: SettingsKey.reviewActiveDays)?.count ?? 0) >= 3,
!(defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []).contains(version)
else { return false }
if let lastAttempt = defaults?.object(forKey: SettingsKey.reviewLastAttempt) as? Date {
return now().timeIntervalSince(lastAttempt) >= 120 * 86_400
}
return true
}
private func accountForWorkspaceTime() {
let tick = uptime()
defer { lastTick = tick }
guard let lastTick, let activeSurfaceID,
let context = contexts[activeSurfaceID], context.isActive, context.location.isWorkspace
else { return }
workspaceSeconds += max(0, tick - lastTick)
}
private func recordActiveDay() {
let date = now()
if defaults?.object(forKey: SettingsKey.reviewFirstUse) == nil {
defaults?.set(date, forKey: SettingsKey.reviewFirstUse)
}
var days = defaults?.stringArray(forKey: SettingsKey.reviewActiveDays) ?? []
// Three distinct local dates prove eligibility; no growing usage log.
guard days.count < 3 else { return }
let parts = calendar.dateComponents([.era, .year, .month, .day], from: date)
let key = "\(parts.era ?? 0)-\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)"
guard !days.contains(key) else { return }
days.append(key)
defaults?.set(days, forKey: SettingsKey.reviewActiveDays)
}
}