Tip jar: three tiers with names and blurbs

Splash & Dash (£0.99), Half a Tank (£2.99), Fill 'Er Up (£4.99) —
each a consumable (com.apt.fuelboard.tip099/tip299/tip499) with its own
blurb row. TipStore now loads all three products, prices fall back to
the intended storefront values until ASC products exist, and the
Transaction.updates listener acknowledges any of the three tiers.
Footer copy: 'A tip helps keep this app free and supports development.
Thank you'.
This commit is contained in:
FuelBoard Contributor
2026-08-16 08:34:27 +01:00
parent 2ffa90d879
commit 41e4c913fb
+49 -29
View File
@@ -298,23 +298,29 @@ struct SettingsView: View {
} }
Section { Section {
Button { ForEach(TipStore.tiers) { tier in
Task { await tipStore.purchase() } Button {
} label: { Task { await tipStore.purchase(tier) }
HStack { } label: {
Label("Leave a tip", systemImage: "heart.fill") HStack {
.foregroundStyle(.pink) VStack(alignment: .leading, spacing: 2) {
Spacer() Text(tier.name)
Text(tipStore.displayPrice) Text(tier.blurb)
.foregroundStyle(.secondary) .font(.caption)
.monospacedDigit() .foregroundStyle(.secondary)
}
Spacer()
Text(tipStore.displayPrice(for: tier))
.foregroundStyle(.secondary)
.monospacedDigit()
}
} }
.disabled(tipStore.purchaseInProgress)
} }
.disabled(tipStore.purchaseInProgress)
} header: { } header: {
Text("Support FuelBoard") Text("Support FuelBoard")
} footer: { } footer: {
Text("A small tip helps keep the data relay and app development going. Thank you!") Text("A tip helps keep this app free and supports development. Thank you")
} }
Section { Section {
@@ -554,13 +560,27 @@ struct SettingsView: View {
} }
} }
/// Loads the £4.99 tip product and drives its purchase. /// Loads the three tip tiers and drives their purchases.
@MainActor @MainActor
final class TipStore: ObservableObject { final class TipStore: ObservableObject {
/// Product ID for the £4.99 tip (App Store Connect consumable). /// A purchasable tip tier (App Store Connect all consumables).
static let productID = "com.apt.fuelboard.tip499" struct TipTier: Identifiable {
let id: String // product ID
let name: String
let blurb: String
let fallbackPrice: String
}
@Published private(set) var product: Product? static let tiers: [TipTier] = [
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99"),
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
blurb: "A generous top-up for development.", fallbackPrice: "£2.99"),
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99"),
]
@Published private(set) var products: [String: Product] = [:]
@Published private(set) var purchaseInProgress = false @Published private(set) var purchaseInProgress = false
@Published private(set) var message: String? @Published private(set) var message: String?
@@ -585,30 +605,30 @@ final class TipStore: ObservableObject {
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async { private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
// Never deliver or finish an untrusted purchase. // Never deliver or finish an untrusted purchase.
guard case .verified(let transaction) = update else { return } guard case .verified(let transaction) = update else { return }
// Only acknowledge our own consumable (future products get their own). // Only acknowledge our own consumables (future products get their own).
guard transaction.productID == Self.productID else { return } guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
await transaction.finish() await transaction.finish()
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "") message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
} }
var displayPrice: String { func displayPrice(for tier: TipTier) -> String {
product?.displayPrice ?? "£4.99" products[tier.id]?.displayPrice ?? tier.fallbackPrice
} }
func load() async { func load() async {
// Refreshes product state on every visit so a newly-approved product // Refreshes product state on every visit so newly-approved products
// (or a restored transaction) is picked up. // (or restored transactions) are picked up.
do { do {
let products = try await Product.products(for: [Self.productID]) let fetched = try await Product.products(for: Self.tiers.map(\.id))
product = products.first products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
} catch { } catch {
// No product yet (sideloaded build) the button still shows the // No products yet (sideloaded build) the buttons still show the
// intended price and reports the purchase attempt gracefully. // intended prices and report purchase attempts gracefully.
product = nil products = [:]
} }
} }
func purchase() async { func purchase(_ tier: TipTier) async {
guard !purchaseInProgress else { return } guard !purchaseInProgress else { return }
purchaseInProgress = true purchaseInProgress = true
defer { purchaseInProgress = false } defer { purchaseInProgress = false }
@@ -616,7 +636,7 @@ final class TipStore: ObservableObject {
// If the product hasn't loaded (e.g. not configured in App Store // If the product hasn't loaded (e.g. not configured in App Store
// Connect yet), still allow the attempt so the user sees a clear // Connect yet), still allow the attempt so the user sees a clear
// outcome rather than a dead button. // outcome rather than a dead button.
guard let product else { guard let product = products[tier.id] else {
message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "") message = NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: "")
return return
} }