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}