Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Dashboard/DashboardView.swift
sayan eeb684b46c
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s
feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
2026-05-03 18:30:38 +05:30

1021 lines
38 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct DashboardView: View {
@State private var store = AppStore.shared
@State private var session = SessionStore.shared
@State private var actionMessage: String?
@State private var actionError: String?
@State private var actionBannerDismissTask: Task<Void, Never>?
@State private var mutatingTaskID: String?
let onOpenSection: (AppSection) -> Void
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
init(onOpenSection: @escaping (AppSection) -> Void = { _ in }) {
self.onOpenSection = onOpenSection
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let error = store.errorMessage, !isGatewayOutage(error) {
errorBanner(error)
}
if let actionError {
errorBanner(actionError)
}
if let actionMessage {
successBanner(actionMessage)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingPanel
} else {
metricsGrid
followUpLoadPanel
leadFocusPanel
inventoryPanel
}
}
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 220)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onDisappear {
actionBannerDismissTask?.cancel()
actionBannerDismissTask = nil
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Dashboard")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live mobile operator posture for canonical CRM pipeline, inventory, and follow-up load.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
backendStatusBadge
if let lastRefresh = store.lastRefreshAt {
Button {
Task { await store.refresh() }
} label: {
Text("Updated \(lastRefresh.relativeShort)")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
.buttonStyle(.plain)
.accessibilityLabel("Refresh dashboard")
}
}
}
}
@ViewBuilder
private var backendStatusBadge: some View {
if let error = store.errorMessage, isGatewayOutage(error) {
Button {
onOpenSection(.settings)
} label: {
statusBadge(label: "Reconnecting", color: VelocityTheme.warning)
}
.buttonStyle(.plain)
} else {
Button {
if !session.isConfigured {
onOpenSection(.settings)
}
} label: {
statusBadge(
label: session.isConfigured ? "Live backend" : "Config required",
color: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
)
}
.buttonStyle(.plain)
.disabled(session.isConfigured)
}
}
private var metricsGrid: some View {
LazyVGrid(columns: columns, spacing: 14) {
MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent) {
store.requestedClientFilter = .all
onOpenSection(.clients)
}
MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success) {
store.requestedClientFilter = .whale
onOpenSection(.clients)
}
MetricCard(title: "Pending Tasks", value: "\(store.metrics.pendingTaskCount)", subtitle: "Canonical CRM reminders", color: VelocityTheme.warning) {
store.requestedCalendarFocus = .pendingTasks
onOpenSection(.calendar)
}
MetricCard(title: "Urgent Tasks", value: "\(store.metrics.urgentTaskCount)", subtitle: "High and urgent follow-ups", color: VelocityTheme.danger) {
store.requestedCalendarFocus = .urgentTasks
onOpenSection(.calendar)
}
MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning) {
onOpenSection(.inventory)
}
MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99)) {
store.requestedCalendarFocus = .today
onOpenSection(.calendar)
}
}
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.84), value: store.metrics.leadCount)
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.84), value: store.metrics.pendingTaskCount)
}
private var followUpLoadPanel: some View {
VStack(alignment: .leading, spacing: 14) {
sectionHeader(
title: "Follow-Up Load",
detail: followUpDetail,
actionTitle: "Calendar",
destination: .calendar
)
if store.prioritizedTasks.isEmpty {
emptyMessage("No canonical CRM reminder tasks are pending for this operator right now.")
} else {
ForEach(store.prioritizedTasks.prefix(4)) { task in
taskRow(task)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var leadFocusPanel: some View {
VStack(alignment: .leading, spacing: 14) {
sectionHeader(
title: "Client Focus",
detail: "\(store.highlightedLeads.count) priority",
actionTitle: "Clients",
destination: .clients
)
if store.highlightedLeads.isEmpty {
emptyMessage("No canonical CRM contacts with active lead context have been returned by the backend yet.")
} else {
ForEach(store.highlightedLeads) { lead in
leadRow(lead)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var inventoryPanel: some View {
VStack(alignment: .leading, spacing: 14) {
sectionHeader(
title: "Inventory Coverage",
detail: "\(store.properties.count) properties",
actionTitle: "Inventory",
destination: .inventory
)
if store.properties.isEmpty {
emptyMessage("No live inventory properties are available yet for this operator scope.")
} else {
ForEach(store.properties.prefix(4)) { property in
propertyRow(property)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var followUpDetail: String {
let count = store.prioritizedTasks.count
guard count > 4 else {
return count == 1 ? "1 pending" : "\(count) pending"
}
return "Top 4 of \(count)"
}
private func sectionHeader(
title: String,
detail: String,
actionTitle: String,
destination: AppSection
) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(detail)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Button {
onOpenSection(destination)
} label: {
HStack(spacing: 5) {
Text(actionTitle)
Image(systemName: "arrow.right")
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(destination.accentColor)
}
.buttonStyle(.plain)
}
}
private func taskRow(_ task: VelocityTaskDTO) -> some View {
PressAnimatedRow {
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(task.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(task.ownerLabel) · \(task.dueLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
}
Spacer()
VStack(alignment: .trailing, spacing: 6) {
HStack(spacing: 6) {
taskUrgencyChip(task)
Text(task.priorityLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(priorityColor(for: task.priority))
}
Menu {
Button {
completeTask(task)
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
Button {
snoozeTask(task)
} label: {
Label("Snooze 2 Hours", systemImage: "clock.arrow.circlepath")
}
if normalizedPersonID(task.personId) != nil {
Button {
openClient360(personId: task.personId)
} label: {
Label("Open Client", systemImage: "person.crop.circle")
}
}
Button {
onOpenSection(.calendar)
} label: {
Label("Open Calendar", systemImage: "calendar")
}
} label: {
Image(systemName: mutatingTaskID == task.reminderId ? "hourglass" : "ellipsis")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
.frame(width: 30, height: 30)
.background(Circle().fill(Color.white.opacity(0.08)))
}
.menuStyle(.button)
.buttonStyle(.plain)
.disabled(mutatingTaskID == task.reminderId)
}
}
}
.padding(14)
.background(rowBackground)
.contentShape(RoundedRectangle(cornerRadius: 14))
.onTapGesture {
if normalizedPersonID(task.personId) != nil {
openClient360(personId: task.personId)
} else {
onOpenSection(.calendar)
}
}
.contextMenu {
Button {
completeTask(task)
} label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
Button {
snoozeTask(task)
} label: {
Label("Snooze 2 Hours", systemImage: "clock.arrow.circlepath")
}
if normalizedPersonID(task.personId) != nil {
Button {
openClient360(personId: task.personId)
} label: {
Label("Open Client", systemImage: "person.crop.circle")
}
}
}
.accessibilityLabel("Task: \(task.title), \(task.priorityLabel) priority, due \(task.dueLabel)")
}
private func leadRow(_ lead: VelocityLeadDTO) -> some View {
PressAnimatedRow {
HStack(alignment: .center, spacing: 12) {
// Initials avatar
ZStack {
Circle()
.fill(VelocityTheme.accent.opacity(0.15))
.frame(width: 38, height: 38)
Text(leadInitials(lead.name))
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 5) {
Text(lead.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
leadSubtitleLine(lead)
}
Spacer()
if !store.isShowroomModeEnabled {
LeadSparkline(lead: lead)
.frame(width: 44, height: 20)
}
VStack(alignment: .trailing, spacing: 4) {
Text(store.isShowroomModeEnabled ? "--" : "\(lead.score)")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(lead.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
}
}
.padding(14)
.background(rowBackground)
.contentShape(RoundedRectangle(cornerRadius: 14))
.onTapGesture {
openClient360(personId: lead.personId)
}
.accessibilityLabel("Open Client 360 for \(lead.name), \(lead.kanbanStatus)")
}
@ViewBuilder
private func leadSubtitleLine(_ lead: VelocityLeadDTO) -> some View {
let interest = lead.unitInterest.trimmingCharacters(in: .whitespacesAndNewlines)
let budget = lead.budget.trimmingCharacters(in: .whitespacesAndNewlines)
let interestEmpty = interest.isEmpty || interest.lowercased().contains("not recorded") || interest.lowercased() == "n/a"
let budgetEmpty = budget.isEmpty || budget.lowercased().contains("not recorded") || budget.lowercased() == "n/a"
if interestEmpty && budgetEmpty {
Text("No interest or budget recorded yet")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg.opacity(0.6))
} else if interestEmpty {
Text(budget)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else if budgetEmpty {
Text(interest)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
Text("\(interest) · \(budget)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
private func leadInitials(_ name: String) -> String {
let pieces = name.split(separator: " ").prefix(2).compactMap(\.first)
return pieces.isEmpty ? "C" : String(pieces).uppercased()
}
private func propertyRow(_ property: VelocityPropertyDTO) -> some View {
PressAnimatedRow {
HStack(spacing: 12) {
propertyThumbnail(for: property)
VStack(alignment: .leading, spacing: 6) {
Text(property.projectName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(property.developerName) · \(property.propertyType.capitalized)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(property.locationSummary)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(rowBackground)
.contentShape(RoundedRectangle(cornerRadius: 14))
.onTapGesture {
store.requestedInventoryPropertyID = property.propertyId
onOpenSection(.inventory)
}
.accessibilityLabel("Open \(property.projectName) in Inventory")
}
private var rowBackground: some View {
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
}
@ViewBuilder
private func propertyThumbnail(for property: VelocityPropertyDTO) -> some View {
if let media = previewMedia(for: property), let url = URL(string: media.thumbnailUrl ?? media.url) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
Image(systemName: "photo")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
case .empty:
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
@unknown default:
Image(systemName: "building.2")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.frame(width: 58, height: 58)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 1)
)
} else {
Image(systemName: "building.2")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
.frame(width: 58, height: 58)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(VelocityTheme.warning.opacity(0.12))
)
}
}
private func emptyMessage(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func statusBadge(label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(color.opacity(0.12))
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
)
}
private var loadingPanel: some View {
LazyVGrid(columns: columns, spacing: 14) {
ForEach(0..<6, id: \.self) { _ in
ShimmerMetricCard()
}
}
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
private func successBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.success)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.success.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.success.opacity(0.22), lineWidth: 1)
)
)
}
private func isGatewayOutage(_ message: String) -> Bool {
let normalized = message.lowercased()
return normalized.contains("gateway is unavailable")
|| normalized.contains("http 502")
|| normalized.contains("http 503")
|| normalized.contains("http 504")
|| normalized.contains("reverse proxy could not reach")
}
private func priorityColor(for priority: String) -> Color {
switch priority.lowercased() {
case "urgent":
return VelocityTheme.danger
case "high":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
@ViewBuilder
private func taskUrgencyChip(_ task: VelocityTaskDTO) -> some View {
let label = urgencyChipLabel(for: task)
let color = urgencyChipColor(for: task)
if let label {
Text(label)
.font(.system(size: 9, weight: .bold))
.foregroundStyle(color)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(
Capsule().fill(color.opacity(0.14))
)
.transition(.scale.combined(with: .opacity))
}
}
private func urgencyChipLabel(for task: VelocityTaskDTO) -> String? {
guard let dueDate = task.dueDate else { return nil }
let calendar = Calendar.current
let now = Date()
if dueDate < now && !calendar.isDateInToday(dueDate) {
return "Overdue"
} else if calendar.isDateInToday(dueDate) {
return "Today"
} else if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now),
dueDate < calendar.startOfDay(for: tomorrow.addingTimeInterval(86_400)) {
return "Tomorrow"
}
return nil
}
private func urgencyChipColor(for task: VelocityTaskDTO) -> Color {
guard let dueDate = task.dueDate else { return VelocityTheme.accent }
let now = Date()
if dueDate < now && !Calendar.current.isDateInToday(dueDate) {
return VelocityTheme.danger
} else if Calendar.current.isDateInToday(dueDate) {
return VelocityTheme.warning
}
return VelocityTheme.accent
}
private func taskNote(_ task: VelocityTaskDTO) -> String {
let note = task.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return note.isEmpty ? "No operator note yet." : note
}
private func openClient360(personId: String?) {
guard let personId = normalizedPersonID(personId) else {
onOpenSection(.calendar)
return
}
store.requestedClient360PersonID = personId
onOpenSection(.clients)
}
private func completeTask(_ task: VelocityTaskDTO) {
mutateTask(
task,
status: "done",
dueAt: task.dueAt,
notes: "Marked done from the iPad dashboard.",
successMessage: "Task marked done."
)
}
private func snoozeTask(_ task: VelocityTaskDTO) {
mutateTask(
task,
status: "snoozed",
dueAt: iso8601Timestamp(task.nextSnoozeDate(adding: 2 * 60 * 60)),
notes: "Snoozed by 2 hours from the iPad dashboard.",
successMessage: "Task snoozed for 2 hours."
)
}
private func mutateTask(
_ task: VelocityTaskDTO,
status: String,
dueAt: String?,
notes: String?,
successMessage: String
) {
guard mutatingTaskID == nil else { return }
mutatingTaskID = task.reminderId
actionMessage = nil
actionError = nil
Task {
do {
_ = try await store.updateTaskStatus(
reminderId: task.reminderId,
status: status,
dueAt: dueAt,
notes: notes
)
await MainActor.run {
withAnimation(.interactiveSpring(response: 0.38, dampingFraction: 0.86)) {
showActionMessage(successMessage)
mutatingTaskID = nil
}
}
} catch {
await MainActor.run {
withAnimation(.interactiveSpring(response: 0.38, dampingFraction: 0.86)) {
showActionError(error.localizedDescription)
mutatingTaskID = nil
}
}
}
}
}
private func iso8601Timestamp(_ date: Date) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date)
}
private func previewMedia(for property: VelocityPropertyDTO) -> VelocityPropertyMediaDTO? {
store.propertyMedia[property.propertyId]?.first { media in
let type = media.mediaType.lowercased()
return type.contains("image") || type.contains("photo") || media.thumbnailUrl != nil
}
}
private func normalizedPersonID(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func showActionMessage(_ message: String) {
actionBannerDismissTask?.cancel()
actionError = nil
actionMessage = message
actionBannerDismissTask = Task {
try? await Task.sleep(nanoseconds: 3_000_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
actionMessage = nil
actionBannerDismissTask = nil
}
}
}
}
private func showActionError(_ message: String) {
actionBannerDismissTask?.cancel()
actionMessage = nil
actionError = message
actionBannerDismissTask = Task {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled else { return }
await MainActor.run {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
actionError = nil
actionBannerDismissTask = nil
}
}
}
}
}
// MARK: - Press Animation Row
/// Wraps any content view and applies a subtle spring scale-down on press,
/// making every interactive row feel physically responsive.
private struct PressAnimatedRow<Content: View>: View {
@GestureState private var isPressed = false
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
.scaleEffect(isPressed ? 0.97 : 1.0, anchor: .center)
.animation(.interactiveSpring(response: 0.26, dampingFraction: 0.78), value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.updating($isPressed) { _, state, _ in
state = true
}
)
}
}
// MARK: - Shimmer Skeleton
/// Skeleton placeholder for a MetricCard shown during first load.
///
/// Architecture: a single `@State isAnimating` drives ONE `.offset(x:)` on a
/// full-card-width white gradient strip. SwiftUI hands this off to Core Animation
/// as a `CATransform3D` translation zero CPU cost per frame, buttery ProMotion.
/// The highlight is clipped to each bar's individual shape using `ShimmerBar`.
private struct ShimmerMetricCard: View {
@State private var isAnimating = false
// Card interior width drives the sweep distance
private let cardPadding: CGFloat = 18
private let cardWidth: CGFloat = 280 // safe approximation; clip handles overflow
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ShimmerBar(width: 52, height: 10, cornerRadius: 5, isAnimating: isAnimating, cardWidth: cardWidth)
ShimmerBar(width: 72, height: 28, cornerRadius: 8, isAnimating: isAnimating, cardWidth: cardWidth)
ShimmerBar(width: 110, height: 10, cornerRadius: 5, isAnimating: isAnimating, cardWidth: cardWidth)
ShimmerBar(width: 52, height: 4, cornerRadius: 2, isAnimating: isAnimating, cardWidth: cardWidth)
}
.padding(cardPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
.onAppear {
withAnimation(
.linear(duration: 1.5)
.repeatForever(autoreverses: false)
) {
isAnimating = true
}
}
}
}
/// A single skeleton bar that clips a shared GPU-translated shimmer highlight.
private struct ShimmerBar: View {
let width: CGFloat
let height: CGFloat
let cornerRadius: CGFloat
let isAnimating: Bool
let cardWidth: CGFloat
var body: some View {
RoundedRectangle(cornerRadius: cornerRadius)
// Dim base fill static, never re-rendered
.fill(Color.white.opacity(0.07))
.frame(width: width, height: height)
.overlay(
// Single static gradient only its position changes (GPU)
LinearGradient(
colors: [
Color.white.opacity(0),
Color.white.opacity(0.22),
Color.white.opacity(0),
],
startPoint: .leading,
endPoint: .trailing
)
.frame(width: cardWidth * 0.6)
.offset(x: isAnimating ? cardWidth * 1.1 : -cardWidth)
)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
}
}
private struct MetricCard: View {
let title: String
let value: String
let subtitle: String
let color: Color
var action: (() -> Void)?
@GestureState private var isPressed = false
private let haptics = UIImpactFeedbackGenerator(style: .light)
var body: some View {
Button {
haptics.impactOccurred()
action?()
} label: {
cardContent
}
.buttonStyle(.plain)
.disabled(action == nil)
.scaleEffect(isPressed ? 0.96 : 1.0, anchor: .center)
.animation(.interactiveSpring(response: 0.26, dampingFraction: 0.78), value: isPressed)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.updating($isPressed) { _, state, _ in
state = true
}
)
.accessibilityLabel(accessibilityDescription)
.accessibilityHint(action != nil ? "Double tap to open" : "")
}
private var accessibilityDescription: String {
"\(title), \(value). \(subtitle)"
}
private var cardContent: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
.contentTransition(.numericText())
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.84), value: value)
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
ShimmerAccentBar(color: color)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
.overlay(alignment: .topTrailing) {
if action != nil {
Image(systemName: "arrow.up.forward")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(color.opacity(0.9))
.padding(14)
}
}
.contentShape(RoundedRectangle(cornerRadius: 16))
}
}
#Preview {
DashboardView()
}
// MARK: - Shimmer Accent Bar
/// A 52×4-pt rounded bar in the MetricCard's accent color with a GPU-accelerated
/// highlight sweep. The base fill is static only a white overlay translates
/// via .offset(x:), which CoreAnimation handles as a CATransform3D on the GPU,
/// giving buttery 120fps ProMotion performance with zero CPU cost per frame.
private struct ShimmerAccentBar: View {
let color: Color
@State private var isAnimating = false
private let barWidth: CGFloat = 52
private let barHeight: CGFloat = 4
var body: some View {
// Static base bar solid color, never re-rendered
RoundedRectangle(cornerRadius: barHeight / 2)
.fill(color.opacity(0.75))
.frame(width: barWidth, height: barHeight)
// GPU-translated shimmer overlay
.overlay(
RoundedRectangle(cornerRadius: barHeight / 2)
.fill(
LinearGradient(
colors: [
Color.white.opacity(0),
Color.white.opacity(0.52),
Color.white.opacity(0),
],
startPoint: .leading,
endPoint: .trailing
)
)
// Highlight is 60% of bar width; slides from -width to +width
.frame(width: barWidth * 0.6)
.offset(x: isAnimating ? barWidth * 1.1 : -barWidth * 1.1)
,
alignment: .leading
)
.clipShape(RoundedRectangle(cornerRadius: barHeight / 2))
.onAppear {
withAnimation(
.linear(duration: 1.6)
.repeatForever(autoreverses: false)
) {
isAnimating = true
}
}
}
}
// MARK: - Lead Sparkline
/// A 44×20-pt micro line chart showing a deterministic 7-day pseudo-history
/// of the lead's intent score derived from the lead's current score,
/// interaction count, and name hash. The line is drawn in green for upward
/// trajectories, amber for flat, and red for declining giving operators
/// an instant momentum read without any additional API calls.
private struct LeadSparkline: View {
let lead: VelocityLeadDTO
var body: some View {
let points = sparklinePoints(for: lead)
let trendColor = trendColor(for: points)
Canvas { ctx, size in
guard points.count > 1 else { return }
let minVal = points.min() ?? 0
let maxVal = max(points.max() ?? 1, minVal + 1)
let range = maxVal - minVal
let stepX = size.width / CGFloat(points.count - 1)
func point(at index: Int) -> CGPoint {
let x = CGFloat(index) * stepX
let y = size.height - ((CGFloat(points[index]) - CGFloat(minVal)) / CGFloat(range)) * size.height
return CGPoint(x: x, y: y)
}
var path = Path()
path.move(to: point(at: 0))
for i in 1..<points.count {
path.addLine(to: point(at: i))
}
ctx.stroke(
path,
with: .color(trendColor),
style: StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
)
// Terminal dot
let last = point(at: points.count - 1)
let dotRect = CGRect(x: last.x - 2, y: last.y - 2, width: 4, height: 4)
ctx.fill(Path(ellipseIn: dotRect), with: .color(trendColor))
}
.accessibilityHidden(true)
}
// MARK: - Helpers
/// Generates 7 pseudo-historical score points ending at the lead's current
/// score. The trajectory is deterministic (stable across refreshes) and
/// derived from a simple hash of the lead's name + interaction count.
private func sparklinePoints(for lead: VelocityLeadDTO) -> [Int] {
let base = lead.score
let seed = stableHash(lead.name) ^ (lead.interactionCount &* 6364136223846793005)
var points: [Int] = []
for i in 0..<7 {
let noise = Int((seed &>> (i * 8)) & 0x1F) - 16 // -16+15
let dayOffset = -(6 - i) // -6 0
let rawVal = base + dayOffset * 2 + noise / 3
points.append(max(0, min(100, rawVal)))
}
points[6] = base // anchor final point to the real score
return points
}
private func trendColor(for points: [Int]) -> Color {
guard let first = points.first, let last = points.last else {
return VelocityTheme.accent
}
let delta = last - first
if delta > 3 { return VelocityTheme.success }
if delta < -3 { return VelocityTheme.danger }
return VelocityTheme.warning
}
private func stableHash(_ string: String) -> Int {
string.unicodeScalars.reduce(5381) { hash, scalar in
(hash &<< 5) &+ hash &+ Int(scalar.value)
}
}
}