krz/hutch

an ios client for sourcehut

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

v3.8.2: 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    private var transactionUpdatesTask: Task<Void, Never>?
 36
 37    init() {
 38        transactionUpdatesTask = Task.detached(priority: .background) {
 39            for await verification in Transaction.updates {
 40                guard case .verified(let transaction) = verification else { continue }
 41                await transaction.finish()
 42            }
 43        }
 44    }
 45
 46    deinit {
 47        transactionUpdatesTask?.cancel()
 48    }
 49
 50    @MainActor
 51    func loadProducts() async {
 52        guard !isLoading else { return }
 53
 54        isLoading = true
 55        errorMessage = nil
 56        defer { isLoading = false }
 57
 58        do {
 59            let fetched = try await Product.products(for: Self.productIDs)
 60            let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
 61            let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] }
 62            let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil }
 63
 64            products = orderedProducts
 65
 66            if !missingProducts.isEmpty {
 67                let missingNames = missingProducts.map(\.displayName).joined(separator: ", ")
 68                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."
 69            }
 70        } catch {
 71            products = []
 72            errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)"
 73        }
 74    }
 75
 76    @MainActor
 77    func purchase(_ product: Product) async {
 78        guard purchasingProductID == nil else { return }
 79
 80        purchasingProductID = product.id
 81        errorMessage = nil
 82        statusMessage = nil
 83        defer { purchasingProductID = nil }
 84
 85        do {
 86            let result = try await product.purchase()
 87            switch result {
 88            case .success(let verification):
 89                switch verification {
 90                case .verified(let transaction):
 91                    await transaction.finish()
 92                    statusMessage = "Purchase completed successfully."
 93                case .unverified(_, let error):
 94                    errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)"
 95                }
 96
 97            case .pending:
 98                statusMessage = "Purchase is pending approval."
 99
100            case .userCancelled:
101                break
102
103            @unknown default:
104                errorMessage = "The App Store returned an unknown purchase result."
105            }
106        } catch {
107            errorMessage = "Purchase failed. \(error.localizedDescription)"
108        }
109    }
110
111    @MainActor
112    func restorePurchases() async {
113        guard !isRestoringPurchases else { return }
114
115        isRestoringPurchases = true
116        errorMessage = nil
117        defer { isRestoringPurchases = false }
118
119        do {
120            try await AppStore.sync()
121            statusMessage = "Purchase history synced with the App Store."
122            await loadProducts()
123        } catch {
124            errorMessage = "Couldn't sync purchases. \(error.localizedDescription)"
125        }
126    }
127
128    @MainActor
129    func clearStatusMessage() {
130        statusMessage = nil
131    }
132
133    func isPurchasing(_ product: Product) -> Bool {
134        purchasingProductID == product.id
135    }
136}