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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,11 @@ struct AboutInfoView: View {
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Divider()
|
||||
.frame(maxWidth: 240)
|
||||
|
||||
TipJarView()
|
||||
|
||||
Text("© \(copyrightYear) Puranjay Savar Mattas")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user