diff options
| author | Christian Cleberg <[email protected]> | 2026-04-19 15:02:43 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-19 15:02:43 -0500 |
| commit | 2199384f887cf5634f3fdcf651eeb9c2c7ed457a (patch) | |
| tree | 20953cc3be5e628618c121ffdd77775f1fbbddee /Hutch | |
| parent | 7d5ca0607c091f76aa29cd6fa8c76cfeb1e02ac8 (diff) | |
| download | hutch-2199384f887cf5634f3fdcf651eeb9c2c7ed457a.tar.gz hutch-2199384f887cf5634f3fdcf651eeb9c2c7ed457a.tar.bz2 hutch-2199384f887cf5634f3fdcf651eeb9c2c7ed457a.zip | |
fix: finish half-completed storekit implementation
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/Views/More/AboutView.swift | 71 | ||||
| -rw-r--r-- | Hutch/Views/More/TipStoreViewModel.swift | 118 |
2 files changed, 169 insertions, 20 deletions
diff --git a/Hutch/Views/More/AboutView.swift b/Hutch/Views/More/AboutView.swift index 887bef1..106c8d2 100644 --- a/Hutch/Views/More/AboutView.swift +++ b/Hutch/Views/More/AboutView.swift @@ -93,12 +93,18 @@ struct AboutView: View { Section { if storeViewModel.isLoading { - ProgressView() + ProgressView("Loading tip options…") .themedRow() } else if storeViewModel.products.isEmpty { - Text("Tips unavailable") - .foregroundStyle(.secondary) - .themedRow() + VStack(alignment: .leading, spacing: 8) { + Text("Tips unavailable") + .font(.headline) + Text(storeViewModel.errorMessage ?? "Hutch couldn't load tip products from the App Store yet.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .themedRow() } else { ForEach(storeViewModel.products, id: \.id) { product in Button { @@ -107,15 +113,49 @@ struct AboutView: View { } } label: { HStack { - Text(product.displayName) + VStack(alignment: .leading, spacing: 2) { + Text(product.displayName) + Text(product.id) + .font(.caption) + .foregroundStyle(.secondary) + } Spacer() - Text(product.displayPrice) - .foregroundStyle(.secondary) + if storeViewModel.isPurchasing(product) { + ProgressView() + .controlSize(.small) + } else { + Text(product.displayPrice) + .foregroundStyle(.secondary) + } } } + .disabled(storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases) + .themedRow() + } + } + + if let errorMessage = storeViewModel.errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(.secondary) .themedRow() + } + + Button("Retry Loading Tips") { + Task { + await storeViewModel.loadProducts() + } + } + .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil) + .themedRow() + + Button(storeViewModel.isRestoringPurchases ? "Syncing Purchases…" : "Restore / Sync Purchases") { + Task { + await storeViewModel.restorePurchases() } } + .disabled(storeViewModel.isLoading || storeViewModel.purchasingProductID != nil || storeViewModel.isRestoringPurchases) + .themedRow() } header: { Text("Support Development") } footer: { @@ -139,6 +179,23 @@ struct AboutView: View { .themedList() .navigationTitle("About") .navigationBarTitleDisplayMode(.inline) + .alert( + "Store Message", + isPresented: Binding( + get: { storeViewModel.statusMessage != nil }, + set: { isPresented in + if !isPresented { + storeViewModel.clearStatusMessage() + } + } + ) + ) { + Button("OK") { + storeViewModel.clearStatusMessage() + } + } message: { + Text(storeViewModel.statusMessage ?? "") + } .task { await storeViewModel.loadProducts() } diff --git a/Hutch/Views/More/TipStoreViewModel.swift b/Hutch/Views/More/TipStoreViewModel.swift index 54868ba..bc909c1 100644 --- a/Hutch/Views/More/TipStoreViewModel.swift +++ b/Hutch/Views/More/TipStoreViewModel.swift @@ -2,43 +2,135 @@ 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>? - private let productIDs = [ - "net.cleberg.hutch.tip.small", - "net.cleberg.hutch.tip.medium", - "net.cleberg.hutch.tip.large" - ] + 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: productIDs) - // Sort by price ascending to maintain small/medium/large order - products = fetched.sorted { $0.price < $1.price } + 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 { - errorMessage = error.localizedDescription + 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): - if case .verified(let transaction) = 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 .userCancelled, .pending: + + case .pending: + statusMessage = "Purchase is pending approval." + + case .userCancelled: break + @unknown default: - break + errorMessage = "The App Store returned an unknown purchase result." } } catch { - errorMessage = error.localizedDescription + 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 + } } |
