summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/Views/More/AboutView.swift71
-rw-r--r--Hutch/Views/More/TipStoreViewModel.swift118
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
+ }
}