From 9030a0884f53b597fab3fa7bc527ea9962c50373 Mon Sep 17 00:00:00 2001 From: imaznation Date: Thu, 21 May 2026 12:38:14 -0700 Subject: [PATCH] add three small reusable UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three self-contained SwiftUI components that proved useful while building dashboard surfaces — each one is one file, no new dependencies, no cross-coupling to anything else in the codebase. Drop in where appropriate or leave on the shelf. TimeAgoText.swift — Self-ticking relative-time text ("5s ago", "3m ago", "2h ago"). Subscribes to a 1-second Timer publisher so it re-renders without the caller having to plumb a clock. PulsingDot.swift — 8pt green dot with a radial pulse halo. Drop-in indicator for "live" / "realtime" state. ConfettiOverlay.swift — Full-screen confetti burst overlay with a global coordinator. Any view can request a burst; the overlay handles particle spawning, physics, and cleanup. Useful for celebrating milestones without scattering animation code across feature widgets. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../UI/Components/ConfettiOverlay.swift | 194 ++++++++++++++++++ .../UI/Components/PulsingDot.swift | 34 +++ .../UI/Components/TimeAgoText.swift | 49 +++++ 3 files changed, 277 insertions(+) create mode 100644 Sources/EdgeControl/UI/Components/ConfettiOverlay.swift create mode 100644 Sources/EdgeControl/UI/Components/PulsingDot.swift create mode 100644 Sources/EdgeControl/UI/Components/TimeAgoText.swift 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" + } +}