diff --git a/Sources/EdgeControl/UI/Components/ConfettiOverlay.swift b/Sources/EdgeControl/UI/Components/ConfettiOverlay.swift new file mode 100644 index 0000000..81cf49a --- /dev/null +++ b/Sources/EdgeControl/UI/Components/ConfettiOverlay.swift @@ -0,0 +1,194 @@ +import SwiftUI + +/// Coordinator other code uses to ask for a confetti burst. Single +/// in-flight burst at a time — celebrate() updates `pendingBurst`, +/// the overlay drains it. +@MainActor +public final class ConfettiCoordinator: ObservableObject { + public static let shared = ConfettiCoordinator() + + public enum BurstKind { + case milestone // MRR boundary crossed — big, gold-heavy + case conversion // Free → Paid — medium, warm palette + case signup // New signup — small puff, mixed colors + } + + @Published public var pendingBurst: BurstKind? + + public func celebrate(_ kind: BurstKind) { + pendingBurst = kind + } + + public func consume() { + pendingBurst = nil + } +} + +/// Visual confetti layer. Rendered as the topmost child of DashboardShell +/// (above Pip + brightness dim). Hit-testing off so it never blocks the +/// dashboard underneath. +public struct ConfettiOverlay: View { + @StateObject private var coord = ConfettiCoordinator.shared + @State private var bursts: [Burst] = [] + + public init() {} + + public var body: some View { + GeometryReader { geo in + ZStack { + ForEach(bursts) { burst in + ConfettiBurstView(burst: burst, viewport: geo.size) + } + } + .allowsHitTesting(false) + .onReceive(coord.$pendingBurst.compactMap { $0 }) { kind in + spawn(kind: kind, viewport: geo.size) + coord.consume() + } + } + .allowsHitTesting(false) + } + + private func spawn(kind: ConfettiCoordinator.BurstKind, viewport: CGSize) { + let burst = Burst.make(kind: kind, viewport: viewport) + bursts.append(burst) + DispatchQueue.main.asyncAfter(deadline: .now() + burst.duration + 0.2) { + bursts.removeAll { $0.id == burst.id } + } + } +} + +// MARK: - Burst model + +private struct Burst: Identifiable { + let id = UUID() + let pieces: [Piece] + let duration: Double + + struct Piece: Identifiable { + let id = UUID() + let color: Color + let startX: CGFloat + let endX: CGFloat + let startY: CGFloat + let endY: CGFloat + let endRotation: Double + let width: CGFloat + let height: CGFloat + let isCircle: Bool + let delay: Double + let fallDuration: Double + } + + static func make(kind: ConfettiCoordinator.BurstKind, viewport: CGSize) -> Burst { + let count: Int + let palette: [Color] + let durationRange: ClosedRange + switch kind { + case .milestone: + count = 180 + palette = [ + Color(red: 1.00, green: 0.80, blue: 0.20), // gold + Color(red: 1.00, green: 0.55, blue: 0.10), // orange + Color(red: 1.00, green: 0.90, blue: 0.55), // pale gold + Color(red: 0.55, green: 0.30, blue: 1.00), // purple + Color(red: 0.20, green: 0.90, blue: 0.50), // green + Color(red: 0.00, green: 0.85, blue: 1.00), // cyan + ] + durationRange = 3.2...4.6 + case .conversion: + count = 120 + palette = [ + Color(red: 0.55, green: 0.30, blue: 1.00), + Color(red: 0.20, green: 0.90, blue: 0.50), + Color(red: 1.00, green: 0.80, blue: 0.20), + Color(red: 0.00, green: 0.85, blue: 1.00), + ] + durationRange = 2.8...4.0 + case .signup: + count = 70 + palette = [ + Color(red: 0.96, green: 0.77, blue: 0.09), + Color(red: 1.00, green: 0.55, blue: 0.30), + Color(red: 0.20, green: 0.90, blue: 0.50), + ] + durationRange = 2.2...3.4 + } + + let width = max(1, viewport.width) + let height = max(1, viewport.height) + + var pieces: [Piece] = [] + for _ in 0.. String { + if iso.isEmpty { return "" } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let d = f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) else { return "" } + let total = Int(now.timeIntervalSince(d)) + let isFuture = total < 0 + let seconds = abs(total) + let formatted: String + if seconds < 60 { + formatted = "\(seconds)s" + } else if seconds < 3600 { + let m = seconds / 60 + let s = seconds % 60 + formatted = s == 0 ? "\(m)m" : "\(m)m \(s)s" + } else if seconds < 86400 { + let h = seconds / 3600 + let m = (seconds % 3600) / 60 + formatted = m == 0 ? "\(h)h" : "\(h)h \(m)m" + } else { + let d = seconds / 86400 + let h = (seconds % 86400) / 3600 + formatted = h == 0 ? "\(d)d" : "\(d)d \(h)h" + } + return isFuture ? "in \(formatted)" : "\(formatted) ago" + } +}