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:
@@ -298,23 +298,29 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await tipStore.purchase() }
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Leave a tip", systemImage: "heart.fill")
|
||||
.foregroundStyle(.pink)
|
||||
Spacer()
|
||||
Text(tipStore.displayPrice)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
ForEach(TipStore.tiers) { tier in
|
||||
Button {
|
||||
Task { await tipStore.purchase(tier) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(tier.name)
|
||||
Text(tier.blurb)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Text(tipStore.displayPrice(for: tier))
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.disabled(tipStore.purchaseInProgress)
|
||||
}
|
||||
.disabled(tipStore.purchaseInProgress)
|
||||
} header: {
|
||||
Text("Support FuelBoard")
|
||||
} 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 {
|
||||
@@ -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
|
||||
final class TipStore: ObservableObject {
|
||||
/// Product ID for the £4.99 tip (App Store Connect — consumable).
|
||||
static let productID = "com.apt.fuelboard.tip499"
|
||||
/// A purchasable tip tier (App Store Connect — all consumables).
|
||||
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 message: String?
|
||||
|
||||
@@ -585,30 +605,30 @@ final class TipStore: ObservableObject {
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
// Never deliver or finish an untrusted purchase.
|
||||
guard case .verified(let transaction) = update else { return }
|
||||
// Only acknowledge our own consumable (future products get their own).
|
||||
guard transaction.productID == Self.productID else { return }
|
||||
// Only acknowledge our own consumables (future products get their own).
|
||||
guard Self.tiers.contains(where: { $0.id == transaction.productID }) else { return }
|
||||
await transaction.finish()
|
||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
||||
}
|
||||
|
||||
var displayPrice: String {
|
||||
product?.displayPrice ?? "£4.99"
|
||||
func displayPrice(for tier: TipTier) -> String {
|
||||
products[tier.id]?.displayPrice ?? tier.fallbackPrice
|
||||
}
|
||||
|
||||
func load() async {
|
||||
// Refreshes product state on every visit so a newly-approved product
|
||||
// (or a restored transaction) is picked up.
|
||||
// Refreshes product state on every visit so newly-approved products
|
||||
// (or restored transactions) are picked up.
|
||||
do {
|
||||
let products = try await Product.products(for: [Self.productID])
|
||||
product = products.first
|
||||
let fetched = try await Product.products(for: Self.tiers.map(\.id))
|
||||
products = Dictionary(uniqueKeysWithValues: fetched.map { ($0.id, $0) })
|
||||
} catch {
|
||||
// No product yet (sideloaded build) — the button still shows the
|
||||
// intended price and reports the purchase attempt gracefully.
|
||||
product = nil
|
||||
// No products yet (sideloaded build) — the buttons still show the
|
||||
// intended prices and report purchase attempts gracefully.
|
||||
products = [:]
|
||||
}
|
||||
}
|
||||
|
||||
func purchase() async {
|
||||
func purchase(_ tier: TipTier) async {
|
||||
guard !purchaseInProgress else { return }
|
||||
purchaseInProgress = true
|
||||
defer { purchaseInProgress = false }
|
||||
@@ -616,7 +636,7 @@ final class TipStore: ObservableObject {
|
||||
// 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
|
||||
// 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: "")
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user