summaryrefslogtreecommitdiff
path: root/Hutch/Views/More/TipStoreViewModel.swift
blob: bc909c16bd7c458c1ca7204dc8ba9100f08a9a59 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import StoreKit

@Observable
final class TipStoreViewModel {
    enum TipProduct: String, CaseIterable {
        case small
        case medium
        case large

        var id: String {
            "net.cleberg.hutch.tip.\(rawValue)"
        }

        var displayName: String {
            switch self {
            case .small:
                "Small Tip"
            case .medium:
                "Medium Tip"
            case .large:
                "Large Tip"
            }
        }
    }

    static let productIDs = TipProduct.allCases.map(\.id)

    var products: [Product] = []
    var isLoading = false
    var isRestoringPurchases = false
    var purchasingProductID: String?
    var errorMessage: String?
    var statusMessage: String?

    private var transactionUpdatesTask: Task<Void, Never>?

    init() {
        transactionUpdatesTask = Task.detached(priority: .background) {
            for await verification in Transaction.updates {
                guard case .verified(let transaction) = verification else { continue }
                await transaction.finish()
            }
        }
    }

    deinit {
        transactionUpdatesTask?.cancel()
    }

    @MainActor
    func loadProducts() async {
        guard !isLoading else { return }

        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            let fetched = try await Product.products(for: Self.productIDs)
            let productsByID = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
            let orderedProducts = TipProduct.allCases.compactMap { productsByID[$0.id] }
            let missingProducts = TipProduct.allCases.filter { productsByID[$0.id] == nil }

            products = orderedProducts

            if !missingProducts.isEmpty {
                let missingNames = missingProducts.map(\.displayName).joined(separator: ", ")
                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."
            }
        } catch {
            products = []
            errorMessage = "Couldn't load tips from the App Store. \(error.localizedDescription)"
        }
    }

    @MainActor
    func purchase(_ product: Product) async {
        guard purchasingProductID == nil else { return }

        purchasingProductID = product.id
        errorMessage = nil
        statusMessage = nil
        defer { purchasingProductID = nil }

        do {
            let result = try await product.purchase()
            switch result {
            case .success(let verification):
                switch verification {
                case .verified(let transaction):
                    await transaction.finish()
                    statusMessage = "Purchase completed successfully."
                case .unverified(_, let error):
                    errorMessage = "The App Store returned an unverified transaction. \(error.localizedDescription)"
                }

            case .pending:
                statusMessage = "Purchase is pending approval."

            case .userCancelled:
                break

            @unknown default:
                errorMessage = "The App Store returned an unknown purchase result."
            }
        } catch {
            errorMessage = "Purchase failed. \(error.localizedDescription)"
        }
    }

    @MainActor
    func restorePurchases() async {
        guard !isRestoringPurchases else { return }

        isRestoringPurchases = true
        errorMessage = nil
        defer { isRestoringPurchases = false }

        do {
            try await AppStore.sync()
            statusMessage = "Purchase history synced with the App Store."
            await loadProducts()
        } catch {
            errorMessage = "Couldn't sync purchases. \(error.localizedDescription)"
        }
    }

    @MainActor
    func clearStatusMessage() {
        statusMessage = nil
    }

    func isPurchasing(_ product: Product) -> Bool {
        purchasingProductID == product.id
    }
}