tip: show outcome as auto-dismissing banner (was footnote)
This commit is contained in:
+109
-18
@@ -54,8 +54,6 @@ struct SettingsView: View {
|
||||
var onClearDebugFence: () -> Void = {}
|
||||
|
||||
@StateObject private var tipStore = TipStore()
|
||||
@State private var showTipAlert = false
|
||||
@State private var tipAlertMessage = ""
|
||||
@State private var testAlertMessage: String?
|
||||
/// Hidden developer flag — the Debug section only appears when on. Toggled
|
||||
/// by tapping the About → Version row five times (NOT a user-facing switch).
|
||||
@@ -90,6 +88,44 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tip outcome shown as a floating card pinned above the Settings list —
|
||||
/// same chrome as the app's status banner. Auto-dismissed by TipStore
|
||||
/// after a few seconds; also tappable to dismiss immediately.
|
||||
private func tipOutcomeBanner(_ outcome: TipStore.TipOutcome) -> some View {
|
||||
Button {
|
||||
withAnimation(.easeOut(duration: 0.2)) { tipStore.dismissOutcome() }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: outcome.icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(outcome.tint)
|
||||
.frame(width: 26)
|
||||
Text(outcome.message)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color(.secondarySystemGroupedBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.stroke(outcome.tint.opacity(0.35), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 3)
|
||||
)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(outcome.message)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
@@ -359,15 +395,16 @@ struct SettingsView: View {
|
||||
Text("About")
|
||||
}
|
||||
|
||||
if let message = tipStore.message {
|
||||
Section {
|
||||
Text(message)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: tipStore.outcome)
|
||||
.overlay(alignment: .top) {
|
||||
if let outcome = tipStore.outcome {
|
||||
tipOutcomeBanner(outcome)
|
||||
.padding(.top, 6)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task { await tipStore.load() }
|
||||
}
|
||||
@@ -610,9 +647,19 @@ final class TipStore: ObservableObject {
|
||||
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 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()
|
||||
/// call — Ask to Buy approvals, payments finished on another device
|
||||
@@ -630,6 +677,7 @@ final class TipStore: ObservableObject {
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
dismissTask?.cancel()
|
||||
}
|
||||
|
||||
private func handle(_ update: VerificationResult<StoreKit.Transaction>) async {
|
||||
@@ -638,7 +686,30 @@ final class TipStore: ObservableObject {
|
||||
// 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: "")
|
||||
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 {
|
||||
@@ -667,7 +738,11 @@ final class TipStore: ObservableObject {
|
||||
// Connect yet), still allow the attempt so the user sees a clear
|
||||
// outcome rather than a dead button.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -680,19 +755,35 @@ final class TipStore: ObservableObject {
|
||||
// Consume the consumable — otherwise StoreKit re-delivers
|
||||
// it through Transaction.updates on every launch.
|
||||
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:
|
||||
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:
|
||||
message = nil // silent — the user just closed the sheet
|
||||
dismissOutcome() // silent — the user just closed the sheet
|
||||
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:
|
||||
message = nil
|
||||
dismissOutcome()
|
||||
}
|
||||
} 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