krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

main: Hutch/Views/More/TipStoreViewModel.swift · raw

  1import StoreKit
  2
  3@Observable
  4final class TipStoreViewModel {
  5    enum TipProduct: String, CaseIterable {
  6        case small
  7        case medium
  8        case large
  9
 10        var id: String {
 11            "net.cleberg.hutch.tip.\(rawValue)"
 12        }
 13
 14        var displayName: String {
 15            switch self {
 16            case .small:
 17                "Small Tip"
 18            case .medium:
 19                "Medium Tip"
 20            case .large:
 21                "Large Tip"
 22            }
 23        }
 24    }
 25
 26    static let productIDs = TipProduct.allCases.map(\.id)
 27
 28    var products: [Product] = []
 29    var isLoading = false
 30    var isRestoringPurchases = false
 31    var purchasingProductID: String?
 32    var errorMessage: String?
 33    var statusMessage: String?
 34
 35    // Internal task handle, not observable state; assigned only on the main
 36    // actor and read once from the nonisolated deinit.
 37    @ObservationIgnored nonisolated(unsafe) private var transactionUpdatesTask: Task<Void, Never>?
 38
 39    init() {
 40        transactionUpdatesTask = Task.detached(priority: .background) {
 41            for await verification in Transaction.updates {
 42                guard case .verified(let transaction) = verification else { continue }
 43                await transaction.finish()
 44            }
 45        }
 46    }
 47
 48    deinit {
 49        transactionUpdatesTask?.cancel()
 50    }
 51
 52    @MainActor
 53    func loadProducts() async {
 54        guard !isLoading else { return }
 55
 56        isLoading = true
 57        errorMessage = nil
 58        defer { isLoading = false }
 59
 60        do {
 61            let fetched = try await Product.products(for: Self.productIDs)
 62            let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
 63            let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] }
 64            let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil }
 65
 66            products = orderedProducts
 67
 68            if !missingProducts.isEmpty {
 69                let missingNames = missingProducts.map(\.displayName).joined(separator: ", ")
 70                errorMessage = "Missing products from the App Store response: \(missingNames). Confirm the product identifiers match App Store Connect exactly and that each item is approved or available in sandbox."
 71            }
 72        } catch {
 73            products = []
 74            errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)"
 75        }
 76    }
 77
 78    @MainActor
 79    func purchase(_ product: Product) async {
 80        guard purchasingProductID == nil else { return }
 81
 82        purchasingProductID = product.id
 83        errorMessage = nil
 84        statusMessage = nil
 85        defer { purchasingProductID = nil }
 86
 87        do {
 88            let result = try await product.purchase()
 89            switch result {
 90            case .success(let verification):
 91                switch verification {
 92                case .verified(let transaction):
 93                    await transaction.finish()
 94                    statusMessage = "Purchase completed successfully."
 95                case .unverified(_, let error):
 96                    errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)"
 97                }
 98
 99            case .pending:
100                statusMessage = "Purchase is pending approval."
101
102            case .userCancelled:
103                break
104
105            @unknown default:
106                errorMessage = "The App Store returned an unknown purchase result."
107            }
108        } catch {
109            errorMessage = "Purchase failed. \(error.localizedDescription)"
110        }
111    }
112
113    @MainActor
114    func restorePurchases() async {
115        guard !isRestoringPurchases else { return }
116
117        isRestoringPurchases = true
118        errorMessage = nil
119        defer { isRestoringPurchases = false }
120
121        do {
122            try await AppStore.sync()
123            statusMessage = "Purchase history synced with the App Store."
124            await loadProducts()
125        } catch {
126            errorMessage = "Couldn't sync purchases. \(error.localizedDescription)"
127        }
128    }
129
130    @MainActor
131    func clearStatusMessage() {
132        statusMessage = nil
133    }
134
135    func isPurchasing(_ product: Product) -> Bool {
136        purchasingProductID == product.id
137    }
138}