211 lines
6.1 KiB
Swift
211 lines
6.1 KiB
Swift
import StoreKit
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class StoreManager: ObservableObject {
|
|
enum ProductLoadState {
|
|
case idle
|
|
case loading
|
|
case loaded
|
|
case timedOut
|
|
case notFound
|
|
case failed
|
|
}
|
|
|
|
enum PurchaseResult {
|
|
case success
|
|
case cancelled
|
|
case pending
|
|
case failed
|
|
}
|
|
|
|
@Published var products: [Product] = []
|
|
@Published var isPremium: Bool = false
|
|
@Published var isLoading: Bool = false
|
|
@Published var isLoadingProducts: Bool = false
|
|
@Published var productLoadState: ProductLoadState = .idle
|
|
@Published var debugLoadedProductIds: [String] = []
|
|
@Published var debugLoadedProducts: [String] = []
|
|
|
|
static let monthlyProductId = "com.mealmood.premium.monthly"
|
|
private var productIds: [String] {
|
|
var ids = [Self.monthlyProductId]
|
|
|
|
if let bundleId = Bundle.main.bundleIdentifier {
|
|
ids.append("\(bundleId).premium.monthly")
|
|
}
|
|
|
|
var seen = Set<String>()
|
|
return ids.filter { seen.insert($0).inserted }
|
|
}
|
|
|
|
init() {
|
|
Task {
|
|
await loadProducts()
|
|
await checkPremiumStatus()
|
|
}
|
|
}
|
|
|
|
func loadProducts() async {
|
|
isLoadingProducts = true
|
|
productLoadState = .loading
|
|
defer { isLoadingProducts = false }
|
|
do {
|
|
let fetchedProducts = try await loadProductsWithRetries()
|
|
products = fetchedProducts
|
|
debugLoadedProductIds = fetchedProducts.map(\.id)
|
|
debugLoadedProducts = fetchedProducts.map { "\($0.id) [\($0.type)]" }
|
|
productLoadState = fetchedProducts.isEmpty ? .notFound : .loaded
|
|
} catch {
|
|
if error is TimeoutError {
|
|
productLoadState = .timedOut
|
|
} else {
|
|
productLoadState = .failed
|
|
}
|
|
print("Failed to load products: \(error)")
|
|
}
|
|
}
|
|
|
|
func purchase(_ product: Product) async -> PurchaseResult {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let result = try await product.purchase()
|
|
switch result {
|
|
case .success(let verification):
|
|
if case .verified(let transaction) = verification {
|
|
await transaction.finish()
|
|
isPremium = true
|
|
return .success
|
|
}
|
|
return .failed
|
|
case .userCancelled:
|
|
return .cancelled
|
|
case .pending:
|
|
return .pending
|
|
@unknown default:
|
|
return .failed
|
|
}
|
|
} catch {
|
|
print("Purchase failed: \(error)")
|
|
return .failed
|
|
}
|
|
}
|
|
|
|
func restorePurchases() async {
|
|
isLoading = true
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
try await AppStore.sync()
|
|
await checkPremiumStatus()
|
|
} catch {
|
|
print("Restore failed: \(error)")
|
|
}
|
|
}
|
|
|
|
private func checkPremiumStatus() async {
|
|
isPremium = await Self.hasActiveSubscription(productIds: productIds)
|
|
}
|
|
|
|
var monthlyProduct: Product? {
|
|
if let match = products.first(where: { product in
|
|
product.id == Self.monthlyProductId && product.type == .autoRenewable
|
|
}) {
|
|
return match
|
|
}
|
|
|
|
if let match = products.first(where: { $0.id == Self.monthlyProductId }) {
|
|
return match
|
|
}
|
|
|
|
if let match = products.first(where: { $0.id.contains("monthly") && $0.type == .autoRenewable }) {
|
|
return match
|
|
}
|
|
|
|
if let match = products.first(where: { $0.type == .autoRenewable }) {
|
|
return match
|
|
}
|
|
|
|
return products.first
|
|
}
|
|
|
|
var debugProductIds: [String] { productIds }
|
|
|
|
static func hasActiveSubscription(productIds: [String] = [
|
|
StoreManager.monthlyProductId
|
|
]) async -> Bool {
|
|
for await result in Transaction.currentEntitlements {
|
|
if case .verified(let transaction) = result,
|
|
productIds.contains(transaction.productID),
|
|
transaction.revocationDate == nil {
|
|
if let expirationDate = transaction.expirationDate, expirationDate < Date() {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private struct TimeoutError: Error {}
|
|
|
|
private func loadProductsWithRetries(maxAttempts: Int = 3) async throws -> [Product] {
|
|
var lastProducts: [Product] = []
|
|
|
|
for attempt in 1...maxAttempts {
|
|
let products = try await loadProductsWithTimeout(seconds: 12)
|
|
if !products.isEmpty {
|
|
return products
|
|
}
|
|
lastProducts = products
|
|
|
|
if attempt < maxAttempts {
|
|
try await Task.sleep(nanoseconds: 800_000_000)
|
|
}
|
|
}
|
|
|
|
return lastProducts
|
|
}
|
|
|
|
private func loadProductsWithTimeout(seconds: UInt64) async throws -> [Product] {
|
|
let ids = productIds
|
|
return try await withThrowingTaskGroup(of: [Product].self) { group in
|
|
group.addTask {
|
|
try await self.loadProductsBySingleID(ids: ids)
|
|
}
|
|
group.addTask {
|
|
try await Task.sleep(nanoseconds: seconds * 1_000_000_000)
|
|
throw TimeoutError()
|
|
}
|
|
|
|
let firstResult = try await group.next() ?? []
|
|
group.cancelAll()
|
|
return firstResult
|
|
}
|
|
}
|
|
|
|
private func loadProductsBySingleID(ids: [String]) async throws -> [Product] {
|
|
var merged: [String: Product] = [:]
|
|
|
|
// First, attempt a single batch request with all IDs.
|
|
let batchItems = try await Product.products(for: ids)
|
|
for item in batchItems {
|
|
merged[item.id] = item
|
|
}
|
|
if !merged.isEmpty {
|
|
return Array(merged.values)
|
|
}
|
|
|
|
// Fallback to one-by-one requests for better resilience/debugging.
|
|
for id in ids {
|
|
let items = try await Product.products(for: [id])
|
|
for item in items {
|
|
merged[item.id] = item
|
|
}
|
|
}
|
|
return Array(merged.values)
|
|
}
|
|
}
|