feat/#24 WebOS Completion (#25)

#24 WebOS Completion

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#25
This commit is contained in:
2026-04-18 18:59:04 +05:30
parent 857e0b88e6
commit 84e439712c
459 changed files with 11713 additions and 3853 deletions

View File

@@ -3,6 +3,8 @@ import SwiftUI
enum AppSection: String, CaseIterable, Hashable, Identifiable {
var id: String { rawValue }
case dashboard = "Dashboard"
case communications = "Communications"
case calendar = "Calendar"
case oracle = "Oracle"
case sentinel = "Sentinel"
case inventory = "Inventory"
@@ -11,6 +13,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .communications: return "phone.connection"
case .calendar: return "calendar.badge.clock"
case .oracle: return "message.and.waveform"
case .sentinel: return "person.crop.rectangle"
case .inventory: return "shippingbox"
@@ -21,6 +25,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
var accentColor: Color {
switch self {
case .dashboard: return VelocityTheme.accent
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
case .inventory: return VelocityTheme.warning
@@ -124,6 +130,8 @@ struct ContentView: View {
Group {
switch selectedSection {
case .dashboard: DashboardView()
case .communications: CommunicationsView()
case .calendar: CalendarView()
case .oracle: OracleView()
case .sentinel: SentinelView()
case .inventory: InventoryView()

View File

@@ -0,0 +1,19 @@
import Foundation
enum AppConfig {
private static func value(for key: String) -> String? {
guard let raw = Bundle.main.infoDictionary?[key] as? String else {
return nil
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "$(\(key))" {
return nil
}
return trimmed
}
static let baseURL: String = value(for: "BASE_URL") ?? "http://54.91.19.60:8082"
static let apiEmail: String? = value(for: "API_EMAIL")
static let apiPassword: String? = value(for: "API_PASSWORD")
static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN")
}

View File

@@ -0,0 +1,258 @@
import Foundation
struct VelocityLeadDTO: Decodable, Identifiable {
let id: String
let name: String
let phone: String?
let source: String
let qualification: String
let score: Int
let kanbanStatus: String
let budget: String
let unitInterest: String
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case name
case phone
case source
case qualification
case score
case kanbanStatus = "kanban_status"
case budget
case unitInterest = "unit_interest"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct VelocityCommunicationEventDTO: Decodable, Identifiable {
let eventId: String
let leadId: String
let channel: String
let direction: String
let provider: String?
let captureMode: String
let consentState: String
let timestamp: String
let durationSeconds: Int?
let summary: String?
let recordingRef: String?
let createdAt: String
var id: String { eventId }
enum CodingKeys: String, CodingKey {
case eventId = "event_id"
case leadId = "lead_id"
case channel
case direction
case provider
case captureMode = "capture_mode"
case consentState = "consent_state"
case timestamp
case durationSeconds = "duration_seconds"
case summary
case recordingRef = "recording_ref"
case createdAt = "created_at"
}
}
struct VelocityCalendarEventDTO: Decodable, Identifiable {
let calendarEventId: String
let leadId: String?
let title: String
let description: String?
let startAt: String
let endAt: String
let allDay: Bool
let status: String
let reminderMinutes: [Int]
let createdBy: String
let location: String?
let createdAt: String
var id: String { calendarEventId }
enum CodingKeys: String, CodingKey {
case calendarEventId = "calendar_event_id"
case leadId = "lead_id"
case title
case description
case startAt = "start_at"
case endAt = "end_at"
case allDay = "all_day"
case status
case reminderMinutes = "reminder_minutes"
case createdBy = "created_by"
case location
case createdAt = "created_at"
}
}
struct VelocityAlertSnapshotDTO: Decodable {
let pendingInsights: Int
let upcomingCalendarEvents24h: Int
let pendingTranscriptions: Int
let generatedAt: String
enum CodingKeys: String, CodingKey {
case pendingInsights = "pending_insights"
case upcomingCalendarEvents24h = "upcoming_calendar_events_24h"
case pendingTranscriptions = "pending_transcriptions"
case generatedAt = "generated_at"
}
}
enum VelocityAPIError: LocalizedError {
case notConfigured(String)
case invalidResponse
case api(String)
var errorDescription: String? {
switch self {
case .notConfigured(let message):
return message
case .invalidResponse:
return "Velocity backend returned an invalid response."
case .api(let message):
return message
}
}
}
actor VelocityAPIClient {
static let shared = VelocityAPIClient()
private struct LoginBody: Encodable {
let email: String
let password: String
}
private struct LoginResponse: Decodable {
let accessToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
private struct LeadsEnvelope: Decodable {
let data: [VelocityLeadDTO]
}
private struct EventsEnvelope: Decodable {
let events: [VelocityCommunicationEventDTO]
}
private struct CalendarEnvelope: Decodable {
let events: [VelocityCalendarEventDTO]
}
private let decoder = JSONDecoder()
private var cachedToken: String?
func fetchLeads() async throws -> [VelocityLeadDTO] {
let request = try await authorizedRequest(path: "/api/leads")
let response: LeadsEnvelope = try await perform(request)
return response.data
}
func fetchEvents(for leadId: String, limit: Int = 5) async throws -> [VelocityCommunicationEventDTO] {
let query = URLQueryItem(name: "lead_id", value: leadId)
let limitItem = URLQueryItem(name: "limit", value: String(limit))
let request = try await authorizedRequest(path: "/api/mobile-edge/events", queryItems: [query, limitItem])
let response: EventsEnvelope = try await perform(request)
return response.events
}
func fetchCalendarEvents(limit: Int = 50) async throws -> [VelocityCalendarEventDTO] {
let request = try await authorizedRequest(
path: "/api/mobile-edge/calendar",
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
)
let response: CalendarEnvelope = try await perform(request)
return response.events
}
func fetchAlerts() async throws -> VelocityAlertSnapshotDTO {
let request = try await authorizedRequest(path: "/api/mobile-edge/alerts")
return try await perform(request)
}
private func authorizedRequest(path: String, queryItems: [URLQueryItem] = []) async throws -> URLRequest {
guard let url = buildURL(path: path, queryItems: queryItems) else {
throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.")
}
let token = try await getToken()
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
return request
}
private func buildURL(path: String, queryItems: [URLQueryItem]) -> URL? {
guard var components = URLComponents(string: AppConfig.baseURL) else {
return nil
}
components.path = path
if !queryItems.isEmpty {
components.queryItems = queryItems
}
return components.url
}
private func getToken() async throws -> String {
if let token = AppConfig.apiBearerToken {
return token
}
if let token = cachedToken {
return token
}
guard let email = AppConfig.apiEmail, let password = AppConfig.apiPassword else {
throw VelocityAPIError.notConfigured(
"Set API_BEARER_TOKEN or API_EMAIL/API_PASSWORD in the app configuration to use live Velocity data."
)
}
guard let loginURL = buildURL(path: "/api/auth/login", queryItems: []) else {
throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.")
}
var request = URLRequest(url: loginURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(LoginBody(email: email, password: password))
request.timeoutInterval = 30
let response: LoginResponse = try await perform(request)
cachedToken = response.accessToken
return response.accessToken
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw VelocityAPIError.invalidResponse
}
guard 200..<300 ~= http.statusCode else {
if let apiError = try? decoder.decode(APIErrorPayload.self, from: data), let detail = apiError.detail {
throw VelocityAPIError.api(detail)
}
throw VelocityAPIError.api("Velocity request failed with HTTP \(http.statusCode).")
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw VelocityAPIError.invalidResponse
}
}
}
private struct APIErrorPayload: Decodable {
let detail: String?
}

View 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()
}

View 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()
}

View File

@@ -0,0 +1,40 @@
import SwiftUI
enum EdgeSection: String, CaseIterable, Identifiable {
case alerts = "Alerts"
case leadSummary = "Lead Summary"
case communications = "Communications"
case notes = "Notes"
case transcriptions = "Transcriptions"
case settings = "Settings"
var id: String { rawValue }
}
struct EdgeRootView: View {
@State private var selectedSection: EdgeSection = .alerts
var body: some View {
TabView(selection: $selectedSection) {
EdgeAlertsView()
.tabItem { Label("Alerts", systemImage: "bell.badge") }
.tag(EdgeSection.alerts)
EdgeLeadSummaryView()
.tabItem { Label("Lead", systemImage: "person.text.rectangle") }
.tag(EdgeSection.leadSummary)
EdgeCommunicationsView()
.tabItem { Label("Comms", systemImage: "phone.connection") }
.tag(EdgeSection.communications)
EdgeNotesView()
.tabItem { Label("Notes", systemImage: "square.and.pencil") }
.tag(EdgeSection.notes)
EdgeTranscriptionsView()
.tabItem { Label("Transcripts", systemImage: "waveform.badge.magnifyingglass") }
.tag(EdgeSection.transcriptions)
EdgeSettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
.tag(EdgeSection.settings)
}
.tint(Color(red: 0.22, green: 0.60, blue: 0.98))
}
}

View File

@@ -0,0 +1,56 @@
import SwiftUI
struct EdgeScaffold: View {
let title: String
let subtitle: String
let actionLabel: String
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.03, green: 0.05, blue: 0.08),
Color.black,
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) {
Text(title)
.font(.system(size: 30, weight: .bold))
.foregroundStyle(.white)
Text(subtitle)
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.7))
VStack(alignment: .leading, spacing: 8) {
Text("EDGE ACTION")
.font(.system(size: 10, weight: .semibold))
.tracking(1.4)
.foregroundStyle(Color(red: 0.22, green: 0.60, blue: 0.98))
Text(actionLabel)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
Text("This narrow surface is ready for `/api/mobile-edge` hookup once auth, installs, and heartbeat registration are connected.")
.font(.system(size: 13))
.foregroundStyle(Color.white.opacity(0.72))
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 22)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 22)
.stroke(Color.white.opacity(0.08), lineWidth: 1)
)
)
Spacer()
}
.padding(24)
}
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct EdgeAlertsView: View {
var body: some View {
EdgeScaffold(
title: "Alerts",
subtitle: "Unread lead responses, callback urgency, and showroom event nudges for field operators.",
actionLabel: "Respond to unread whale-lead thread"
)
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct EdgeCommunicationsView: View {
var body: some View {
EdgeScaffold(
title: "Communications",
subtitle: "Calls, WhatsApp touchpoints, and imported operator activity in one surface.",
actionLabel: "Log a manual communication note"
)
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct EdgeLeadSummaryView: View {
var body: some View {
EdgeScaffold(
title: "Lead Summary",
subtitle: "Compact account memory, qualification signals, and next-best action.",
actionLabel: "Review Mohammed Al-Rashid context"
)
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct EdgeNotesView: View {
var body: some View {
EdgeScaffold(
title: "Notes",
subtitle: "Quick capture for memory facts, objections, and promised follow-ups.",
actionLabel: "Save a note with memory extraction hints"
)
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct EdgeSettingsView: View {
var body: some View {
EdgeScaffold(
title: "Settings",
subtitle: "Install registration, operator identity, and backend connection state.",
actionLabel: "Verify surface heartbeat and app version"
)
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
struct EdgeTranscriptionsView: View {
var body: some View {
EdgeScaffold(
title: "Transcriptions",
subtitle: "Imported voice artifacts and transcript summaries for field follow-up.",
actionLabel: "Review pending recording import"
)
}
}

View File

@@ -0,0 +1,3 @@
# Velocity Edge Phone
SwiftUI scaffold for the narrow phone companion surface. This folder is intentionally source-first so it can be dropped into a new Xcode target without carrying repo-wide project changes during MVP.

View File

@@ -0,0 +1,10 @@
import SwiftUI
@main
struct VelocityEdgePhoneApp: App {
var body: some Scene {
WindowGroup {
EdgeRootView()
}
}
}

View File

@@ -3,6 +3,8 @@ import SwiftUI
enum AppSection: String, CaseIterable, Hashable, Identifiable {
var id: String { rawValue }
case dashboard = "Dashboard"
case communications = "Communications"
case calendar = "Calendar"
case oracle = "Oracle"
case sentinel = "Sentinel"
case inventory = "Inventory"
@@ -11,6 +13,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
var systemImage: String {
switch self {
case .dashboard: return "square.grid.2x2"
case .communications: return "phone.connection"
case .calendar: return "calendar.badge.clock"
case .oracle: return "message.and.waveform"
case .sentinel: return "person.crop.rectangle"
case .inventory: return "shippingbox"
@@ -21,6 +25,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
var accentColor: Color {
switch self {
case .dashboard: return VelocityTheme.accent
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
case .inventory: return VelocityTheme.warning
@@ -124,6 +130,8 @@ struct ContentView: View {
Group {
switch selectedSection {
case .dashboard: DashboardView()
case .communications: CommunicationsView()
case .calendar: CalendarView()
case .oracle: OracleView()
case .sentinel: SentinelView()
case .inventory: InventoryView()

View File

@@ -4,13 +4,20 @@ import Foundation
/// To override without touching source, add a `Config.xcconfig` (gitignored):
/// BASE_URL = http://192.168.x.x:8080
enum AppConfig {
/// Base URL for the Dream Weaver gateway (port 8080).
/// Swap this to an HTTPS domain once SSL is set up §3 of integration guide.
static let baseURL: String = {
if let override = Bundle.main.infoDictionary?["BASE_URL"] as? String,
!override.isEmpty, override != "$(BASE_URL)" {
return override
private static func value(for key: String) -> String? {
guard let raw = Bundle.main.infoDictionary?[key] as? String else {
return nil
}
return "http://54.91.19.60:8082"
}()
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty || trimmed == "$(\(key))" {
return nil
}
return trimmed
}
/// Base URL for the Velocity backend / gateway.
static let baseURL: String = value(for: "BASE_URL") ?? "http://54.91.19.60:8082"
static let apiEmail: String? = value(for: "API_EMAIL")
static let apiPassword: String? = value(for: "API_PASSWORD")
static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN")
}

View File

@@ -0,0 +1,258 @@
import Foundation
struct VelocityLeadDTO: Decodable, Identifiable {
let id: String
let name: String
let phone: String?
let source: String
let qualification: String
let score: Int
let kanbanStatus: String
let budget: String
let unitInterest: String
let createdAt: String?
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id
case name
case phone
case source
case qualification
case score
case kanbanStatus = "kanban_status"
case budget
case unitInterest = "unit_interest"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
struct VelocityCommunicationEventDTO: Decodable, Identifiable {
let eventId: String
let leadId: String
let channel: String
let direction: String
let provider: String?
let captureMode: String
let consentState: String
let timestamp: String
let durationSeconds: Int?
let summary: String?
let recordingRef: String?
let createdAt: String
var id: String { eventId }
enum CodingKeys: String, CodingKey {
case eventId = "event_id"
case leadId = "lead_id"
case channel
case direction
case provider
case captureMode = "capture_mode"
case consentState = "consent_state"
case timestamp
case durationSeconds = "duration_seconds"
case summary
case recordingRef = "recording_ref"
case createdAt = "created_at"
}
}
struct VelocityCalendarEventDTO: Decodable, Identifiable {
let calendarEventId: String
let leadId: String?
let title: String
let description: String?
let startAt: String
let endAt: String
let allDay: Bool
let status: String
let reminderMinutes: [Int]
let createdBy: String
let location: String?
let createdAt: String
var id: String { calendarEventId }
enum CodingKeys: String, CodingKey {
case calendarEventId = "calendar_event_id"
case leadId = "lead_id"
case title
case description
case startAt = "start_at"
case endAt = "end_at"
case allDay = "all_day"
case status
case reminderMinutes = "reminder_minutes"
case createdBy = "created_by"
case location
case createdAt = "created_at"
}
}
struct VelocityAlertSnapshotDTO: Decodable {
let pendingInsights: Int
let upcomingCalendarEvents24h: Int
let pendingTranscriptions: Int
let generatedAt: String
enum CodingKeys: String, CodingKey {
case pendingInsights = "pending_insights"
case upcomingCalendarEvents24h = "upcoming_calendar_events_24h"
case pendingTranscriptions = "pending_transcriptions"
case generatedAt = "generated_at"
}
}
enum VelocityAPIError: LocalizedError {
case notConfigured(String)
case invalidResponse
case api(String)
var errorDescription: String? {
switch self {
case .notConfigured(let message):
return message
case .invalidResponse:
return "Velocity backend returned an invalid response."
case .api(let message):
return message
}
}
}
actor VelocityAPIClient {
static let shared = VelocityAPIClient()
private struct LoginBody: Encodable {
let email: String
let password: String
}
private struct LoginResponse: Decodable {
let accessToken: String
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
}
}
private struct LeadsEnvelope: Decodable {
let data: [VelocityLeadDTO]
}
private struct EventsEnvelope: Decodable {
let events: [VelocityCommunicationEventDTO]
}
private struct CalendarEnvelope: Decodable {
let events: [VelocityCalendarEventDTO]
}
private let decoder = JSONDecoder()
private var cachedToken: String?
func fetchLeads() async throws -> [VelocityLeadDTO] {
let request = try await authorizedRequest(path: "/api/leads")
let response: LeadsEnvelope = try await perform(request)
return response.data
}
func fetchEvents(for leadId: String, limit: Int = 5) async throws -> [VelocityCommunicationEventDTO] {
let query = URLQueryItem(name: "lead_id", value: leadId)
let limitItem = URLQueryItem(name: "limit", value: String(limit))
let request = try await authorizedRequest(path: "/api/mobile-edge/events", queryItems: [query, limitItem])
let response: EventsEnvelope = try await perform(request)
return response.events
}
func fetchCalendarEvents(limit: Int = 50) async throws -> [VelocityCalendarEventDTO] {
let request = try await authorizedRequest(
path: "/api/mobile-edge/calendar",
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
)
let response: CalendarEnvelope = try await perform(request)
return response.events
}
func fetchAlerts() async throws -> VelocityAlertSnapshotDTO {
let request = try await authorizedRequest(path: "/api/mobile-edge/alerts")
return try await perform(request)
}
private func authorizedRequest(path: String, queryItems: [URLQueryItem] = []) async throws -> URLRequest {
guard let url = buildURL(path: path, queryItems: queryItems) else {
throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.")
}
let token = try await getToken()
var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.timeoutInterval = 30
return request
}
private func buildURL(path: String, queryItems: [URLQueryItem]) -> URL? {
guard var components = URLComponents(string: AppConfig.baseURL) else {
return nil
}
components.path = path
if !queryItems.isEmpty {
components.queryItems = queryItems
}
return components.url
}
private func getToken() async throws -> String {
if let token = AppConfig.apiBearerToken {
return token
}
if let token = cachedToken {
return token
}
guard let email = AppConfig.apiEmail, let password = AppConfig.apiPassword else {
throw VelocityAPIError.notConfigured(
"Set API_BEARER_TOKEN or API_EMAIL/API_PASSWORD in the app configuration to use live Velocity data."
)
}
guard let loginURL = buildURL(path: "/api/auth/login", queryItems: []) else {
throw VelocityAPIError.notConfigured("Velocity backend base URL is invalid.")
}
var request = URLRequest(url: loginURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONEncoder().encode(LoginBody(email: email, password: password))
request.timeoutInterval = 30
let response: LoginResponse = try await perform(request)
cachedToken = response.accessToken
return response.accessToken
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw VelocityAPIError.invalidResponse
}
guard 200..<300 ~= http.statusCode else {
if let apiError = try? decoder.decode(APIErrorPayload.self, from: data), let detail = apiError.detail {
throw VelocityAPIError.api(detail)
}
throw VelocityAPIError.api("Velocity request failed with HTTP \(http.statusCode).")
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw VelocityAPIError.invalidResponse
}
}
}
private struct APIErrorPayload: Decodable {
let detail: String?
}

View 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("This week")
.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("Recommended schedule blended from comms urgency and whale lead velocity.")
.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: "High priority", value: "0", color: VelocityTheme.danger),
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()
}

View 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()
}

View File

@@ -7,6 +7,14 @@
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>BASE_URL</key>
<string>$(BASE_URL)</string>
<key>API_EMAIL</key>
<string>$(API_EMAIL)</string>
<key>API_PASSWORD</key>
<string>$(API_PASSWORD)</string>
<key>API_BEARER_TOKEN</key>
<string>$(API_BEARER_TOKEN)</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
@@ -17,4 +25,3 @@
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
</dict>
</plist>