tip: show outcome in the shared status banner (auto-dismiss)
Tip purchase results now ride the SAME banner chrome as the network/offline strip above the tabs instead of a hard-to-see Settings footnote. TipStore is lifted to ContentView (the banner owner) and shared down to Settings. Each outcome maps to an icon/tint; auto-dismisses after ~3.5 s; tap to dismiss. Renderer refactored into a shared statusBannerCard(icon:tint:title:subtitle: trailingIcon:action:) used by both the network strip (retry) and tips (x).
This commit is contained in:
@@ -70,6 +70,10 @@ struct ContentView: View {
|
|||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@State private var locationManager = LocationManager()
|
@State private var locationManager = LocationManager()
|
||||||
@StateObject private var monitor = ProximityMonitor()
|
@StateObject private var monitor = ProximityMonitor()
|
||||||
|
/// Owns tip purchases; the single instance is shared down to Settings.
|
||||||
|
/// Its transient outcome rides the SAME status-banner chrome as the
|
||||||
|
/// network/offline strip (ContentView renders it above the tabs).
|
||||||
|
@StateObject private var tipStore = TipStore()
|
||||||
|
|
||||||
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
/// The pool the list draws from. In Cheapest mode the chosen miles radius
|
||||||
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
/// bounds it ("best price within X miles"); in Closest mode the radius is
|
||||||
@@ -461,10 +465,18 @@ struct ContentView: View {
|
|||||||
rootTabView
|
rootTabView
|
||||||
}
|
}
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: tipStore.outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var statusBannerView: some View {
|
private var statusBannerView: some View {
|
||||||
|
if let outcome = tipStore.outcome {
|
||||||
|
// Tip outcomes ride the SAME banner chrome as the network/offline
|
||||||
|
// strip — auto-dismissed by TipStore, tap anywhere to dismiss.
|
||||||
|
tipStatusBanner(outcome)
|
||||||
|
.transition(.asymmetric(insertion: .move(edge: .top).combined(with: .opacity),
|
||||||
|
removal: .opacity))
|
||||||
|
} else {
|
||||||
switch dataStatus {
|
switch dataStatus {
|
||||||
case .offlineDump(let date):
|
case .offlineDump(let date):
|
||||||
let title = offlineTitle(date: date)
|
let title = offlineTitle(date: date)
|
||||||
@@ -477,6 +489,7 @@ struct ContentView: View {
|
|||||||
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||||
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||||
)
|
)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
case .connectionProblem:
|
case .connectionProblem:
|
||||||
statusBanner(
|
statusBanner(
|
||||||
icon: "wifi.exclamationmark",
|
icon: "wifi.exclamationmark",
|
||||||
@@ -485,10 +498,12 @@ struct ContentView: View {
|
|||||||
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||||
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||||
)
|
)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
case .live:
|
case .live:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var rootTabView: some View {
|
private var rootTabView: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -517,13 +532,31 @@ struct ContentView: View {
|
|||||||
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
/// The offline/connection strip: same chrome as the tip banner, but the
|
||||||
/// Tapping retries the live fetch from ANY screen — no pull gesture
|
/// tap retries the live fetch from ANY screen — no pull gesture needed,
|
||||||
/// needed, so the offline banner isn't trapped on the Stations tab.
|
/// so the offline banner isn't trapped on the Stations tab.
|
||||||
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
||||||
Button {
|
statusBannerCard(icon: icon, tint: tint, title: title, subtitle: subtitle,
|
||||||
|
trailingIcon: "arrow.clockwise", accessibilityLabel: accessibilityLabel) {
|
||||||
Task { await refresh(force: true) }
|
Task { await refresh(force: true) }
|
||||||
} label: {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tip outcome, shown through the exact same banner chrome. Unlike the
|
||||||
|
/// network banner there's no retry — tapping dismisses (auto-dismiss in
|
||||||
|
/// TipStore also fires after ~3.5 s).
|
||||||
|
private func tipStatusBanner(_ outcome: TipStore.TipOutcome) -> some View {
|
||||||
|
statusBannerCard(icon: outcome.icon, tint: outcome.tint, title: outcome.message, subtitle: nil,
|
||||||
|
trailingIcon: "xmark", accessibilityLabel: outcome.message) {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||||
|
private func statusBannerCard(icon: String, tint: Color, title: String, subtitle: String?,
|
||||||
|
trailingIcon: String, accessibilityLabel: String,
|
||||||
|
action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 17, weight: .semibold))
|
.font(.system(size: 17, weight: .semibold))
|
||||||
@@ -533,12 +566,14 @@ struct ContentView: View {
|
|||||||
Text(title)
|
Text(title)
|
||||||
.font(.subheadline.weight(.semibold))
|
.font(.subheadline.weight(.semibold))
|
||||||
.foregroundStyle(.primary)
|
.foregroundStyle(.primary)
|
||||||
|
if let subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Image(systemName: "arrow.clockwise")
|
Image(systemName: trailingIcon)
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: 14, weight: .semibold))
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
}
|
}
|
||||||
@@ -557,7 +592,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the current best-in-radius station into the Live Activity.
|
/// Pushes the current best-in-radius station into the Live Activity.
|
||||||
@@ -645,6 +679,7 @@ struct ContentView: View {
|
|||||||
/// within the compiler's type-check budget.
|
/// within the compiler's type-check budget.
|
||||||
private var settingsTab: some View {
|
private var settingsTab: some View {
|
||||||
SettingsView(
|
SettingsView(
|
||||||
|
tipStore: tipStore,
|
||||||
distanceUnit: $distanceUnit,
|
distanceUnit: $distanceUnit,
|
||||||
priceDisplayStyle: $priceDisplayStyle,
|
priceDisplayStyle: $priceDisplayStyle,
|
||||||
alertsFuel: alertsFuel,
|
alertsFuel: alertsFuel,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import WidgetKit
|
|||||||
/// freshness) is debug-only too — moved here so release builds don't expose
|
/// freshness) is debug-only too — moved here so release builds don't expose
|
||||||
/// relay plumbing.
|
/// relay plumbing.
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
/// Shared tip-purchase store — owned by ContentView so its outcome can
|
||||||
|
/// ride the app-wide status banner (above the tabs), not a local card.
|
||||||
|
@ObservedObject var tipStore: TipStore
|
||||||
@Binding var distanceUnit: DistanceUnit
|
@Binding var distanceUnit: DistanceUnit
|
||||||
@Binding var priceDisplayStyle: PriceDisplayStyle
|
@Binding var priceDisplayStyle: PriceDisplayStyle
|
||||||
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
/// The fuel + radius currently configured for alerts (mirrors the Alerts
|
||||||
@@ -53,9 +56,6 @@ struct SettingsView: View {
|
|||||||
/// Clears the armed fence (monitor.clearDebugFence).
|
/// Clears the armed fence (monitor.clearDebugFence).
|
||||||
var onClearDebugFence: () -> Void = {}
|
var onClearDebugFence: () -> Void = {}
|
||||||
|
|
||||||
@StateObject private var tipStore = TipStore()
|
|
||||||
@State private var showTipAlert = false
|
|
||||||
@State private var tipAlertMessage = ""
|
|
||||||
@State private var testAlertMessage: String?
|
@State private var testAlertMessage: String?
|
||||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||||
@@ -359,13 +359,6 @@ struct SettingsView: View {
|
|||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let message = tipStore.message {
|
|
||||||
Section {
|
|
||||||
Text(message)
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -610,9 +603,19 @@ final class TipStore: ObservableObject {
|
|||||||
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/// A banner outcome: the exact copy to show plus its icon + tint.
|
||||||
|
struct TipOutcome: Equatable {
|
||||||
|
let message: String
|
||||||
|
let icon: String
|
||||||
|
let tint: Color
|
||||||
|
}
|
||||||
|
|
||||||
@Published private(set) var products: [String: Product] = [:]
|
@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 outcome: TipOutcome?
|
||||||
|
|
||||||
|
/// Auto-dismiss timer for the outcome banner (replaced on each new outcome).
|
||||||
|
private var dismissTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
/// Listens for transactions that complete OUTSIDE the direct purchase()
|
||||||
/// call — Ask to Buy approvals, payments finished on another device
|
/// call — Ask to Buy approvals, payments finished on another device
|
||||||
@@ -630,6 +633,7 @@ final class TipStore: ObservableObject {
|
|||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
updatesTask?.cancel()
|
updatesTask?.cancel()
|
||||||
|
dismissTask?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||||
@@ -638,7 +642,30 @@ final class TipStore: ObservableObject {
|
|||||||
// Only acknowledge our own consumables (future products get their own).
|
// Only acknowledge our own consumables (future products get their own).
|
||||||
guard Self.tiers.contains(where: { $0.id == transaction.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: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the banner outcome and schedules its auto-dismiss (~3.5 s).
|
||||||
|
/// Re-triggering cancels the previous timer so rapid taps don't collide.
|
||||||
|
private func showOutcome(_ message: String, icon: String, tint: Color) {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
outcome = TipOutcome(message: message, icon: icon, tint: tint)
|
||||||
|
dismissTask = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: 3_500_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
withAnimation(.easeOut(duration: 0.25)) { self?.outcome = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap-to-dismiss — clears the banner and cancels the pending auto-dismiss.
|
||||||
|
func dismissOutcome() {
|
||||||
|
dismissTask?.cancel()
|
||||||
|
dismissTask = nil
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { outcome = nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayPrice(for tier: TipTier) -> String {
|
func displayPrice(for tier: TipTier) -> String {
|
||||||
@@ -667,7 +694,11 @@ final class TipStore: ObservableObject {
|
|||||||
// 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 = products[tier.id] 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: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("The tip isn't available in this build yet — check back after an App Store release.", comment: ""),
|
||||||
|
icon: "info.circle.fill",
|
||||||
|
tint: Color(.secondaryLabel)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,19 +711,35 @@ final class TipStore: ObservableObject {
|
|||||||
// Consume the consumable — otherwise StoreKit re-delivers
|
// Consume the consumable — otherwise StoreKit re-delivers
|
||||||
// it through Transaction.updates on every launch.
|
// it through Transaction.updates on every launch.
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
message = NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("Thank you! Your tip has been received. ⛽", comment: ""),
|
||||||
|
icon: "checkmark.circle.fill",
|
||||||
|
tint: Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158
|
||||||
|
)
|
||||||
case .unverified:
|
case .unverified:
|
||||||
message = NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("The purchase couldn't be verified. Please try again.", comment: ""),
|
||||||
|
icon: "exclamationmark.triangle.fill",
|
||||||
|
tint: .orange
|
||||||
|
)
|
||||||
}
|
}
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
message = nil // silent — the user just closed the sheet
|
dismissOutcome() // silent — the user just closed the sheet
|
||||||
case .pending:
|
case .pending:
|
||||||
message = NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: "")
|
showOutcome(
|
||||||
|
NSLocalizedString("Your tip is pending approval. It'll finish automatically.", comment: ""),
|
||||||
|
icon: "hourglass",
|
||||||
|
tint: Color(red: 0.39, green: 0.82, blue: 1.0) // #64D2FF
|
||||||
|
)
|
||||||
@unknown default:
|
@unknown default:
|
||||||
message = nil
|
dismissOutcome()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
message = String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription)
|
showOutcome(
|
||||||
|
String(format: NSLocalizedString("The tip couldn't be completed: %@", comment: ""), error.localizedDescription),
|
||||||
|
icon: "xmark.circle.fill",
|
||||||
|
tint: .red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user