wip
This commit is contained in:
235
Shared/IAPManager.swift
Normal file
235
Shared/IAPManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user