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}