This commit is contained in:
Trey t
2025-12-09 23:37:04 -06:00
parent 3a10b4b8d6
commit f2565678be
1587 changed files with 7747 additions and 647 deletions

235
Shared/IAPManager.swift Normal file
View File

@@ -0,0 +1,235 @@
//
// IAPManager.swift
// Feels
//
// Refactored StoreKit 2 subscription manager with clean state model.
//
import Foundation
import StoreKit
import SwiftUI
// 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: - 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
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults)
private var firstLaunchDate = Date()
// MARK: - Private
private var updateListenerTask: Task<Void, Error>?
// MARK: - Computed Properties
var isSubscribed: Bool {
if case .subscribed = state { return true }
return false
}
var hasFullAccess: Bool {
switch state {
case .subscribed, .inTrial:
return true
case .unknown, .trialExpired, .expired:
return false
}
}
var shouldShowPaywall: Bool {
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
func checkSubscriptionStatus() async {
isLoading = true
defer { isLoading = false }
// 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() {
GroupUserDefaults.groupDefaults.set(hasFullAccess, forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
}
/// Restore purchases
func restore() async {
do {
try await AppStore.sync()
await checkSubscriptionStatus()
} catch {
print("Failed to restore purchases: \(error)")
}
}
// MARK: - Private Methods
private func loadProducts() async {
do {
let products = try await Product.products(for: productIdentifiers)
availableProducts = products.filter { $0.type == .autoRenewable }
} catch {
print("Failed to load products: \(error)")
}
}
private func checkForActiveSubscription() async -> Bool {
var foundActiveSubscription = false
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 }
// Found an active subscription
foundActiveSubscription = true
// 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()
}
}
}
}