Files
FamilyMealPlanner/MealMood/Services/StoreManager.swift
T
2026-02-26 10:27:09 +01:00

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)
}
}