diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index c02245f..405e9d8 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -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) 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 }