Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions Sources/EdgeControl/UI/Components/ConfettiOverlay.swift
Original file line number Diff line number Diff line change
@@ -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<Double>
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..<count {
let startX = CGFloat.random(in: 0...width)
let drift = CGFloat.random(in: -120...120)
let endX = max(0, min(width, startX + drift))
let isCircle = Bool.random()
let isLarge = Double.random(in: 0...1) < 0.2
let baseW = CGFloat.random(in: isLarge ? 9...14 : 5...9)
let pieceW = isCircle ? baseW : baseW
let pieceH = isCircle ? baseW : baseW * CGFloat.random(in: 0.4...0.7)
let dur = Double.random(in: durationRange)
pieces.append(Piece(
color: palette.randomElement()!,
startX: startX,
endX: endX,
startY: -CGFloat.random(in: 20...80),
endY: height + 40,
endRotation: Double.random(in: -540...540),
width: pieceW,
height: pieceH,
isCircle: isCircle,
delay: Double.random(in: 0...0.55),
fallDuration: dur
))
}
return Burst(pieces: pieces, duration: durationRange.upperBound + 0.6)
}
}

private struct ConfettiBurstView: View {
let burst: Burst
let viewport: CGSize

var body: some View {
ZStack {
ForEach(burst.pieces) { p in
PieceView(piece: p)
}
}
.frame(width: viewport.width, height: viewport.height, alignment: .topLeading)
.clipped()
}
}

private struct PieceView: View {
let piece: Burst.Piece
@State private var animated = false

var body: some View {
Group {
if piece.isCircle {
Circle().fill(piece.color)
} else {
Rectangle().fill(piece.color)
}
}
.frame(width: piece.width, height: piece.height)
.rotationEffect(.degrees(animated ? piece.endRotation : 0))
.position(
x: animated ? piece.endX : piece.startX,
y: animated ? piece.endY : piece.startY
)
.opacity(animated ? 0 : 1)
.onAppear {
// Slight delay then animate to end — eases the fall, fades out
// near the bottom, spins along the way.
DispatchQueue.main.asyncAfter(deadline: .now() + piece.delay) {
withAnimation(.easeIn(duration: piece.fallDuration)) {
animated = true
}
}
}
}
}
34 changes: 34 additions & 0 deletions Sources/EdgeControl/UI/Components/PulsingDot.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import SwiftUI

/// 8pt green dot with a radial pulse halo. Drop-in for "live / realtime"
/// indicators like the active-users pill.
public struct PulsingDot: View {
public var color: Color = Color(red: 0.20, green: 0.90, blue: 0.50)
public var size: CGFloat = 8

@State private var pulsing = false

public init(color: Color = Color(red: 0.20, green: 0.90, blue: 0.50), size: CGFloat = 8) {
self.color = color
self.size = size
}

public var body: some View {
ZStack {
Circle()
.fill(color.opacity(0.5))
.frame(width: size * 2, height: size * 2)
.scaleEffect(pulsing ? 1.0 : 0.5)
.opacity(pulsing ? 0 : 0.8)
Circle()
.fill(color)
.frame(width: size, height: size)
}
.frame(width: size * 2, height: size * 2)
.onAppear {
withAnimation(.easeOut(duration: 1.4).repeatForever(autoreverses: false)) {
pulsing = true
}
}
}
}
49 changes: 49 additions & 0 deletions Sources/EdgeControl/UI/Components/TimeAgoText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import SwiftUI

/// Self-ticking relative-time text. Subscribes to a 1-second timer so
/// "21s ago" actually ticks forward every second. For sub-hour ages
/// shows minute+second resolution ("1m 23s ago"). For longer ages
/// shows hour+minute or day+hour. Future timestamps render as
/// "in 5m 12s" / "in 2h 30m" / etc.
public struct TimeAgoText: View {
public let iso: String

@State private var now: Date = Date()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()

public init(_ iso: String) {
self.iso = iso
}

public var body: some View {
Text(TimeAgoText.format(iso: iso, now: now))
.onReceive(timer) { tick in now = tick }
}

public static func format(iso: String, now: Date = Date()) -> 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"
}
}