feat/#24 WebOS Completion (#25)
#24 WebOS Completion Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #25
This commit was merged in pull request #25.
This commit is contained in:
363
iOS/Features/Calendar/CalendarView.swift
Normal file
363
iOS/Features/Calendar/CalendarView.swift
Normal file
@@ -0,0 +1,363 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct CalendarAgendaItem: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let slot: String
|
||||
let owner: String
|
||||
let location: String
|
||||
let type: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
private struct CalendarQuickMetric: Identifiable {
|
||||
let id: String
|
||||
let label: String
|
||||
let value: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct CalendarView: View {
|
||||
@State private var selectedDay = "Wednesday"
|
||||
@State private var agendaItems: [CalendarAgendaItem] = []
|
||||
@State private var calendarMetrics: [CalendarQuickMetric] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
if let errorMessage {
|
||||
errorBanner(errorMessage)
|
||||
}
|
||||
if isLoading {
|
||||
loadingPanel
|
||||
} else {
|
||||
metricsRow
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
scheduleRail
|
||||
agendaPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await loadCalendar() }
|
||||
.refreshable { await loadCalendar() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadCalendar(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Calendar")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Operator scheduling edge for follow-ups, tours, and legal milestones.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text("Live sync")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(Capsule().stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var metricsRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(calendarMetrics) { metric in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(metric.label.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(metric.value)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(metric.color)
|
||||
.frame(width: 48, height: 4)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scheduleRail: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Week Grid")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
ForEach(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], id: \.self) { day in
|
||||
Button {
|
||||
selectedDay = day
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(day)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(daySubtitle(day))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(selectedDay == day ? VelocityTheme.accent : VelocityTheme.borderSubtle)
|
||||
.frame(width: 10, height: 10)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: 300, alignment: .topLeading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private var agendaPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(selectedDay)
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Confirmed live schedule for the authenticated Velocity operator.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if agendaItems.isEmpty {
|
||||
Text("No live calendar events are scheduled yet for this user.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
ForEach(filteredAgendaItems) { item in
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(item.color)
|
||||
.frame(width: 12, height: 12)
|
||||
Rectangle()
|
||||
.fill(item.color.opacity(0.22))
|
||||
.frame(width: 2, height: 44)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(item.type)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(item.color)
|
||||
}
|
||||
Text(item.slot)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Owner: \(item.owner) · \(item.location)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Calendar synthesis")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(calendarSynthesis)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.5))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private var filteredAgendaItems: [CalendarAgendaItem] {
|
||||
let weekday = selectedDay.lowercased()
|
||||
let filtered = agendaItems.filter { $0.slot.lowercased().contains(weekday) }
|
||||
return filtered.isEmpty ? agendaItems : filtered
|
||||
}
|
||||
|
||||
private var calendarSynthesis: String {
|
||||
if agendaItems.isEmpty {
|
||||
return "Velocity has not received any live calendar events yet. Once mobile-edge reminders and confirmed follow-ups are written, they will appear here automatically."
|
||||
}
|
||||
return "Live calendar events are being pulled from the mobile-edge backend and refreshed automatically so follow-up timing stays aligned with confirmed operator actions."
|
||||
}
|
||||
|
||||
private var loadingPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading live calendar events...")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("This surface reads confirmed mobile-edge calendar records for the authenticated Velocity user.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
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 loadCalendar(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
do {
|
||||
let events = try await VelocityAPIClient.shared.fetchCalendarEvents()
|
||||
let mapped = events.map { event in
|
||||
CalendarAgendaItem(
|
||||
id: event.calendarEventId,
|
||||
title: event.title,
|
||||
slot: formattedSlot(startAt: event.startAt),
|
||||
owner: event.createdBy.replacingOccurrences(of: "_", with: " ").capitalized,
|
||||
location: event.location ?? "No location",
|
||||
type: event.status.capitalized,
|
||||
color: color(for: event.status)
|
||||
)
|
||||
}
|
||||
let metrics = buildMetrics(from: events)
|
||||
|
||||
await MainActor.run {
|
||||
agendaItems = mapped
|
||||
calendarMetrics = metrics
|
||||
if let firstDay = mapped.first?.slot.components(separatedBy: " · ").first {
|
||||
selectedDay = firstDay
|
||||
}
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
agendaItems = []
|
||||
calendarMetrics = [
|
||||
CalendarQuickMetric(id: "today", label: "Today", value: "0 slots", color: VelocityTheme.accent),
|
||||
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "0", color: VelocityTheme.success),
|
||||
CalendarQuickMetric(id: "pending", label: "Pending invites", value: "0", color: VelocityTheme.warning),
|
||||
]
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildMetrics(from events: [VelocityCalendarEventDTO]) -> [CalendarQuickMetric] {
|
||||
let today = events.filter { isToday($0.startAt) }.count
|
||||
let confirmed = events.filter { $0.status.lowercased() == "confirmed" }.count
|
||||
let tentative = events.filter { $0.status.lowercased() == "tentative" }.count
|
||||
return [
|
||||
CalendarQuickMetric(id: "today", label: "Today", value: "\(today) slots", color: VelocityTheme.accent),
|
||||
CalendarQuickMetric(id: "priority", label: "Confirmed", value: "\(confirmed)", color: VelocityTheme.success),
|
||||
CalendarQuickMetric(id: "pending", label: "Pending invites", value: "\(tentative)", color: VelocityTheme.warning),
|
||||
]
|
||||
}
|
||||
|
||||
private func daySubtitle(_ day: String) -> String {
|
||||
let count = agendaItems.filter { $0.slot.lowercased().contains(day.lowercased()) }.count
|
||||
return count == 1 ? "1 scheduled item" : "\(count) scheduled items"
|
||||
}
|
||||
|
||||
private func formattedSlot(startAt: String) -> String {
|
||||
guard let date = ISO8601DateFormatter().date(from: startAt) else {
|
||||
return startAt
|
||||
}
|
||||
let dayFormatter = DateFormatter()
|
||||
dayFormatter.dateFormat = "EEEE"
|
||||
let timeFormatter = DateFormatter()
|
||||
timeFormatter.dateFormat = "h:mm a"
|
||||
return "\(dayFormatter.string(from: date)) · \(timeFormatter.string(from: date))"
|
||||
}
|
||||
|
||||
private func isToday(_ startAt: String) -> Bool {
|
||||
guard let date = ISO8601DateFormatter().date(from: startAt) else {
|
||||
return false
|
||||
}
|
||||
return Calendar.current.isDateInToday(date)
|
||||
}
|
||||
|
||||
private func color(for status: String) -> Color {
|
||||
switch status.lowercased() {
|
||||
case "confirmed":
|
||||
return VelocityTheme.success
|
||||
case "tentative":
|
||||
return VelocityTheme.warning
|
||||
default:
|
||||
return VelocityTheme.mutedFg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CalendarView()
|
||||
}
|
||||
448
iOS/Features/Communications/CommunicationsView.swift
Normal file
448
iOS/Features/Communications/CommunicationsView.swift
Normal file
@@ -0,0 +1,448 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct CommunicationThread: Identifiable {
|
||||
let id: String
|
||||
let leadName: String
|
||||
let channel: String
|
||||
let status: String
|
||||
let summary: String
|
||||
let nextAction: String
|
||||
let updatedAt: String
|
||||
let accent: Color
|
||||
}
|
||||
|
||||
private struct CommunicationAlert: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let detail: String
|
||||
let severity: String
|
||||
let color: Color
|
||||
}
|
||||
|
||||
struct CommunicationsView: View {
|
||||
@State private var selectedThread: String?
|
||||
@State private var threads: [CommunicationThread] = []
|
||||
@State private var alerts: [CommunicationAlert] = []
|
||||
@State private var isLoading = true
|
||||
@State private var errorMessage: String?
|
||||
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
|
||||
|
||||
private var activeThread: CommunicationThread? {
|
||||
threads.first(where: { $0.id == selectedThread })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
header
|
||||
if let errorMessage {
|
||||
errorBanner(errorMessage)
|
||||
}
|
||||
if isLoading {
|
||||
loadingPanel
|
||||
} else {
|
||||
alertsStrip
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
threadRail
|
||||
detailPanel
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await loadLiveData() }
|
||||
.refreshable { await loadLiveData() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadLiveData(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Communications")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Phone, WhatsApp, transcript, and memory edge across active leads.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
statusBadge(label: "\(threads.count) live threads", color: VelocityTheme.success)
|
||||
if let queueAlert = alerts.first(where: { $0.id == "pending_transcriptions" }) {
|
||||
statusBadge(label: queueAlert.detail, color: queueAlert.color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var alertsStrip: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(alerts) { alert in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(alert.severity)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(alert.color)
|
||||
Spacer()
|
||||
Circle()
|
||||
.fill(alert.color)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
Text(alert.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(alert.detail)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(3)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 250, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var threadRail: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Active Threads")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if threads.isEmpty {
|
||||
detailRow(title: "Live data", value: "No communication events have been captured for current leads yet.")
|
||||
}
|
||||
|
||||
ForEach(threads) { thread in
|
||||
Button {
|
||||
selectedThread = thread.id
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(thread.leadName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(thread.channel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(thread.updatedAt)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
Text(thread.summary)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(3)
|
||||
|
||||
HStack {
|
||||
Text(thread.status.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(thread.accent)
|
||||
Spacer()
|
||||
Text(thread.nextAction)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(selectedThread == thread.id ? thread.accent.opacity(0.12) : VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(selectedThread == thread.id ? thread.accent.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: 360, alignment: .topLeading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private var detailPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(activeThread?.leadName ?? "Select a thread")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(activeThread?.channel ?? "Communication detail")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if let thread = activeThread {
|
||||
statusBadge(label: thread.status, color: thread.accent)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
detailRow(title: "Latest summary", value: activeThread?.summary ?? "No thread selected")
|
||||
detailRow(title: "Next operator action", value: activeThread?.nextAction ?? "None")
|
||||
detailRow(title: "Memory extraction", value: activeThread != nil ? "Backed by persisted mobile-edge communication events and live backend alerts." : "No communication memory available.")
|
||||
detailRow(title: "Suggested response", value: activeThread != nil ? "Use the current thread state, transcript queue, and calendar urgency to choose the next operator action." : "Select a thread to view live context.")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Recent activity")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
ForEach(alerts.prefix(3)) { alert in
|
||||
activityCard(icon: alertIcon(for: alert.id), title: alert.title, detail: alert.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func detailRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
private func activityCard(icon: String, title: String, detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(VelocityTheme.accent.opacity(0.14))
|
||||
.frame(width: 38, height: 38)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(detail)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading live communications...")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity is fetching current leads, communication events, and alert state from the backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
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 loadLiveData(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
do {
|
||||
async let leadsTask = VelocityAPIClient.shared.fetchLeads()
|
||||
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
|
||||
let leads = try await leadsTask
|
||||
let alertSnapshot = try await alertsTask
|
||||
|
||||
let topLeads = Array(leads.sorted(by: { $0.score > $1.score }).prefix(8))
|
||||
var fetchedThreads: [CommunicationThread] = []
|
||||
|
||||
for lead in topLeads {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)
|
||||
let latest = events.first
|
||||
fetchedThreads.append(
|
||||
CommunicationThread(
|
||||
id: lead.id,
|
||||
leadName: lead.name,
|
||||
channel: latest.map { channelLabel($0.channel) } ?? sourceLabel(lead.source),
|
||||
status: statusLabel(for: lead, event: latest),
|
||||
summary: latest?.summary ?? "No communication events captured yet for this lead.",
|
||||
nextAction: nextActionLabel(for: lead, event: latest),
|
||||
updatedAt: latest.map { relativeShort($0.timestamp) } ?? "No events",
|
||||
accent: accentColor(for: lead, event: latest)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let fetchedAlerts = buildAlerts(from: alertSnapshot)
|
||||
|
||||
await MainActor.run {
|
||||
threads = fetchedThreads
|
||||
alerts = fetchedAlerts
|
||||
if selectedThread == nil {
|
||||
selectedThread = fetchedThreads.first?.id
|
||||
}
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
threads = []
|
||||
alerts = []
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildAlerts(from snapshot: VelocityAlertSnapshotDTO) -> [CommunicationAlert] {
|
||||
[
|
||||
CommunicationAlert(
|
||||
id: "pending_insights",
|
||||
title: "Pending insights",
|
||||
detail: "\(snapshot.pendingInsights) insight recommendations need operator review.",
|
||||
severity: "Priority",
|
||||
color: VelocityTheme.danger
|
||||
),
|
||||
CommunicationAlert(
|
||||
id: "pending_transcriptions",
|
||||
title: "Transcription queue",
|
||||
detail: "\(snapshot.pendingTranscriptions) transcript jobs waiting.",
|
||||
severity: "Queue",
|
||||
color: VelocityTheme.warning
|
||||
),
|
||||
CommunicationAlert(
|
||||
id: "calendar_due",
|
||||
title: "Calendar due soon",
|
||||
detail: "\(snapshot.upcomingCalendarEvents24h) calendar events are due in the next 24 hours.",
|
||||
severity: "Calendar",
|
||||
color: VelocityTheme.success
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
private func statusLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
|
||||
if event == nil {
|
||||
return "No events yet"
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return "Whale priority"
|
||||
}
|
||||
return lead.kanbanStatus
|
||||
}
|
||||
|
||||
private func nextActionLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
|
||||
if event?.recordingRef != nil {
|
||||
return "Review transcript"
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return "Schedule follow-up"
|
||||
}
|
||||
return "Update operator note"
|
||||
}
|
||||
|
||||
private func accentColor(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> Color {
|
||||
if event?.recordingRef != nil {
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return VelocityTheme.success
|
||||
}
|
||||
return VelocityTheme.warning
|
||||
}
|
||||
|
||||
private func alertIcon(for id: String) -> String {
|
||||
switch id {
|
||||
case "pending_transcriptions":
|
||||
return "waveform.badge.mic"
|
||||
case "calendar_due":
|
||||
return "calendar.badge.plus"
|
||||
default:
|
||||
return "brain.head.profile"
|
||||
}
|
||||
}
|
||||
|
||||
private func channelLabel(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private func sourceLabel(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private func relativeShort(_ iso: String) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let date = formatter.date(from: iso) else {
|
||||
return iso
|
||||
}
|
||||
let delta = Int(Date().timeIntervalSince(date))
|
||||
if delta < 60 { return "now" }
|
||||
if delta < 3600 { return "\(delta / 60)m ago" }
|
||||
if delta < 86400 { return "\(delta / 3600)h ago" }
|
||||
return "\(delta / 86400)d ago"
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CommunicationsView()
|
||||
}
|
||||
Reference in New Issue
Block a user