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.") + } + } +}