From 8ca5735019caecfb4b98ae81d7d0b4c5a152ad7b Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:23:48 +0100 Subject: [PATCH] feat(app): tip jar (StoreKit consumables) in Settings -> About Four consumable IAP tiers - Small (0.99), Medium (2.99), Large (4.99), Generous (9.99), product ids com.psmattas.OutpostApp.tip.{small, medium,large,generous}. TipJarStore loads them via Product.products(for:), purchases via product.purchase(), finishes the transaction immediately on success - consumables have no entitlement to persist or restore (a tip doesn't unlock anything), so there's none of the Transaction.currentEntitlements restore-on-launch logic a real purchase would need. TipJarView shows one button per tier (price + name, StoreKit's own localized display strings) with a "Thank you!" after success or a plain message on failure - no manual retry button, tapping a tier again just re-attempts. Wired into AboutInfoView between the source link and the copyright line. Added Configuration.storekit (4 products matching the IDs above) for local testing in Xcode without needing real App Store Connect products yet - enable it via Edit Scheme -> Run/Preview -> Options -> StoreKit Configuration. Before actually shipping, the same 4 product IDs need to exist for real in App Store Connect (Consumable type, matching reference names) - the local file doesn't create anything there. Not compiler-verified - Outpost app target has no CLI build path. --- Outpost/Configuration.storekit | 74 ++++++++++++++++++++++++ Outpost/Features/About/AboutView.swift | 5 ++ Outpost/Features/About/TipJarView.swift | 75 +++++++++++++++++++++++++ Outpost/Support/TipJarStore.swift | 71 +++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 Outpost/Configuration.storekit create mode 100644 Outpost/Features/About/TipJarView.swift create mode 100644 Outpost/Support/TipJarStore.swift diff --git a/Outpost/Configuration.storekit b/Outpost/Configuration.storekit new file mode 100644 index 0000000..9ce6e12 --- /dev/null +++ b/Outpost/Configuration.storekit @@ -0,0 +1,74 @@ +{ + "identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A", + "nonRenewingSubscriptions" : [], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6", + "localizations" : [ + { + "description" : "A small tip to support Outpost's development.", + "displayName" : "Small Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.small", + "referenceName" : "Small Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : false, + "internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938", + "localizations" : [ + { + "description" : "A medium tip to support Outpost's development.", + "displayName" : "Medium Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.medium", + "referenceName" : "Medium Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "4.99", + "familyShareable" : false, + "internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D", + "localizations" : [ + { + "description" : "A large tip to support Outpost's development.", + "displayName" : "Large Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.large", + "referenceName" : "Large Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "E072C1AC-2719-483E-8A54-F4924808B724", + "localizations" : [ + { + "description" : "A generous tip to support Outpost's development.", + "displayName" : "Generous Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.generous", + "referenceName" : "Generous Tip", + "type" : "Consumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false + }, + "subscriptionGroups" : [], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/Outpost/Features/About/AboutView.swift b/Outpost/Features/About/AboutView.swift index 9137273..4246a88 100644 --- a/Outpost/Features/About/AboutView.swift +++ b/Outpost/Features/About/AboutView.swift @@ -48,6 +48,11 @@ struct AboutInfoView: View { } .font(.callout) + Divider() + .frame(maxWidth: 240) + + TipJarView() + Text("© \(copyrightYear) Puranjay Savar Mattas") .font(.caption2) .foregroundStyle(.tertiary) diff --git a/Outpost/Features/About/TipJarView.swift b/Outpost/Features/About/TipJarView.swift new file mode 100644 index 0000000..ee73611 --- /dev/null +++ b/Outpost/Features/About/TipJarView.swift @@ -0,0 +1,75 @@ +#if os(macOS) +import SwiftUI +import StoreKit + +/// One button per consumable tip tier — no "restore purchases" (nothing to +/// restore, consumables aren't entitlements) and no manual retry: a failed +/// load just shows a message, tapping a tier again re-attempts naturally. +struct TipJarView: View { + @State private var store = TipJarStore() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Support Outpost") + .font(.callout.weight(.semibold)) + + if store.isLoading && store.products.isEmpty { + ProgressView() + .controlSize(.small) + } else if !store.products.isEmpty { + HStack(spacing: 8) { + ForEach(store.products) { product in + tipButton(for: product) + } + } + } + + switch store.purchaseState { + case .thankYou: + Label("Thank you!", systemImage: "heart.fill") + .font(.caption) + .foregroundStyle(.pink) + case .failed(let message): + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + case .idle, .purchasing: + EmptyView() + } + } + .task { await store.loadProductsIfNeeded() } + } + + private func tipButton(for product: Product) -> some View { + Button { + Task { await store.purchase(product) } + } label: { + VStack(spacing: 2) { + if isPurchasing(product) { + ProgressView() + .controlSize(.small) + } else { + Text(product.displayPrice) + .font(.callout.weight(.semibold)) + } + Text(product.displayName) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(minWidth: 64) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + .disabled(isAnyPurchaseInFlight) + } + + private func isPurchasing(_ product: Product) -> Bool { + store.purchaseState == .purchasing(product.id) + } + + private var isAnyPurchaseInFlight: Bool { + if case .purchasing = store.purchaseState { return true } + return false + } +} +#endif diff --git a/Outpost/Support/TipJarStore.swift b/Outpost/Support/TipJarStore.swift new file mode 100644 index 0000000..1374e2c --- /dev/null +++ b/Outpost/Support/TipJarStore.swift @@ -0,0 +1,71 @@ +import StoreKit +import Observation + +/// Backs the tip jar in Settings → About. Consumables only — a tip doesn't +/// unlock anything, so there's no entitlement to persist or restore, and +/// finishing the transaction immediately (rather than checking +/// `Transaction.currentEntitlements` on launch, the way a real purchase +/// would need to) is correct here. +@MainActor +@Observable +final class TipJarStore { + enum PurchaseState: Equatable { + case idle + case purchasing(String) + case thankYou(String) + case failed(String) + } + + /// Must match the consumable In-App Purchase products created in App + /// Store Connect for this app exactly, including the bundle id prefix. + static let productIDs = [ + "com.psmattas.OutpostApp.tip.small", + "com.psmattas.OutpostApp.tip.medium", + "com.psmattas.OutpostApp.tip.large", + "com.psmattas.OutpostApp.tip.generous" + ] + + private(set) var products: [Product] = [] + private(set) var isLoading = false + var purchaseState: PurchaseState = .idle + + /// Tip options don't change during a session — no reason to refetch + /// every time the About screen appears. + func loadProductsIfNeeded() async { + guard products.isEmpty, !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let fetched = try await Product.products(for: Self.productIDs) + // Keep the order defined above (small -> generous), not + // whatever order the App Store happens to return them in. + products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } } + if products.isEmpty { + purchaseState = .failed("Tip options aren't available right now.") + } + } catch { + purchaseState = .failed("Couldn't load tip options. Check your connection and try again.") + } + } + + func purchase(_ product: Product) async { + purchaseState = .purchasing(product.id) + do { + switch try await product.purchase() { + case .success(let verification): + guard case .verified(let transaction) = verification else { + purchaseState = .failed("Couldn't verify this purchase.") + return + } + await transaction.finish() + purchaseState = .thankYou(product.id) + case .userCancelled, .pending: + purchaseState = .idle + @unknown default: + purchaseState = .idle + } + } catch { + purchaseState = .failed("Something went wrong completing the purchase.") + } + } +}