#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #44
1021 lines
38 KiB
Swift
1021 lines
38 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|