Files
Reflect/Shared/IAPManager.swift
Trey t bea2d3bbc9 Update Neon colors and show color circles in theme picker
- Update NeonMoodTint to use synthwave colors matching Neon voting style
  (cyan, lime, yellow, orange, magenta)
- Replace text label with 5 color circles in theme preview Colors row
- Remove unused textColor customization code and picker views
- Add .id(moodTint) to Month/Year views for color refresh
- Clean up various unused color-related code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 00:08:01 -06:00

276 lines
8.3 KiB
Swift

//
// IAPManager.swift
// Feels
//
// Refactored StoreKit 2 subscription manager with clean state model.
//
import Foundation
import StoreKit
import SwiftUI
import os.log
// MARK: - Subscription State
enum SubscriptionState: Equatable {
case unknown
case subscribed(expirationDate: Date?, willAutoRenew: Bool)
case inTrial(daysRemaining: Int)
case trialExpired
case expired
}
// MARK: - IAPManager
@MainActor
class IAPManager: ObservableObject {
// MARK: - Shared Instance
/// Shared instance for service-level access (e.g., HealthKit gating)
static let shared = IAPManager()
// MARK: - Debug Toggle
/// Set to `true` to bypass all subscription checks and grant full access (for development only)
/// Set to `false` to test trial/subscription behavior in DEBUG builds
#if DEBUG
static let bypassSubscription = false
#else
static let bypassSubscription = false
#endif
// MARK: - Constants
static let subscriptionGroupID = "2CFE4C4F"
private let productIdentifiers: Set<String> = [
"com.tt.ifeel.IAP.subscriptions.monthly",
"com.tt.ifeel.IAP.subscriptions.yearly"
]
private let trialDays = 30
// MARK: - Published State
@Published private(set) var state: SubscriptionState = .unknown
@Published private(set) var availableProducts: [Product] = []
@Published private(set) var currentProduct: Product? = nil
@Published private(set) var isLoading = false
// MARK: - Storage
/// Reads firstLaunchDate directly from UserDefaults to ensure we always get the latest value
/// (Using @AppStorage in a class doesn't auto-sync when other components update the same key)
private var firstLaunchDate: Date {
get {
GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue) as? Date ?? Date()
}
set {
GroupUserDefaults.groupDefaults.set(newValue, forKey: UserDefaultsStore.Keys.firstLaunchDate.rawValue)
}
}
// MARK: - Private
private var updateListenerTask: Task<Void, Error>?
/// Last time subscription status was checked (for throttling)
private var lastStatusCheckTime: Date?
/// Minimum interval between status checks (5 minutes)
private let statusCheckInterval: TimeInterval = 300
// MARK: - Computed Properties
var isSubscribed: Bool {
if case .subscribed = state { return true }
return false
}
var hasFullAccess: Bool {
if Self.bypassSubscription { return true }
switch state {
case .subscribed, .inTrial:
return true
case .unknown, .trialExpired, .expired:
return false
}
}
var shouldShowPaywall: Bool {
if Self.bypassSubscription { return false }
switch state {
case .trialExpired, .expired:
return true
case .unknown, .subscribed, .inTrial:
return false
}
}
var shouldShowTrialWarning: Bool {
if case .inTrial = state { return true }
return false
}
var daysLeftInTrial: Int {
if case .inTrial(let days) = state { return days }
return 0
}
var trialExpirationDate: Date? {
Calendar.current.date(byAdding: .day, value: trialDays, to: firstLaunchDate)
}
/// Products sorted by price (lowest first)
var sortedProducts: [Product] {
availableProducts.sorted { $0.price < $1.price }
}
// MARK: - Initialization
init() {
updateListenerTask = listenForTransactions()
Task {
await checkSubscriptionStatus()
}
}
deinit {
updateListenerTask?.cancel()
}
// MARK: - Public Methods
/// Check subscription status - call on app launch and when becoming active
/// Throttled to avoid excessive StoreKit calls on rapid foreground transitions
func checkSubscriptionStatus() async {
// Throttle: skip if we checked recently (unless state is unknown)
if state != .unknown,
let lastCheck = lastStatusCheckTime,
Date().timeIntervalSince(lastCheck) < statusCheckInterval {
return
}
// Only update isLoading if value actually changes to avoid unnecessary view updates
if !isLoading { isLoading = true }
defer {
if isLoading { isLoading = false }
lastStatusCheckTime = Date()
}
// Fetch available products
await loadProducts()
// Check for active subscription
let hasActiveSubscription = await checkForActiveSubscription()
if hasActiveSubscription {
// State already set in checkForActiveSubscription
syncSubscriptionStatusToUserDefaults()
return
}
// No active subscription - check trial status
updateTrialState()
}
/// Sync subscription status to UserDefaults for widget access
private func syncSubscriptionStatusToUserDefaults() {
let accessValue = Self.bypassSubscription ? true : hasFullAccess
GroupUserDefaults.groupDefaults.set(accessValue, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
}
/// Restore purchases
func restore() async {
do {
try await AppStore.sync()
await checkSubscriptionStatus()
} catch {
AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)")
}
}
// MARK: - Private Methods
private func loadProducts() async {
do {
let products = try await Product.products(for: productIdentifiers)
availableProducts = products.filter { $0.type == .autoRenewable }
} catch {
AppLogger.iap.error("Failed to load products: \(error.localizedDescription)")
}
}
private func checkForActiveSubscription() async -> Bool {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else { continue }
// Skip revoked transactions
if transaction.revocationDate != nil { continue }
// Check if this is one of our subscription products
guard productIdentifiers.contains(transaction.productID) else { continue }
// Get the product for this transaction
currentProduct = availableProducts.first { $0.id == transaction.productID }
// Get renewal info
if let product = currentProduct,
let subscription = product.subscription,
let statuses = try? await subscription.status {
for status in statuses {
guard case .verified(let renewalInfo) = status.renewalInfo else { continue }
switch status.state {
case .subscribed, .inGracePeriod, .inBillingRetryPeriod:
state = .subscribed(
expirationDate: transaction.expirationDate,
willAutoRenew: renewalInfo.willAutoRenew
)
return true
case .expired, .revoked:
continue
default:
continue
}
}
}
// Fallback if we couldn't get detailed status
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
return true
}
// No active subscription found
currentProduct = nil
return false
}
private func updateTrialState() {
let daysSinceInstall = Calendar.current.dateComponents([.day], from: firstLaunchDate, to: Date()).day ?? 0
let daysRemaining = trialDays - daysSinceInstall
if daysRemaining > 0 {
state = .inTrial(daysRemaining: daysRemaining)
} else {
state = .trialExpired
}
syncSubscriptionStatusToUserDefaults()
}
private func listenForTransactions() -> Task<Void, Error> {
Task.detached { [weak self] in
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
await transaction.finish()
await self?.checkSubscriptionStatus()
}
}
}
}