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.
This commit is contained in:
2026-08-21 01:23:48 +01:00
parent f624ce6c9f
commit 8ca5735019
4 changed files with 225 additions and 0 deletions
+74
View File
@@ -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
}
}
+5
View File
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
} }
.font(.callout) .font(.callout)
Divider()
.frame(maxWidth: 240)
TipJarView()
Text("© \(copyrightYear) Puranjay Savar Mattas") Text("© \(copyrightYear) Puranjay Savar Mattas")
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
+75
View File
@@ -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
+71
View File
@@ -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.")
}
}
}