diff --git a/FuelBoard/SettingsView.swift b/FuelBoard/SettingsView.swift index 1cba09b..5cce7b5 100644 --- a/FuelBoard/SettingsView.swift +++ b/FuelBoard/SettingsView.swift @@ -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? /// 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) 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 + ) } } }