Merge Conflicts (#41)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
@@ -1,181 +0,0 @@
|
||||
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"
|
||||
case settings = "Settings"
|
||||
|
||||
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"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
SidebarRow(section: section,
|
||||
isSelected: selectedSection == section)
|
||||
.onTapGesture { selectedSection = section }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text("AF")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Ahmed Al-Farsi")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Director")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
private var detailContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .communications: CommunicationsView()
|
||||
case .calendar: CalendarView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.rawValue)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
struct SunPosition {
|
||||
let azimuth: Double // 0...360, degrees clockwise from true north
|
||||
let elevation: Double // -90...90 degrees above horizon
|
||||
}
|
||||
|
||||
enum SunMath {
|
||||
static func calculateSunPosition(date: Date, coordinate: CLLocationCoordinate2D) -> SunPosition {
|
||||
let timezone = TimeZone.current
|
||||
let localOffsetHours = Double(timezone.secondsFromGMT(for: date)) / 3600.0
|
||||
let julianDay = date.julianDay
|
||||
|
||||
let n = julianDay - 2_451_545.0
|
||||
let meanLongitude = normalizeDegrees(280.46 + 0.985_647_4 * n)
|
||||
let meanAnomaly = normalizeDegrees(357.528 + 0.985_600_3 * n)
|
||||
|
||||
let lambda = meanLongitude
|
||||
+ 1.915 * sin(meanAnomaly.radians)
|
||||
+ 0.020 * sin((2.0 * meanAnomaly).radians)
|
||||
let obliquity = 23.439 - 0.000_000_4 * n
|
||||
|
||||
let rightAscension = atan2(
|
||||
cos(obliquity.radians) * sin(lambda.radians),
|
||||
cos(lambda.radians)
|
||||
).degrees
|
||||
let declination = asin(sin(obliquity.radians) * sin(lambda.radians)).degrees
|
||||
|
||||
let utcHours = date.utcHours
|
||||
let lst = normalizeDegrees(100.46 + 0.985_647 * n + coordinate.longitude + 15.0 * utcHours + localOffsetHours)
|
||||
let hourAngle = normalizeDegrees(lst - rightAscension)
|
||||
let signedHourAngle = hourAngle > 180.0 ? hourAngle - 360.0 : hourAngle
|
||||
|
||||
let latitude = coordinate.latitude.radians
|
||||
let declinationRad = declination.radians
|
||||
let hourAngleRad = signedHourAngle.radians
|
||||
|
||||
let elevation = asin(
|
||||
sin(latitude) * sin(declinationRad)
|
||||
+ cos(latitude) * cos(declinationRad) * cos(hourAngleRad)
|
||||
).degrees
|
||||
|
||||
let azimuth = normalizeDegrees(
|
||||
atan2(
|
||||
-sin(hourAngleRad),
|
||||
tan(declinationRad) * cos(latitude) - sin(latitude) * cos(hourAngleRad)
|
||||
).degrees
|
||||
)
|
||||
|
||||
return SunPosition(azimuth: azimuth, elevation: elevation)
|
||||
}
|
||||
|
||||
static func sunPathSamples(for date: Date, coordinate: CLLocationCoordinate2D) -> [Date: SunPosition] {
|
||||
let calendar = Calendar.current
|
||||
let sampleHours = [8, 10, 12, 14, 16]
|
||||
var output: [Date: SunPosition] = [:]
|
||||
|
||||
for hour in sampleHours {
|
||||
if let sampleDate = calendar.date(bySettingHour: hour, minute: 0, second: 0, of: date) {
|
||||
output[sampleDate] = calculateSunPosition(date: sampleDate, coordinate: coordinate)
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private static func normalizeDegrees(_ value: Double) -> Double {
|
||||
let reduced = value.truncatingRemainder(dividingBy: 360.0)
|
||||
return reduced >= 0 ? reduced : reduced + 360.0
|
||||
}
|
||||
}
|
||||
|
||||
private extension Date {
|
||||
var utcHours: Double {
|
||||
let calendar = Calendar(identifier: .gregorian)
|
||||
let comps = calendar.dateComponents(in: TimeZone(secondsFromGMT: 0)!, from: self)
|
||||
let hours = Double(comps.hour ?? 0)
|
||||
let minutes = Double(comps.minute ?? 0)
|
||||
let seconds = Double(comps.second ?? 0)
|
||||
return hours + minutes / 60.0 + seconds / 3600.0
|
||||
}
|
||||
|
||||
var julianDay: Double {
|
||||
let interval = timeIntervalSince1970
|
||||
return (interval / 86_400.0) + 2_440_587.5
|
||||
}
|
||||
}
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
var degrees: Double { self * 180.0 / .pi }
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
@preconcurrency import Alamofire
|
||||
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
|
||||
private let endpoint = "http://192.168.x.x:8000/dream-weaver"
|
||||
private let session: Session
|
||||
|
||||
private init(session: Session = .default) {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func generateImage(source: UIImage, prompt: String) async throws -> UIImage {
|
||||
let resized = source.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.9) else {
|
||||
throw ComfyClientError.encodingFailed
|
||||
}
|
||||
|
||||
let payload = DreamWeaverRequest(
|
||||
imageBase64: imageData.base64EncodedString(),
|
||||
prompt: prompt
|
||||
)
|
||||
|
||||
let response = try await session.request(
|
||||
endpoint,
|
||||
method: .post,
|
||||
parameters: payload,
|
||||
encoder: JSONParameterEncoder.default,
|
||||
headers: [.contentType("application/json")]
|
||||
)
|
||||
.validate(statusCode: 200..<300)
|
||||
.serializingDecodable(DreamWeaverResponse.self)
|
||||
.value
|
||||
|
||||
guard
|
||||
let data = Data(base64Encoded: response.outputBase64),
|
||||
let generated = UIImage(data: data)
|
||||
else {
|
||||
throw ComfyClientError.decodingFailed
|
||||
}
|
||||
|
||||
return generated
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverRequest: Encodable, Sendable {
|
||||
let imageBase64: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
private struct DreamWeaverResponse: Decodable, Sendable {
|
||||
let outputBase64: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case outputBase64 = "output_base64"
|
||||
case imageBase64 = "image_base64"
|
||||
case image
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let preferred = try container.decodeIfPresent(String.self, forKey: .outputBase64) {
|
||||
outputBase64 = preferred
|
||||
return
|
||||
}
|
||||
if let legacy = try container.decodeIfPresent(String.self, forKey: .imageBase64) {
|
||||
outputBase64 = legacy
|
||||
return
|
||||
}
|
||||
outputBase64 = try container.decode(String.self, forKey: .image)
|
||||
}
|
||||
}
|
||||
|
||||
enum ComfyClientError: Error {
|
||||
case encodingFailed
|
||||
case decodingFailed
|
||||
}
|
||||
|
||||
private extension UIImage {
|
||||
func resizedSquare(to side: CGFloat) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat.default()
|
||||
format.scale = 1
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side), format: format)
|
||||
|
||||
return renderer.image { _ in
|
||||
let aspect = size.width / size.height
|
||||
let targetRect: CGRect
|
||||
if aspect > 1 {
|
||||
let width = side * aspect
|
||||
targetRect = CGRect(x: (side - width) / 2, y: 0, width: width, height: side)
|
||||
} else {
|
||||
let height = side / aspect
|
||||
targetRect = CGRect(x: 0, y: (side - height) / 2, width: side, height: height)
|
||||
}
|
||||
draw(in: targetRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
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?
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
enum SentimentType: String, CaseIterable {
|
||||
case excited, interested, neutral, confused, disinterested
|
||||
var score: Int {
|
||||
switch self {
|
||||
case .excited: return 100
|
||||
case .interested: return 80
|
||||
case .neutral: return 50
|
||||
case .confused: return 30
|
||||
case .disinterested: return 10
|
||||
}
|
||||
}
|
||||
var emoji: String {
|
||||
switch self {
|
||||
case .excited: return "😃"
|
||||
case .interested: return "🤔"
|
||||
case .neutral: return "😐"
|
||||
case .confused: return "😕"
|
||||
case .disinterested: return "😴"
|
||||
}
|
||||
}
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .excited: return VelocityTheme.success
|
||||
case .interested: return VelocityTheme.accent
|
||||
case .neutral: return VelocityTheme.mutedFg
|
||||
case .confused: return VelocityTheme.warning
|
||||
case .disinterested: return VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Visitor: Identifiable {
|
||||
let id: String
|
||||
let faceId: String
|
||||
var sentiment: SentimentType
|
||||
var confidence: Double
|
||||
var dwellTime: Int // seconds
|
||||
var zone: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
enum LeadSource: String {
|
||||
case whatsapp = "WhatsApp"
|
||||
case walkin = "Walk-in"
|
||||
case website = "Website"
|
||||
}
|
||||
|
||||
enum LeadStatus: String {
|
||||
case hot = "Hot"
|
||||
case engaged = "Engaged"
|
||||
case new = "New"
|
||||
case qualified = "Qualified"
|
||||
case closed = "Closed"
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .hot: return VelocityTheme.danger
|
||||
case .engaged: return VelocityTheme.accent
|
||||
case .new: return VelocityTheme.mutedFg
|
||||
case .qualified: return VelocityTheme.success
|
||||
case .closed: return Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Lead: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let phone: String
|
||||
let source: LeadSource
|
||||
var status: LeadStatus
|
||||
var lastMessage: String
|
||||
var lastActive: Date
|
||||
var unreadCount: Int
|
||||
let qualification: String
|
||||
let budget: String
|
||||
let interest: String
|
||||
var initials: String { String(name.split(separator: " ").prefix(2).compactMap(\.first)) }
|
||||
}
|
||||
|
||||
struct ChatMessage: Identifiable {
|
||||
let id: String
|
||||
let sender: String // "user" | "oracle" | "ai"
|
||||
let content: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
struct SystemHealth {
|
||||
var cpu: Double // 0–1
|
||||
var gpu: Double
|
||||
var memory: Double
|
||||
}
|
||||
|
||||
struct DashboardMetrics {
|
||||
var activeVisitors: Int
|
||||
var revenue: String
|
||||
var aiJobs: Int
|
||||
var dailyVisitors: Int
|
||||
var sentimentScore: Double // 0–100
|
||||
var systemHealth: SystemHealth
|
||||
}
|
||||
|
||||
// MARK: – Shared Store
|
||||
|
||||
@Observable
|
||||
final class AppStore {
|
||||
|
||||
static let shared = AppStore()
|
||||
private init() { startTimer() }
|
||||
|
||||
// ── Dashboard ─────────────────────────────────────────────────
|
||||
var metrics = DashboardMetrics(
|
||||
activeVisitors: 17,
|
||||
revenue: "$3.2M",
|
||||
aiJobs: 24,
|
||||
dailyVisitors: 128,
|
||||
sentimentScore: 78,
|
||||
systemHealth: SystemHealth(cpu: 0.42, gpu: 0.61, memory: 0.55)
|
||||
)
|
||||
|
||||
var dashboardMessages: [ChatMessage] = [
|
||||
ChatMessage(id: "d0", sender: "ai",
|
||||
content: "Hello, Ahmed. I've analysed the Q3 pipeline. Would you like a refined strategy for the Apex Innovations deal?",
|
||||
timestamp: Date().addingTimeInterval(-300))
|
||||
]
|
||||
var isDashboardThinking = false
|
||||
|
||||
// ── Visitors ──────────────────────────────────────────────────
|
||||
var visitors: [Visitor] = [
|
||||
Visitor(id: "v1", faceId: "face_001", sentiment: .excited, confidence: 0.92, dwellTime: 450, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v2", faceId: "face_002", sentiment: .interested, confidence: 0.87, dwellTime: 320, zone: "Amenity Deck VR", timestamp: Date()),
|
||||
Visitor(id: "v3", faceId: "face_003", sentiment: .neutral, confidence: 0.78, dwellTime: 180, zone: "Reception", timestamp: Date()),
|
||||
Visitor(id: "v4", faceId: "face_004", sentiment: .confused, confidence: 0.74, dwellTime: 95, zone: "Penthouse Show", timestamp: Date()),
|
||||
Visitor(id: "v5", faceId: "face_005", sentiment: .disinterested, confidence: 0.65, dwellTime: 60, zone: "Gallery", timestamp: Date()),
|
||||
]
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────
|
||||
var isAlertActive = false
|
||||
var alertMessage = ""
|
||||
|
||||
func triggerAlert(_ msg: String) {
|
||||
isAlertActive = true
|
||||
alertMessage = msg
|
||||
}
|
||||
func clearAlert() {
|
||||
isAlertActive = false
|
||||
alertMessage = ""
|
||||
}
|
||||
|
||||
// ── Leads (Oracle) ────────────────────────────────────────────
|
||||
var leads: [Lead] = [
|
||||
Lead(id: "1", name: "Mohammed Al-Rashid", phone: "+971 55 123 4567", source: .whatsapp,
|
||||
status: .hot, lastMessage: "Can we schedule a viewing for the penthouse tomorrow?",
|
||||
lastActive: Date().addingTimeInterval(-300), unreadCount: 2,
|
||||
qualification: "whale", budget: "AED 15M+", interest: "Penthouse Suite"),
|
||||
Lead(id: "2", name: "Sarah Chen", phone: "+971 50 987 6543", source: .walkin,
|
||||
status: .engaged, lastMessage: "Thank you for the brochure. I will review with my partner.",
|
||||
lastActive: Date().addingTimeInterval(-1800), unreadCount: 0,
|
||||
qualification: "potential", budget: "AED 5–8M", interest: "2BR Sea View"),
|
||||
Lead(id: "3", name: "James Wilson", phone: "+971 52 456 7890", source: .website,
|
||||
status: .new, lastMessage: "Interested in investment opportunities.",
|
||||
lastActive: Date().addingTimeInterval(-7200), unreadCount: 1,
|
||||
qualification: "potential", budget: "AED 3–5M", interest: "1BR Investment"),
|
||||
Lead(id: "4", name: "Fatima Hassan", phone: "+971 54 321 0987", source: .whatsapp,
|
||||
status: .qualified,lastMessage: "What are the payment plan options?",
|
||||
lastActive: Date().addingTimeInterval(-14400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 12M+", interest: "3BR + Maid"),
|
||||
Lead(id: "5", name: "David Kumar", phone: "+971 56 789 0123", source: .walkin,
|
||||
status: .closed, lastMessage: "Contract signed. Thank you!",
|
||||
lastActive: Date().addingTimeInterval(-86400), unreadCount: 0,
|
||||
qualification: "whale", budget: "AED 20M", interest: "Full Floor"),
|
||||
]
|
||||
|
||||
var messages: [String: [ChatMessage]] = [
|
||||
"1": [
|
||||
ChatMessage(id: "m1", sender: "user", content: "Hi, I am interested in the penthouse units.",
|
||||
timestamp: Date().addingTimeInterval(-7200)),
|
||||
ChatMessage(id: "m2", sender: "oracle",
|
||||
content: "Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M.",
|
||||
timestamp: Date().addingTimeInterval(-7200 + 30)),
|
||||
ChatMessage(id: "m3", sender: "user", content: "Can we schedule a viewing tomorrow?",
|
||||
timestamp: Date().addingTimeInterval(-300)),
|
||||
],
|
||||
"2": [
|
||||
ChatMessage(id: "m4", sender: "oracle",
|
||||
content: "Hello Sarah! Here is the digital brochure for the 2-bedroom units we discussed.",
|
||||
timestamp: Date().addingTimeInterval(-14400)),
|
||||
ChatMessage(id: "m5", sender: "user", content: "Thank you. I will review with my partner.",
|
||||
timestamp: Date().addingTimeInterval(-1800)),
|
||||
],
|
||||
]
|
||||
|
||||
var activeLeadId: String? = "1"
|
||||
var isOracleThinking = false
|
||||
|
||||
func addDashboardMessage(sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
dashboardMessages.append(msg)
|
||||
}
|
||||
|
||||
func addOracleMessage(leadId: String, sender: String, content: String) {
|
||||
let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date())
|
||||
if messages[leadId] == nil { messages[leadId] = [] }
|
||||
messages[leadId]!.append(msg)
|
||||
}
|
||||
|
||||
// ── Live ticker ───────────────────────────────────────────────
|
||||
private var timerTask: AnyCancellable?
|
||||
private var alertTask: DispatchWorkItem?
|
||||
|
||||
private func startTimer() {
|
||||
timerTask = Timer.publish(every: 5, on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in self?.tick() }
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
// jitter visitor count ±1
|
||||
let delta = Int.random(in: -1...1)
|
||||
metrics.activeVisitors = max(10, metrics.activeVisitors + delta)
|
||||
|
||||
// jitter sentiment ±2
|
||||
let sDelta = Double.random(in: -2...2)
|
||||
metrics.sentimentScore = min(100, max(40, metrics.sentimentScore + sDelta))
|
||||
|
||||
// jitter system health
|
||||
metrics.systemHealth.cpu = Double.random(in: 0.30...0.65)
|
||||
metrics.systemHealth.gpu = Double.random(in: 0.45...0.75)
|
||||
metrics.systemHealth.memory = Double.random(in: 0.40...0.70)
|
||||
|
||||
// Random alert (same 10% chance as WebOS every tick)
|
||||
if !isAlertActive && Double.random(in: 0...1) > 0.85 {
|
||||
triggerAlert("Confusion detected in Zone B – Penthouse Gallery")
|
||||
let work = DispatchWorkItem { [weak self] in self?.clearAlert() }
|
||||
alertTask = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Helpers
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let diff = Int(Date().timeIntervalSince(self))
|
||||
if diff < 60 { return "now" }
|
||||
if diff < 3600 { return "\(diff / 60)m ago" }
|
||||
if diff < 86400 { return "\(diff / 3600)h ago" }
|
||||
return "\(diff / 86400)d ago"
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,442 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
@State private var chatInput = ""
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
|
||||
// KPI Grid — live from store
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
LiveKPICard(
|
||||
title: "Visitors",
|
||||
value: "\(store.metrics.activeVisitors)",
|
||||
subtitle: "Active now",
|
||||
icon: "person.2",
|
||||
accentColor: VelocityTheme.accent,
|
||||
glowColor: VelocityTheme.accent.opacity(0.22),
|
||||
badge: "LIVE"
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Revenue",
|
||||
value: store.metrics.revenue,
|
||||
subtitle: "30-day forecast",
|
||||
icon: "chart.line.uptrend.xyaxis",
|
||||
accentColor: Color(red: 0.13, green: 0.83, blue: 0.93),
|
||||
glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "AI Jobs",
|
||||
value: "\(store.metrics.aiJobs)",
|
||||
subtitle: "Queue depth",
|
||||
icon: "cpu",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99),
|
||||
glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20)
|
||||
)
|
||||
LiveKPICard(
|
||||
title: "Listings",
|
||||
value: "\(store.metrics.dailyVisitors)",
|
||||
subtitle: "Active units",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.success,
|
||||
glowColor: VelocityTheme.success.opacity(0.18)
|
||||
)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors)
|
||||
|
||||
// Sentiment Gauge
|
||||
sentimentGauge
|
||||
|
||||
// System Health
|
||||
systemHealthPanel
|
||||
|
||||
// AI Chat Widget
|
||||
aiChatWidget
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: – Page Header
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Dashboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v.1.1")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(VelocityTheme.success)
|
||||
.frame(width: 7, height: 7)
|
||||
Text("Live")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sentiment Gauge
|
||||
private var sentimentGauge: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Image(systemName: "waveform.path.ecg")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment Thermometer")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("Showroom Vibe")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
let label = store.metrics.sentimentScore >= 70 ? "Excellent" :
|
||||
store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention"
|
||||
let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success :
|
||||
store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(labelColor)
|
||||
}
|
||||
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.frame(height: 26)
|
||||
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.11, green: 0.30, blue: 0.86),
|
||||
VelocityTheme.accent,
|
||||
Color(red: 0.38, green: 0.65, blue: 0.98)],
|
||||
startPoint: .leading, endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6)
|
||||
.animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore)
|
||||
|
||||
Text("\(Int(store.metrics.sentimentScore))%")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: 26)
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – System Health
|
||||
private var systemHealthPanel: some View {
|
||||
let gauges: [(label: String, value: Double, color: Color)] = [
|
||||
("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent),
|
||||
("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)),
|
||||
("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)),
|
||||
]
|
||||
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("System Health")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach(gauges, id: \.label) { g in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(g.color)
|
||||
.frame(width: geo.size.width * g.value, height: 5)
|
||||
.shadow(color: g.color.opacity(0.6), radius: 4)
|
||||
.animation(.easeInOut(duration: 0.6), value: g.value)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – AI Chat Widget
|
||||
private var aiChatWidget: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34)
|
||||
Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 5) {
|
||||
Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Messages
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(store.dashboardMessages) { msg in
|
||||
ChatBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
if store.isDashboardThinking {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.frame(height: 240)
|
||||
.onChange(of: store.dashboardMessages.count) {
|
||||
if let last = store.dashboardMessages.last {
|
||||
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
.onChange(of: store.isDashboardThinking) {
|
||||
if store.isDashboardThinking {
|
||||
withAnimation { proxy.scrollTo("typing", anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Input
|
||||
HStack(spacing: 10) {
|
||||
TextField("Ask AI assistant...", text: $chatInput)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { sendDashboardMessage() }
|
||||
|
||||
Button(action: sendDashboardMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
}
|
||||
.disabled(chatInput.isEmpty || store.isDashboardThinking)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func sendDashboardMessage() {
|
||||
let text = chatInput.trimmingCharacters(in: .whitespaces)
|
||||
guard !text.isEmpty else { return }
|
||||
chatInput = ""
|
||||
store.addDashboardMessage(sender: "user", content: text)
|
||||
store.isDashboardThinking = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
store.isDashboardThinking = false
|
||||
store.addDashboardMessage(
|
||||
sender: "ai",
|
||||
content: dashboardAIResponse(for: text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func dashboardAIResponse(for prompt: String) -> String {
|
||||
let p = prompt.lowercased()
|
||||
if p.contains("penthouse") || p.contains("apex") {
|
||||
return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement."
|
||||
} else if p.contains("visitor") || p.contains("traffic") {
|
||||
return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes."
|
||||
} else if p.contains("revenue") || p.contains("deal") {
|
||||
return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid."
|
||||
} else if p.contains("sentiment") {
|
||||
return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores."
|
||||
}
|
||||
return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card (live-bound)
|
||||
private struct LiveKPICard: View {
|
||||
let title: String
|
||||
let value: String
|
||||
let subtitle: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
let glowColor: Color
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32)
|
||||
Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .medium)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
.minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4)
|
||||
|
||||
Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, minHeight: 148, alignment: .leading)
|
||||
.background(
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20)
|
||||
VStack {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing))
|
||||
.frame(height: 1)
|
||||
Spacer()
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Chat Bubble
|
||||
private struct ChatBubble: View {
|
||||
let message: ChatMessage
|
||||
private var isUser: Bool { message.sender == "user" }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if isUser { Spacer(minLength: 40) }
|
||||
|
||||
if !isUser {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(isUser ? .white : VelocityTheme.foreground)
|
||||
.padding(.horizontal, 12).padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: isUser ? 14 : 14)
|
||||
.fill(isUser
|
||||
? VelocityTheme.accent.opacity(0.85)
|
||||
: Color.white.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
if isUser {
|
||||
ZStack {
|
||||
Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26)
|
||||
Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
if !isUser { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Typing Indicator
|
||||
private struct TypingIndicator: View {
|
||||
@State private var phase = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
ZStack {
|
||||
Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26)
|
||||
Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(VelocityTheme.mutedFg)
|
||||
.frame(width: 6, height: 6)
|
||||
.scaleEffect(phase == i ? 1.4 : 0.8)
|
||||
.animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1)))
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
.id("typing")
|
||||
.onAppear {
|
||||
withAnimation { phase = 1 }
|
||||
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
|
||||
phase = (phase + 1) % 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import ARKit
|
||||
import CoreLocation
|
||||
import CoreMotion
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
struct ARSunOverlayView: UIViewRepresentable {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(sunNodesReady: $sunNodesReady)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> ARSCNView {
|
||||
let view = ARSCNView(frame: .zero)
|
||||
view.delegate = context.coordinator
|
||||
view.scene = SCNScene()
|
||||
view.automaticallyUpdatesLighting = true
|
||||
|
||||
let config = ARWorldTrackingConfiguration()
|
||||
config.worldAlignment = .gravityAndHeading
|
||||
view.session.run(config)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: ARSCNView, context: Context) {}
|
||||
|
||||
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
|
||||
uiView.session.pause()
|
||||
coordinator.stop()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
|
||||
private let locationManager = CLLocationManager()
|
||||
private let motionManager = CMMotionManager()
|
||||
private weak var sceneView: ARSCNView?
|
||||
private var heading: CLLocationDirection = 0
|
||||
private var coordinate: CLLocationCoordinate2D?
|
||||
@Binding private var sunNodesReady: Bool
|
||||
|
||||
init(sunNodesReady: Binding<Bool>) {
|
||||
_sunNodesReady = sunNodesReady
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
locationManager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
locationManager.headingFilter = 1
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
locationManager.startUpdatingLocation()
|
||||
locationManager.startUpdatingHeading()
|
||||
startMotion()
|
||||
}
|
||||
|
||||
func attach(to sceneView: ARSCNView) {
|
||||
self.sceneView = sceneView
|
||||
addSunPathNodesIfPossible()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
motionManager.stopDeviceMotionUpdates()
|
||||
locationManager.stopUpdatingHeading()
|
||||
locationManager.stopUpdatingLocation()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard coordinate == nil, let location = locations.last else { return }
|
||||
coordinate = location.coordinate
|
||||
addSunPathNodesIfPossible()
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
|
||||
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
|
||||
addSunPathNodesIfPossible()
|
||||
}
|
||||
|
||||
private func startMotion() {
|
||||
guard motionManager.isDeviceMotionAvailable else { return }
|
||||
motionManager.deviceMotionUpdateInterval = 0.1
|
||||
motionManager.startDeviceMotionUpdates()
|
||||
}
|
||||
|
||||
private func addSunPathNodesIfPossible() {
|
||||
guard
|
||||
let sceneView,
|
||||
let coordinate,
|
||||
!sunNodesReady
|
||||
else { return }
|
||||
|
||||
let samples = SunMath.sunPathSamples(for: Date(), coordinate: coordinate)
|
||||
let sorted = samples.sorted { $0.key < $1.key }
|
||||
let root = SCNNode()
|
||||
let northOffset = (heading).radians
|
||||
let radius: Float = 1.8
|
||||
|
||||
for (_, pos) in sorted {
|
||||
let elevation = Float(pos.elevation.radians)
|
||||
let azimuth = Float((pos.azimuth).radians) - Float(northOffset)
|
||||
let x = radius * cos(elevation) * sin(azimuth)
|
||||
let y = radius * sin(elevation)
|
||||
let z = -radius * cos(elevation) * cos(azimuth)
|
||||
|
||||
let sphere = SCNSphere(radius: 0.03)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow
|
||||
let node = SCNNode(geometry: sphere)
|
||||
node.position = SCNVector3(x, y, z)
|
||||
root.addChildNode(node)
|
||||
}
|
||||
|
||||
sceneView.scene.rootNode.addChildNode(root)
|
||||
sunNodesReady = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
}
|
||||
@@ -1,439 +0,0 @@
|
||||
import AVFoundation
|
||||
import Observation
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
@Observable
|
||||
final class InventoryStore {
|
||||
enum Mode: String, CaseIterable, Identifiable {
|
||||
case sunseeker = "Sunseeker"
|
||||
case dreamWeaver = "Dream Weaver"
|
||||
case dollhouse = "Dollhouse"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
var mode: Mode = .sunseeker
|
||||
var selectedPrompt: String = "Modern Islamic"
|
||||
var sourceImage: UIImage?
|
||||
var generatedImage: UIImage?
|
||||
var isProcessing: Bool = false
|
||||
var sunNodesReady: Bool = false
|
||||
var dollhouseHour: Double = 12
|
||||
|
||||
let prompts = ["Modern Islamic", "Minimalist", "Night Mode"]
|
||||
}
|
||||
|
||||
struct InventoryView: View {
|
||||
@State private var store = InventoryStore()
|
||||
@State private var showCamera = false
|
||||
@State private var sliderTickHour = 12
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Inventory")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker · Dream Weaver · Dollhouse")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
Picker("Mode", selection: $store.mode) {
|
||||
ForEach(InventoryStore.Mode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
Group {
|
||||
switch store.mode {
|
||||
case .sunseeker:
|
||||
#if targetEnvironment(simulator)
|
||||
ZStack {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "camera.metering.unknown")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("AR Not Available in Simulator")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker requires a real device with a camera and compass. Run on iPhone or iPad to use this feature.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
#else
|
||||
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
|
||||
#endif
|
||||
case .dreamWeaver:
|
||||
DreamWeaverPanel(
|
||||
sourceImage: $store.sourceImage,
|
||||
generatedImage: $store.generatedImage,
|
||||
selectedPrompt: $store.selectedPrompt,
|
||||
isProcessing: $store.isProcessing,
|
||||
prompts: store.prompts,
|
||||
showCamera: $showCamera
|
||||
)
|
||||
case .dollhouse:
|
||||
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 20)
|
||||
.animation(.easeInOut(duration: 0.25), value: store.mode)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.onAppear {
|
||||
// Dark-theme the segmented control
|
||||
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
|
||||
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor.white], for: .selected)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
|
||||
UISegmentedControl.appearance().backgroundColor = UIColor(
|
||||
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
|
||||
}
|
||||
.sheet(isPresented: $showCamera) {
|
||||
CameraPicker(image: $store.sourceImage, isPresented: $showCamera)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SunseekerPanel: View {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ARSunOverlayView(sunNodesReady: $sunNodesReady)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
|
||||
DashedSunLine()
|
||||
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 80)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Sunseeker")
|
||||
.font(.headline)
|
||||
Text("Point the iPad toward windows to inspect yearly sun-entry path.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverPanel: View {
|
||||
@Binding var sourceImage: UIImage?
|
||||
@Binding var generatedImage: UIImage?
|
||||
@Binding var selectedPrompt: String
|
||||
@Binding var isProcessing: Bool
|
||||
let prompts: [String]
|
||||
@Binding var showCamera: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
|
||||
if let sourceImage {
|
||||
Image(uiImage: sourceImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
} else {
|
||||
ContentUnavailableView("No Capture", systemImage: "camera.viewfinder", description: Text("Tap Capture to snap a room."))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
if let generatedImage {
|
||||
Image(uiImage: generatedImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(12)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if isProcessing {
|
||||
ProcessingOverlay()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 420)
|
||||
.animation(.easeInOut(duration: 0.35), value: generatedImage)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(prompts, id: \.self) { prompt in
|
||||
Text(prompt)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(prompt == selectedPrompt ? Color.yellow.opacity(0.85) : Color.white.opacity(0.12))
|
||||
)
|
||||
.onTapGesture { selectedPrompt = prompt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Capture") {
|
||||
showCamera = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Reimagine") {
|
||||
Task { await generate() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(sourceImage == nil || isProcessing)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func generate() async {
|
||||
guard let sourceImage, !isProcessing else { return }
|
||||
isProcessing = true
|
||||
do {
|
||||
let result = try await ComfyClient.shared.generateImage(source: sourceImage, prompt: selectedPrompt)
|
||||
withAnimation(.easeInOut(duration: 0.4)) {
|
||||
generatedImage = result
|
||||
}
|
||||
} catch {
|
||||
print("Dream Weaver error: \(error)")
|
||||
}
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
private struct DollhousePanel: View {
|
||||
@Binding var hour: Double
|
||||
@Binding var tickHour: Int
|
||||
let haptics: UIImpactFeedbackGenerator
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
SceneKitDollhouseView(hour: $hour)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.frame(maxWidth: .infinity, minHeight: 460)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(String(format: "Time: %02d:00", Int(hour.rounded())))
|
||||
.font(.headline)
|
||||
Slider(value: $hour, in: 0...24, step: 0.25)
|
||||
.onChange(of: hour) { _, newValue in
|
||||
let rounded = Int(newValue.rounded())
|
||||
if rounded != tickHour {
|
||||
tickHour = rounded
|
||||
haptics.impactOccurred(intensity: 0.7)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background {
|
||||
GlassBlurView(style: .systemThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SceneKitDollhouseView: UIViewRepresentable {
|
||||
@Binding var hour: Double
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let view = SCNView()
|
||||
view.scene = context.coordinator.scene
|
||||
view.autoenablesDefaultLighting = false
|
||||
view.allowsCameraControl = true
|
||||
view.backgroundColor = UIColor.systemBackground
|
||||
context.coordinator.setupScene()
|
||||
context.coordinator.updateSunLight(hour: hour)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {
|
||||
context.coordinator.updateSunLight(hour: hour)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
let scene = SCNScene()
|
||||
private let sunNode = SCNNode()
|
||||
|
||||
func setupScene() {
|
||||
if let modelScene = SCNScene(named: "Building.usdz") ?? SCNScene(named: "Building.scn") {
|
||||
let container = SCNNode()
|
||||
for child in modelScene.rootNode.childNodes {
|
||||
container.addChildNode(child.clone())
|
||||
}
|
||||
scene.rootNode.addChildNode(container)
|
||||
} else {
|
||||
let fallback = SCNFloor()
|
||||
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
|
||||
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
|
||||
}
|
||||
|
||||
let camera = SCNCamera()
|
||||
let cameraNode = SCNNode()
|
||||
cameraNode.camera = camera
|
||||
cameraNode.position = SCNVector3(0, 4, 10)
|
||||
scene.rootNode.addChildNode(cameraNode)
|
||||
|
||||
let light = SCNLight()
|
||||
light.type = .directional
|
||||
light.intensity = 1_200
|
||||
light.castsShadow = true
|
||||
sunNode.light = light
|
||||
scene.rootNode.addChildNode(sunNode)
|
||||
|
||||
let ambient = SCNLight()
|
||||
ambient.type = .ambient
|
||||
ambient.intensity = 200
|
||||
let ambientNode = SCNNode()
|
||||
ambientNode.light = ambient
|
||||
scene.rootNode.addChildNode(ambientNode)
|
||||
}
|
||||
|
||||
func updateSunLight(hour: Double) {
|
||||
let normalized = (hour / 24.0) * (2 * Double.pi)
|
||||
let x = Float(cos(normalized) * 8.0)
|
||||
let y = Float(max(sin(normalized) * 8.0, 1.0))
|
||||
let z = Float(sin(normalized + .pi / 3) * 6.0)
|
||||
sunNode.position = SCNVector3(x, y, z)
|
||||
sunNode.look(at: SCNVector3(0, 0, 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProcessingOverlay: View {
|
||||
@State private var animate = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(.black.opacity(0.45))
|
||||
|
||||
Text("AI Processing...")
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.background {
|
||||
GlassBlurView(style: .systemUltraThinMaterialDark)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.clear, .white.opacity(0.6), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.rotationEffect(.degrees(18))
|
||||
.offset(x: animate ? 160 : -160)
|
||||
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
|
||||
.blendMode(.screen)
|
||||
.mask(Capsule().frame(height: 44))
|
||||
)
|
||||
}
|
||||
.padding(12)
|
||||
.onAppear { animate = true }
|
||||
}
|
||||
}
|
||||
|
||||
private struct DashedSunLine: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75))
|
||||
path.addQuadCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25),
|
||||
control: CGPoint(x: rect.midX, y: rect.minY + 30)
|
||||
)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct CameraPicker: UIViewControllerRepresentable {
|
||||
@Binding var image: UIImage?
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||
picker.sourceType = .camera
|
||||
picker.cameraCaptureMode = .photo
|
||||
} else {
|
||||
picker.sourceType = .photoLibrary
|
||||
}
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
|
||||
private let parent: CameraPicker
|
||||
|
||||
init(_ parent: CameraPicker) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
parent.isPresented = false
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
if let captured = info[.originalImage] as? UIImage {
|
||||
parent.image = captured
|
||||
}
|
||||
parent.isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,960 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: – Oracle Canvas Modes
|
||||
enum OracleMode: String, CaseIterable {
|
||||
case pipeline = "Pipeline"
|
||||
case teamPerformance = "Team Performance"
|
||||
case accountTimeline = "Account Timeline"
|
||||
case leadMap = "Lead Map"
|
||||
case calendarTasks = "Calendar & Tasks"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .pipeline: return "square.grid.3x1.below.line.grid.1x2"
|
||||
case .teamPerformance: return "person.3"
|
||||
case .accountTimeline: return "clock.arrow.circlepath"
|
||||
case .leadMap: return "map"
|
||||
case .calendarTasks: return "calendar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline mock data (extended with detail fields)
|
||||
struct OracleLeadCard: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String
|
||||
let name: String
|
||||
let company: String
|
||||
let value: String
|
||||
let status: LeadStatus
|
||||
let phone: String
|
||||
let interest: String
|
||||
let qualification: String
|
||||
}
|
||||
|
||||
private let pipelineData: [(stage: String, cards: [OracleLeadCard])] = [
|
||||
("New", [
|
||||
OracleLeadCard(initials: "JW", name: "James Wilson", company: "Website", value: "AED 3.5M", status: .new,
|
||||
phone: "+971 52 456 7890", interest: "1BR Investment", qualification: "Potential"),
|
||||
]),
|
||||
("Qualified", [
|
||||
OracleLeadCard(initials: "FH", name: "Fatima Hassan", company: "WhatsApp", value: "AED 12M", status: .qualified,
|
||||
phone: "+971 54 321 0987", interest: "3BR + Maid", qualification: "Whale"),
|
||||
OracleLeadCard(initials: "SC", name: "Sarah Chen", company: "Walk-in", value: "AED 6.5M", status: .engaged,
|
||||
phone: "+971 50 987 6543", interest: "2BR Sea View", qualification: "Potential"),
|
||||
]),
|
||||
("Proposal", [
|
||||
OracleLeadCard(initials: "MA", name: "Mohammed Al-Rashid", company: "WhatsApp", value: "AED 15M+", status: .hot,
|
||||
phone: "+971 55 123 4567", interest: "Penthouse Suite", qualification: "Whale"),
|
||||
]),
|
||||
("Closed", [
|
||||
OracleLeadCard(initials: "DK", name: "David Kumar", company: "Walk-in", value: "AED 20M", status: .closed,
|
||||
phone: "+971 56 789 0123", interest: "Full Floor", qualification: "Whale"),
|
||||
]),
|
||||
]
|
||||
|
||||
struct TeamMemberData: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String; let name: String; let deals: Int; let revenue: String; let trend: String
|
||||
}
|
||||
private let teamData: [TeamMemberData] = [
|
||||
.init(initials: "RA", name: "Rania Al-Farsi", deals: 42, revenue: "$2.1M", trend: "↑ 18%"),
|
||||
.init(initials: "KM", name: "Khaled Mensah", deals: 31, revenue: "$1.6M", trend: "↑ 12%"),
|
||||
.init(initials: "LT", name: "Lina Torres", deals: 28, revenue: "$1.3M", trend: "→ 2%"),
|
||||
.init(initials: "AH", name: "Ahmed Hassan", deals: 19, revenue: "$0.9M", trend: "↓ 5%"),
|
||||
]
|
||||
|
||||
struct OracleTimelineEvent: Identifiable {
|
||||
let id = UUID()
|
||||
let badge: String; let summary: String; let when: String; let detail: String
|
||||
}
|
||||
private let timelineEvents: [OracleTimelineEvent] = [
|
||||
.init(badge: "MEETING", summary: "VR Amenity Tour – Apex Innovations", when: "2h ago",
|
||||
detail: "CFO and Legal Director attended. Strong reaction to the panoramic sea view suite. Follow-up proposal requested by Tuesday."),
|
||||
.init(badge: "EMAIL", summary: "Proposal deck sent to legal team", when: "Yesterday",
|
||||
detail: "58-page proposal + payment plan schedule sent via DocuSign. Legal team has 5 business days to review."),
|
||||
.init(badge: "CALL", summary: "Budget discussion – CFO confirmed", when: "Mon",
|
||||
detail: "Budget ceiling confirmed at AED 15M+. CFO expressed strong preference for a penthouse unit with private terrace."),
|
||||
.init(badge: "VISIT", summary: "Site walkthrough – Penthouse Suite", when: "Last week",
|
||||
detail: "First in-person visit. 45-minute walkthrough of Penthouse A & B. Visitor dwell time analysis showed 'excited' sentiment for 90% of the visit."),
|
||||
]
|
||||
|
||||
struct RegionPin: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String; let country: String; let count: Int; let temp: String; let topLead: String
|
||||
}
|
||||
private let mapPins: [RegionPin] = [
|
||||
.init(label: "UAE", country: "🇦🇪", count: 8, temp: "hot", topLead: "Mohammed Al-Rashid"),
|
||||
.init(label: "Saudi Arabia", country: "🇸🇦", count: 5, temp: "warm", topLead: "Al-Mansour Group"),
|
||||
.init(label: "UK", country: "🇬🇧", count: 3, temp: "cold", topLead: "Rexford Capital"),
|
||||
.init(label: "USA", country: "🇺🇸", count: 4, temp: "warm", topLead: "Apex Innovations"),
|
||||
.init(label: "India", country: "🇮🇳", count: 6, temp: "hot", topLead: "Starlight Systems"),
|
||||
.init(label: "Germany", country: "🇩🇪", count: 2, temp: "cold", topLead: "TechWave GmbH"),
|
||||
]
|
||||
|
||||
struct CalTask: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String; let subtitle: String; let due: String
|
||||
}
|
||||
private let calTasks: [CalTask] = [
|
||||
.init(title: "Follow up with Mohammed", subtitle: "High-value penthouse lead – 2 unread messages", due: "Today 3 PM"),
|
||||
.init(title: "Send contract to Fatima", subtitle: "3BR unit finalised – payment plan to confirm", due: "Tomorrow 10 AM"),
|
||||
.init(title: "Schedule VR tour – James", subtitle: "Website lead, potential 1BR investor", due: "Thu 2 PM"),
|
||||
]
|
||||
|
||||
// MARK: – OracleView (main)
|
||||
struct OracleView: View {
|
||||
@State private var selectedMode: OracleMode = .pipeline
|
||||
@State private var prompt = "Show me a pipeline view by stage for Q4."
|
||||
@State private var insightText = "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
@State private var isSubmitting = false
|
||||
|
||||
// Sheet states
|
||||
@State private var selectedLead: OracleLeadCard? = nil
|
||||
@State private var selectedMember: TeamMemberData? = nil
|
||||
@State private var selectedRegion: RegionPin? = nil
|
||||
@State private var scheduledTask: CalTask? = nil
|
||||
@State private var showScheduleConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
pageHeader
|
||||
.padding(.horizontal, 24).padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
insightCard
|
||||
.padding(.horizontal, 24).padding(.bottom, 14)
|
||||
|
||||
ScrollView {
|
||||
canvasView
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
}
|
||||
|
||||
promptBar
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
|
||||
// Lead detail sheet
|
||||
.sheet(item: $selectedLead) { card in
|
||||
LeadDetailSheet(card: card)
|
||||
}
|
||||
// Team member sheet
|
||||
.sheet(item: $selectedMember) { member in
|
||||
MemberDetailSheet(member: member)
|
||||
}
|
||||
// Region callout sheet
|
||||
.sheet(item: $selectedRegion) { pin in
|
||||
RegionDetailSheet(pin: pin)
|
||||
}
|
||||
// Schedule confirmation alert
|
||||
.alert("Confirm Schedule",
|
||||
isPresented: $showScheduleConfirm,
|
||||
presenting: scheduledTask) { task in
|
||||
Button("Schedule") {
|
||||
// In a real app this would create a calendar event
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { task in
|
||||
Text("Add \"\(task.title)\" to your calendar for \(task.due)?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sub-views
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Oracle").font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("AI intelligence pipeline").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var insightCard: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(LinearGradient(colors: [Color(red: 0.58, green: 0.77, blue: 0.99), VelocityTheme.accent],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 3)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("AI INSIGHT").font(.system(size: 9, weight: .semibold)).tracking(1.5)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text(insightText).font(.system(size: 13)).foregroundStyle(VelocityTheme.foreground).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.55))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var canvasView: some View {
|
||||
switch selectedMode {
|
||||
case .pipeline:
|
||||
PipelineCanvas(onSelectLead: { selectedLead = $0 })
|
||||
case .teamPerformance:
|
||||
TeamPerformanceCanvas(onSelectMember: { selectedMember = $0 })
|
||||
case .accountTimeline:
|
||||
AccountTimelineCanvas()
|
||||
case .leadMap:
|
||||
LeadMapCanvas(onSelectRegion: { selectedRegion = $0 })
|
||||
case .calendarTasks:
|
||||
CalendarCanvas(onSchedule: { task in
|
||||
scheduledTask = task
|
||||
showScheduleConfirm = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Prompt Bar
|
||||
private var promptBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextField("Ask Oracle anything…", text: $prompt)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { submitPrompt() }
|
||||
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Menu {
|
||||
ForEach(OracleMode.allCases, id: \.self) { mode in
|
||||
Button {
|
||||
selectedMode = mode
|
||||
prompt = modeSamplePrompt(mode)
|
||||
insightText = oracleInsight(for: mode)
|
||||
} label: {
|
||||
Label(mode.rawValue, systemImage: mode.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 10))
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
Image(systemName: "chevron.down").font(.system(size: 8))
|
||||
}
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.14))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.3), lineWidth: 1)))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: submitPrompt) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSubmitting ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.5), radius: 8)
|
||||
if isSubmitting {
|
||||
ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(0.6)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill").font(.system(size: 12)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.disabled(isSubmitting || prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.bottom, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color(red: 0.039, green: 0.043, blue: 0.063).opacity(0.95))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.white.opacity(0.11), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 20, y: -4)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – Prompt logic
|
||||
private func submitPrompt() {
|
||||
let clean = prompt.trimmingCharacters(in: .whitespaces)
|
||||
guard !clean.isEmpty && !isSubmitting else { return }
|
||||
isSubmitting = true
|
||||
let lower = clean.lowercased()
|
||||
if lower.contains("team") || lower.contains("performance") || lower.contains("sales") {
|
||||
selectedMode = .teamPerformance
|
||||
} else if lower.contains("account") || lower.contains("apex") || lower.contains("timeline") {
|
||||
selectedMode = .accountTimeline
|
||||
} else if lower.contains("map") || lower.contains("geographic") || lower.contains("location") {
|
||||
selectedMode = .leadMap
|
||||
} else if lower.contains("calendar") || lower.contains("schedule") || lower.contains("task") {
|
||||
selectedMode = .calendarTasks
|
||||
} else {
|
||||
selectedMode = .pipeline
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
insightText = oracleInsight(for: selectedMode)
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func modeSamplePrompt(_ mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Show me a pipeline view by stage for Q4."
|
||||
case .teamPerformance: return "What's the performance of the sales team this month?"
|
||||
case .accountTimeline: return "Find all contacts at Apex Innovations and their recent activity."
|
||||
case .leadMap: return "Give me a geographic map of all leads."
|
||||
case .calendarTasks: return "Schedule follow-ups with the top 3 high-value leads."
|
||||
}
|
||||
}
|
||||
|
||||
private func oracleInsight(for mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
case .teamPerformance: return "Rania Al-Farsi leads the team at $2.1M closed. Overall quota attainment is at 87% — ahead of last month."
|
||||
case .accountTimeline: return "Apex Innovations has 4 active stakeholders. Confusion detected in legal stage — recommend expediting contract review."
|
||||
case .leadMap: return "UAE and India show the hottest lead concentration. 8 high-value prospects in UAE require immediate outreach."
|
||||
case .calendarTasks: return "3 high-priority follow-ups scheduled. Mohammed Al-Rashid has 2 unread messages — respond within 24h."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline Canvas
|
||||
private struct PipelineCanvas: View {
|
||||
let onSelectLead: (OracleLeadCard) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 200), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: cols, alignment: .leading, spacing: 12) {
|
||||
ForEach(pipelineData, id: \.stage) { col in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(col.stage.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(col.cards.count)")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
ForEach(col.cards) { card in
|
||||
TappableLeadCard(card: card, onTap: { onSelectLead(card) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderSubtle, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableLeadCard: View {
|
||||
let card: OracleLeadCard
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(card.status.color.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(card.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(card.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text(card.company).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(card.value).font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Image(systemName: "chevron.right").font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.10) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1))
|
||||
)
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Detail Sheet
|
||||
private struct LeadDetailSheet: View {
|
||||
let card: OracleLeadCard
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Avatar + name
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(card.status.color.opacity(0.20)).frame(width: 60, height: 60)
|
||||
Text(card.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(card.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Text(card.status.rawValue)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(card.status.color)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(Capsule().fill(card.status.color.opacity(0.14)))
|
||||
Text(card.qualification).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Deal Value", value: card.value)
|
||||
DetailField(label: "Source", value: card.company)
|
||||
DetailField(label: "Interest", value: card.interest)
|
||||
DetailField(label: "Phone", value: card.phone)
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 12) {
|
||||
ActionChip(icon: "phone.fill", label: "Call", color: VelocityTheme.success)
|
||||
ActionChip(icon: "message.fill", label: "Message", color: VelocityTheme.accent)
|
||||
ActionChip(icon: "calendar.badge.plus", label: "Schedule", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Lead Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailField: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased()).font(.system(size: 9, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.07), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActionChip: View {
|
||||
let icon: String; let label: String; let color: Color
|
||||
@State private var pressed = false
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon).font(.system(size: 12))
|
||||
Text(label).font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(color.opacity(pressed ? 0.22 : 0.13))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.30), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.96 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture { pressed = true; DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { pressed = false } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Performance Canvas
|
||||
private struct TeamPerformanceCanvas: View {
|
||||
let onSelectMember: (TeamMemberData) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
quotaPanel
|
||||
teamListPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var quotaPanel: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.06), lineWidth: 10).frame(width: 110, height: 110)
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.87)
|
||||
.stroke(AngularGradient(colors: [VelocityTheme.accent, Color(red: 0.13, green: 0.83, blue: 0.93)],
|
||||
center: .center),
|
||||
style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 110, height: 110)
|
||||
VStack(spacing: 2) {
|
||||
Text("87%").font(.system(size: 26, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("QUOTA").font(.system(size: 8, weight: .semibold)).tracking(1.2).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Quota Attainment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Monthly target exceeded").font(.system(size: 11)).foregroundStyle(VelocityTheme.success)
|
||||
Text("Q4 FY2025–26").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var teamListPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("TEAM PERFORMANCE").font(.system(size: 10, weight: .semibold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 8)
|
||||
ForEach(teamData) { member in
|
||||
TappableTeamRow(member: member, onTap: { onSelectMember(member) })
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableTeamRow: View {
|
||||
let member: TeamMemberData
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(member.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.name).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(member.deals) deals closed").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(member.revenue).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(member.trend)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(member.trend.hasPrefix("↑") ? VelocityTheme.success :
|
||||
member.trend.hasPrefix("↓") ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.08) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.25) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.98 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Member Detail Sheet
|
||||
private struct MemberDetailSheet: View {
|
||||
let member: TeamMemberData
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 60, height: 60)
|
||||
Text(member.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(member.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Executive").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Revenue Closed", value: member.revenue)
|
||||
DetailField(label: "Deals Closed", value: "\(member.deals)")
|
||||
DetailField(label: "Trend", value: member.trend)
|
||||
DetailField(label: "Period", value: "Q4 FY2025–26")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Team Member")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Account Timeline Canvas
|
||||
private struct AccountTimelineCanvas: View {
|
||||
@State private var expandedId: UUID? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
// Account overview
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("ACCOUNT OVERVIEW").font(.system(size: 9, weight: .semibold)).tracking(1.5).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Apex Innovations").font(.system(size: 24, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 14) {
|
||||
InfoMini(label: "Deal Value", value: "AED 15M+")
|
||||
InfoMini(label: "Primary Contact", value: "CEO – James T.")
|
||||
InfoMini(label: "Industry", value: "Technology")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
|
||||
// Expandable timeline
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Activity Timeline").font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).padding(.bottom, 16)
|
||||
ForEach(Array(timelineEvents.enumerated()), id: \.offset) { i, event in
|
||||
TimelineEventRow(event: event, isLast: i == timelineEvents.count - 1,
|
||||
isExpanded: expandedId == event.id) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
expandedId = expandedId == event.id ? nil : event.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineEventRow: View {
|
||||
let event: OracleTimelineEvent
|
||||
let isLast: Bool
|
||||
let isExpanded: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(spacing: 0) {
|
||||
Circle().fill(VelocityTheme.accent).frame(width: 10, height: 10)
|
||||
.overlay(Circle().stroke(VelocityTheme.background, lineWidth: 2))
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [VelocityTheme.accent.opacity(0.5), .clear],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 2)
|
||||
.frame(height: isExpanded ? 100 : 50)
|
||||
.animation(.easeInOut(duration: 0.25), value: isExpanded)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(event.badge).font(.system(size: 9, weight: .bold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.15))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VelocityTheme.accent.opacity(0.25), lineWidth: 1)))
|
||||
Spacer()
|
||||
Text(event.when).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Text(event.summary).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
if isExpanded {
|
||||
Text(event.detail).font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isExpanded ? VelocityTheme.accent.opacity(0.06) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isExpanded ? VelocityTheme.accent.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.onTapGesture { onTap() }
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoMini: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.system(size: 9)).tracking(0.8).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Map Canvas
|
||||
private struct LeadMapCanvas: View {
|
||||
let onSelectRegion: (RegionPin) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 140), spacing: 10)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 16) {
|
||||
LegendDot(color: VelocityTheme.danger, label: "Hot Lead")
|
||||
LegendDot(color: Color(red: 0.13, green: 0.83, blue: 0.93), label: "Warm Lead")
|
||||
LegendDot(color: VelocityTheme.mutedFg, label: "Cold Lead")
|
||||
Spacer()
|
||||
}
|
||||
LazyVGrid(columns: cols, spacing: 10) {
|
||||
ForEach(mapPins) { pin in
|
||||
TappableRegionPin(pin: pin, onTap: { onSelectRegion(pin) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableRegionPin: View {
|
||||
let pin: RegionPin
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(pin.country).font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pin.label).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(pinColor).frame(width: 6, height: 6).shadow(color: pinColor.opacity(0.8), radius: 3)
|
||||
Text("\(pin.count) leads").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
.font(.system(size: 13)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? pinColor.opacity(0.12) : Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(pinColor.opacity(pressed ? 0.5 : 0.25), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegendDot: View {
|
||||
let color: Color; let label: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8).shadow(color: color.opacity(0.8), radius: 3)
|
||||
Text(label).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Region Detail Sheet
|
||||
private struct RegionDetailSheet: View {
|
||||
let pin: RegionPin
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
Text(pin.country).font(.system(size: 52))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(pin.label).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(pinColor).frame(width: 7, height: 7)
|
||||
Text(pin.temp.capitalized + " Market")
|
||||
.font(.system(size: 12, weight: .medium)).foregroundStyle(pinColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Active Leads", value: "\(pin.count)")
|
||||
DetailField(label: "Top Lead", value: pin.topLead)
|
||||
DetailField(label: "Temperature", value: pin.temp.capitalized)
|
||||
DetailField(label: "Priority", value: pin.temp == "hot" ? "High 🔴" : pin.temp == "warm" ? "Medium 🟡" : "Low ⚪")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Region Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Calendar Canvas
|
||||
private struct CalendarCanvas: View {
|
||||
let onSchedule: (CalTask) -> Void
|
||||
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
weekPanel
|
||||
tasksPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var weekPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weekly Schedule").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(days, id: \.self) { day in
|
||||
Text(day).font(.system(size: 10, weight: .medium)).tracking(0.5)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { i, _ in
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(i == 2 ? VelocityTheme.accent.opacity(0.15) : Color.white.opacity(0.03))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(i == 2 ? VelocityTheme.accent.opacity(0.3) : Color.white.opacity(0.05), lineWidth: 1))
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var tasksPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(Color(red: 0.13, green: 0.83, blue: 0.93)).frame(width: 6, height: 6)
|
||||
Text("Tasks & Actions").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
ForEach(calTasks) { task in
|
||||
CalTaskRow(task: task, onSchedule: { onSchedule(task) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalTaskRow: View {
|
||||
let task: CalTask
|
||||
let onSchedule: () -> Void
|
||||
@State private var scheduled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(task.title).font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(scheduled ? "Scheduled ✓" : "Action")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(scheduled ? VelocityTheme.success : VelocityTheme.mutedFg)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.12) : Color.white.opacity(0.06)))
|
||||
}
|
||||
Text(task.subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg).lineLimit(2)
|
||||
HStack {
|
||||
Image(systemName: "clock").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(task.due).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Spacer()
|
||||
Button {
|
||||
onSchedule()
|
||||
withAnimation(.easeInOut(duration: 0.3)) { scheduled = true }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: scheduled ? "checkmark" : "calendar.badge.plus")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
Text(scheduled ? "Scheduled" : "Schedule")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12).padding(.vertical, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 7)
|
||||
.fill(scheduled ? VelocityTheme.success : VelocityTheme.accent)
|
||||
.shadow(color: (scheduled ? VelocityTheme.success : VelocityTheme.accent).opacity(0.4), radius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.05) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(scheduled ? VelocityTheme.success.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
@@ -1,413 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
private var store: AppStore { AppStore.shared }
|
||||
private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99)
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
kpiGrid
|
||||
analyticsRow
|
||||
bottomRow
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
|
||||
// MARK: – Sub-views extracted so the type-checker can cope
|
||||
private var pageHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sentinel")
|
||||
.font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("FaceID · visitor analytics · real-time alerts")
|
||||
.font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var kpiGrid: some View {
|
||||
let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)]
|
||||
return LazyVGrid(columns: cols, spacing: 12) {
|
||||
SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent,
|
||||
label: "Active Visitors", value: "\(store.visitors.count)",
|
||||
sub: "Currently tracked", badge: "LIVE")
|
||||
SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success,
|
||||
label: "Avg Sentiment", value: "\(avgSentiment)%",
|
||||
sub: "Overall mood")
|
||||
SentinelKPI(icon: "eye.fill", iconColor: indigo,
|
||||
label: "Detection Accuracy", value: "\(avgConfidence)%",
|
||||
sub: "Avg confidence")
|
||||
SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning,
|
||||
label: "Tracked Today", value: "47",
|
||||
sub: "Unique faces")
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.4), value: store.visitors.count)
|
||||
}
|
||||
|
||||
private var analyticsRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
ZoneAnalyticsPanel()
|
||||
ClientInsightsPanel()
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14),
|
||||
GridItem(.flexible(), spacing: 14)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) {
|
||||
SentimentDistributionPanel(visitors: store.visitors)
|
||||
DwellTimePanel()
|
||||
AlertPanel(isActive: store.isAlertActive, message: store.alertMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var avgSentiment: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0) { $0 + $1.sentiment.score }
|
||||
return total / store.visitors.count
|
||||
}
|
||||
|
||||
private var avgConfidence: Int {
|
||||
guard !store.visitors.isEmpty else { return 0 }
|
||||
let total = store.visitors.reduce(0.0) { $0 + $1.confidence }
|
||||
return Int((total / Double(store.visitors.count)) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – KPI Card
|
||||
private struct SentinelKPI: View {
|
||||
let icon: String; let iconColor: Color
|
||||
let label: String; let value: String; let sub: String
|
||||
var badge: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34)
|
||||
Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor)
|
||||
}
|
||||
Spacer()
|
||||
if let badge {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 5, height: 5)
|
||||
Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
.contentTransition(.numericText())
|
||||
Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Zone Analytics
|
||||
private struct ZoneAnalyticsPanel: View {
|
||||
private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [
|
||||
("A", "Main Showroom", 5, 72),
|
||||
("B", "Penthouse Gallery",3, 85),
|
||||
("C", "Amenity Deck VR", 2, 68),
|
||||
("D", "Reception", 2, 90),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(zones, id: \.id) { zone in
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28)
|
||||
Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 4) {
|
||||
let c: Color = zone.sentiment >= 80 ? VelocityTheme.success :
|
||||
zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning
|
||||
Circle().fill(c).frame(width: 7, height: 7)
|
||||
Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Client Insights
|
||||
private struct ClientInsightsPanel: View {
|
||||
private struct Insight {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
|
||||
var icon: String {
|
||||
score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle"
|
||||
}
|
||||
var scoreColor: Color {
|
||||
score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private let insights: [Insight] = [
|
||||
.init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92,
|
||||
insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.",
|
||||
color: VelocityTheme.success),
|
||||
.init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45,
|
||||
insight: "Initial interest detected but hesitation around pricing model tier.",
|
||||
color: VelocityTheme.warning),
|
||||
.init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68,
|
||||
insight: "Confusion markers during contract terms review. Legal team is the blocker.",
|
||||
color: VelocityTheme.danger),
|
||||
.init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78,
|
||||
insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.",
|
||||
color: VelocityTheme.accent),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
insightHeader
|
||||
insightGrid
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var insightHeader: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("AI Strategic Insights")
|
||||
.font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private var insightGrid: some View {
|
||||
let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)]
|
||||
return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) {
|
||||
ForEach(insights, id: \.name) { item in
|
||||
InsightCard(
|
||||
name: item.name, stage: item.stage, sentiment: item.sentiment,
|
||||
score: item.score, insight: item.insight, color: item.color,
|
||||
icon: item.icon, scoreColor: item.scoreColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InsightCard: View {
|
||||
struct Item {
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" }
|
||||
var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger }
|
||||
}
|
||||
|
||||
// Accept ClientInsightsPanel.Insight via protocol duck-typing workaround:
|
||||
let name: String; let stage: String; let sentiment: String
|
||||
let score: Int; let insight: String; let color: Color
|
||||
let icon: String; let scoreColor: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28)
|
||||
Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(score)").font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(scoreColor)
|
||||
.padding(.horizontal, 6).padding(.vertical, 2)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15)))
|
||||
}
|
||||
Text(name).font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).lineLimit(1)
|
||||
Text(insight).font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg).lineLimit(3)
|
||||
HStack {
|
||||
Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
Spacer()
|
||||
Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: – Sentiment Distribution
|
||||
private struct SentimentDistributionPanel: View {
|
||||
let visitors: [Visitor]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
ForEach(SentimentType.allCases, id: \.self) { type in
|
||||
let count = visitors.filter { $0.sentiment == type }.count
|
||||
let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(type.emoji).font(.system(size: 14))
|
||||
Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5)
|
||||
RoundedRectangle(cornerRadius: 3).fill(type.color)
|
||||
.frame(width: geo.size.width * fraction, height: 5)
|
||||
.animation(.easeOut(duration: 0.6), value: fraction)
|
||||
}
|
||||
}
|
||||
.frame(height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Dwell Time Panel
|
||||
private struct DwellTimePanel: View {
|
||||
private let data: [(range: String, count: Int, trend: String)] = [
|
||||
("< 5 min", 3, "down"),
|
||||
("5–15 min", 5, "up"),
|
||||
("15–30 min", 8, "up"),
|
||||
("> 30 min", 4, "stable"),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)]
|
||||
LazyVGrid(columns: cols, spacing: 8) {
|
||||
ForEach(data, id: \.range) { item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Image(systemName: item.trend == "up" ? "arrow.up.right" :
|
||||
item.trend == "down" ? "arrow.down.right" : "minus")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(item.trend == "up" ? VelocityTheme.success :
|
||||
item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Alert Panel
|
||||
private struct AlertPanel: View {
|
||||
let isActive: Bool
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(isActive ? "Active" : "Clear")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15))
|
||||
.overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1)))
|
||||
}
|
||||
|
||||
if isActive {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.warning)
|
||||
Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3)
|
||||
Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 14)).foregroundStyle(VelocityTheme.success)
|
||||
Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1)))
|
||||
.transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity),
|
||||
removal: .scale(scale: 0.95).combined(with: .opacity)))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.animation(.easeInOut(duration: 0.3), value: isActive)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
// Page header
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
// System (live) section
|
||||
SettingsSection(title: "System") {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(VelocityTheme.success.opacity(0.12)).frame(width: 30, height: 30)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
Text("Connection Status").font(.system(size: 14)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(VelocityTheme.success).frame(width: 6, height: 6)
|
||||
Text("Online").font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.vertical, 12)
|
||||
}
|
||||
|
||||
// Backend section
|
||||
SettingsSection(title: "Backend") {
|
||||
SettingsRow(label: "ComfyUI Endpoint",
|
||||
value: "http://192.168.x.x:8000",
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Dream Weaver Path",
|
||||
value: "/dream-weaver",
|
||||
icon: "arrow.triangle.branch",
|
||||
accentColor: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
// Display section
|
||||
SettingsSection(title: "Display") {
|
||||
SettingsRow(label: "Orientation",
|
||||
value: "Landscape Only",
|
||||
icon: "rectangle.landscape.rotate",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Theme",
|
||||
value: "Dark",
|
||||
icon: "moon.fill",
|
||||
accentColor: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
// App info section
|
||||
SettingsSection(title: "About") {
|
||||
SettingsRow(label: "Version",
|
||||
value: "1.1.0",
|
||||
icon: "info.circle",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(label: "Build",
|
||||
value: "SwiftUI · iOS 17+",
|
||||
icon: "hammer",
|
||||
accentColor: VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
10
iOS/README.md
Normal file
10
iOS/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Velocity iOS Source Of Truth
|
||||
|
||||
The active iPad application source is:
|
||||
|
||||
- `iOS/velocity-ipad/velocity`
|
||||
- `iOS/velocity-ipad/velocity.xcodeproj`
|
||||
- `iOS/velocity-ipad/velocityTests`
|
||||
|
||||
The root-level prototype source folders that previously duplicated iPad code have been removed to prevent drift. The separate `iOS/velocity-iphone` target is intentionally retained as a distinct iPhone companion app.
|
||||
|
||||
@@ -8,10 +8,23 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = A27B23452F58DAF100A74A49 /* Alamofire */; };
|
||||
B31D10012F6A000100000004 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B31D10012F6A000100000002 /* XCTest.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
B31D10012F6A000100000005 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = A27B230D2F58D9C300A74A49 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = A27B23142F58D9C300A74A49;
|
||||
remoteInfo = velocity;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = velocity.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B31D10012F6A000100000001 /* velocityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = velocityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B31D10012F6A000100000002 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -33,6 +46,11 @@
|
||||
path = velocity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B31D10012F6A000100000003 /* velocityTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = velocityTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -44,6 +62,14 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B31D10012F6A000100000008 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
B31D10012F6A000100000004 /* XCTest.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -51,6 +77,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
B31D10012F6A000100000003 /* velocityTests */,
|
||||
B31D10012F6A00010000000E /* Frameworks */,
|
||||
A27B23162F58D9C300A74A49 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -59,10 +87,19 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */,
|
||||
B31D10012F6A000100000001 /* velocityTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B31D10012F6A00010000000E /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B31D10012F6A000100000002 /* XCTest.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -89,6 +126,29 @@
|
||||
productReference = A27B23152F58D9C300A74A49 /* velocity.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
B31D10012F6A00010000000A /* velocityTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B31D10012F6A00010000000B /* Build configuration list for PBXNativeTarget "velocityTests" */;
|
||||
buildPhases = (
|
||||
B31D10012F6A000100000007 /* Sources */,
|
||||
B31D10012F6A000100000008 /* Frameworks */,
|
||||
B31D10012F6A000100000009 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
B31D10012F6A000100000006 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
B31D10012F6A000100000003 /* velocityTests */,
|
||||
);
|
||||
name = velocityTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = velocityTests;
|
||||
productReference = B31D10012F6A000100000001 /* velocityTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -102,6 +162,9 @@
|
||||
A27B23142F58D9C300A74A49 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
B31D10012F6A00010000000A = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */;
|
||||
@@ -122,6 +185,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A27B23142F58D9C300A74A49 /* velocity */,
|
||||
B31D10012F6A00010000000A /* velocityTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -134,6 +198,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B31D10012F6A000100000009 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -144,8 +215,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B31D10012F6A000100000007 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
B31D10012F6A000100000006 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = A27B23142F58D9C300A74A49 /* velocity */;
|
||||
targetProxy = B31D10012F6A000100000005 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A27B231E2F58D9C400A74A49 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -199,7 +285,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
@@ -256,7 +342,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
@@ -278,19 +364,17 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = velocity/Info.plist;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipad;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -298,7 +382,7 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 2;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
@@ -314,19 +398,17 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = velocity/Info.plist;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipad;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
@@ -334,7 +416,59 @@
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TARGETED_DEVICE_FAMILY = 2;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
B31D10012F6A00010000000C /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipadTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/velocity.app/velocity";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B31D10012F6A00010000000D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineuron.velocity.ipadTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 2;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/velocity.app/velocity";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
@@ -359,6 +493,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
B31D10012F6A00010000000B /* Build configuration list for PBXNativeTarget "velocityTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
B31D10012F6A00010000000C /* Debug */,
|
||||
B31D10012F6A00010000000D /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
42
iOS/velocity-ipad/velocity/App/ConfigurationGateView.swift
Normal file
42
iOS/velocity-ipad/velocity/App/ConfigurationGateView.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConfigurationGateView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: 10) {
|
||||
Text("Configure Velocity")
|
||||
.font(.system(size: 34, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Text("This iPad now expects a real runtime session. Add the production endpoint and operator credentials before live data can load.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 700)
|
||||
}
|
||||
|
||||
SessionConfigurationPanel(
|
||||
title: "Secure Session Setup",
|
||||
subtitle: "Runtime credentials replace the old build-time-only configuration path. Velocity saves secrets in Keychain and immediately tries a live refresh after saving.",
|
||||
primaryActionTitle: "Save and continue",
|
||||
allowsClearingStoredConfiguration: false
|
||||
)
|
||||
.frame(maxWidth: 760)
|
||||
|
||||
Text("Production note: this setup flow does not bypass backend TLS failures. If the configured endpoint is unhealthy, Velocity will save the session and report the live refresh error truthfully.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 760)
|
||||
}
|
||||
.padding(28)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import SwiftUI
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case clients = "Clients"
|
||||
case imports = "Imports"
|
||||
case communications = "Communications"
|
||||
case calendar = "Calendar"
|
||||
case oracle = "Oracle"
|
||||
@@ -10,9 +12,20 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var displayTitle: String {
|
||||
switch self {
|
||||
case .sentinel:
|
||||
return SentinelScope.navigationTitle
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .clients: return "person.text.rectangle"
|
||||
case .imports: return "tray.and.arrow.down"
|
||||
case .communications: return "phone.connection"
|
||||
case .calendar: return "calendar.badge.clock"
|
||||
case .oracle: return "message.and.waveform"
|
||||
@@ -25,6 +38,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var accentColor: Color {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .clients: return Color(red: 0.22, green: 0.78, blue: 0.96)
|
||||
case .imports: return Color(red: 0.94, green: 0.70, blue: 0.25)
|
||||
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
|
||||
@@ -37,14 +52,21 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
@State private var session = SessionStore.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
Group {
|
||||
if session.isConfigured {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
detailContent
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
} else {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
@@ -84,9 +106,14 @@ struct ContentView: View {
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
SidebarRow(section: section,
|
||||
isSelected: selectedSection == section)
|
||||
.onTapGesture { selectedSection = section }
|
||||
Button {
|
||||
selectedSection = section
|
||||
} label: {
|
||||
SidebarRow(section: section, isSelected: selectedSection == section)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.displayTitle)
|
||||
.accessibilityAddTraits(selectedSection == section ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
@@ -109,7 +136,7 @@ struct ContentView: View {
|
||||
Text(operatorName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(AppConfig.authModeDescription)
|
||||
Text(session.authModeDescription)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -130,6 +157,8 @@ struct ContentView: View {
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .clients: ClientsView()
|
||||
case .imports: ImportsView()
|
||||
case .communications: CommunicationsView()
|
||||
case .calendar: CalendarView()
|
||||
case .oracle: OracleView()
|
||||
@@ -144,11 +173,11 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var operatorName: String {
|
||||
AppConfig.apiEmail ?? "Velocity Operator"
|
||||
session.operatorIdentity
|
||||
}
|
||||
|
||||
private var operatorInitials: String {
|
||||
let source = AppConfig.apiEmail ?? "VO"
|
||||
let source = session.operatorIdentity
|
||||
let parts = source
|
||||
.replacingOccurrences(of: "@", with: " ")
|
||||
.split(separator: ".")
|
||||
@@ -170,7 +199,7 @@ private struct SidebarRow: View {
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.rawValue)
|
||||
Text(section.displayTitle)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal file
302
iOS/velocity-ipad/velocity/Core/Config/AppConfig.swift
Normal file
@@ -0,0 +1,302 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Central app configuration.
|
||||
/// Build settings remain the fallback, but production installs should prefer
|
||||
/// runtime configuration stored on-device.
|
||||
enum AppConfig {
|
||||
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
|
||||
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
|
||||
private static let runtimeDreamWeaverAPIKeyKey = "velocity.runtime.dream_weaver_api_key"
|
||||
private static let runtimeEmailKey = "velocity.runtime.email"
|
||||
private static let runtimePasswordKey = "velocity.runtime.password"
|
||||
private static let runtimeBearerTokenKey = "velocity.runtime.bearer_token"
|
||||
private static let runtimeAccessTokenKey = "velocity.runtime.access_token"
|
||||
private static let runtimeAccessTokenExpiresAtKey = "velocity.runtime.access_token_expires_at"
|
||||
private static let keychainService = "com.desineuron.velocity.ipad.session"
|
||||
|
||||
static func parsedValue(from infoDictionary: [String: Any]?, key: String) -> String? {
|
||||
let raw = infoDictionary?[key] as? String
|
||||
return sanitizedValue(raw, key: key)
|
||||
}
|
||||
|
||||
static func isLiveConfigured(
|
||||
bearerToken: String?,
|
||||
email: String?,
|
||||
password: String?
|
||||
) -> Bool {
|
||||
bearerToken != nil || (email != nil && password != nil)
|
||||
}
|
||||
|
||||
static func authModeDescription(
|
||||
bearerToken: String?,
|
||||
email: String?,
|
||||
password: String?
|
||||
) -> String {
|
||||
if bearerToken != nil {
|
||||
return "Bearer token"
|
||||
}
|
||||
if email != nil && password != nil {
|
||||
return "Email/password"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
private static func value(for key: String) -> String? {
|
||||
parsedValue(from: Bundle.main.infoDictionary, key: key)
|
||||
}
|
||||
|
||||
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed == "$(\(key))" {
|
||||
return nil
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static var baseURL: String {
|
||||
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
|
||||
}
|
||||
|
||||
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
|
||||
/// generation falls back to the main backend endpoint.
|
||||
static var dreamWeaverBaseURL: String {
|
||||
configuredDreamWeaverBaseURL ?? originBaseURL(from: baseURL)
|
||||
}
|
||||
|
||||
static var usesDedicatedDreamWeaverBaseURL: Bool {
|
||||
guard let configuredDreamWeaverBaseURL else {
|
||||
return false
|
||||
}
|
||||
return configuredDreamWeaverBaseURL != baseURL
|
||||
}
|
||||
|
||||
static var dreamWeaverAPIKey: String? {
|
||||
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
|
||||
}
|
||||
|
||||
static var apiEmail: String? {
|
||||
runtimeEmail ?? value(for: "API_EMAIL")
|
||||
}
|
||||
|
||||
static var apiPassword: String? {
|
||||
runtimePassword ?? value(for: "API_PASSWORD")
|
||||
}
|
||||
|
||||
static var apiBearerToken: String? {
|
||||
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
|
||||
}
|
||||
|
||||
static var apiAccessToken: String? {
|
||||
guard let expiresAt = runtimeAccessTokenExpiresAt, expiresAt > Date().addingTimeInterval(60) else {
|
||||
try? clearStoredAccessToken()
|
||||
return nil
|
||||
}
|
||||
return secret(account: runtimeAccessTokenKey)
|
||||
}
|
||||
|
||||
static var isLiveConfigured: Bool {
|
||||
isLiveConfigured(
|
||||
bearerToken: apiBearerToken,
|
||||
email: apiEmail,
|
||||
password: apiPassword
|
||||
)
|
||||
}
|
||||
|
||||
static var authModeDescription: String {
|
||||
authModeDescription(
|
||||
bearerToken: apiBearerToken,
|
||||
email: apiEmail,
|
||||
password: apiPassword
|
||||
)
|
||||
}
|
||||
|
||||
static var hasStoredRuntimeConfiguration: Bool {
|
||||
runtimeBaseURL != nil ||
|
||||
runtimeDreamWeaverBaseURL != nil ||
|
||||
runtimeEmail != nil ||
|
||||
runtimePassword != nil ||
|
||||
runtimeBearerToken != nil
|
||||
}
|
||||
|
||||
static func currentSessionConfiguration() -> AppSessionConfiguration {
|
||||
AppSessionConfiguration(
|
||||
baseURL: baseURL,
|
||||
dreamWeaverBaseURL: dreamWeaverBaseURL,
|
||||
usesDedicatedDreamWeaverBaseURL: usesDedicatedDreamWeaverBaseURL,
|
||||
hasDreamWeaverAPIKey: dreamWeaverAPIKey != nil,
|
||||
email: apiEmail,
|
||||
hasPassword: apiPassword != nil,
|
||||
hasBearerToken: apiBearerToken != nil,
|
||||
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
|
||||
)
|
||||
}
|
||||
|
||||
static func saveRuntimeConfiguration(
|
||||
baseURL: String,
|
||||
dreamWeaverBaseURL: String?,
|
||||
dreamWeaverAPIKey: String?,
|
||||
email: String?,
|
||||
password: String?,
|
||||
bearerToken: String?
|
||||
) throws {
|
||||
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
|
||||
|
||||
if let dreamWeaverBaseURL {
|
||||
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
}
|
||||
|
||||
if let email {
|
||||
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
}
|
||||
|
||||
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
|
||||
try storeSecret(password, account: runtimePasswordKey)
|
||||
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
static func clearStoredRuntimeConfiguration() throws {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
try deleteSecret(account: runtimePasswordKey)
|
||||
try deleteSecret(account: runtimeBearerTokenKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
static func saveAccessToken(_ token: String, expiresIn: Int?) throws {
|
||||
try storeSecret(token, account: runtimeAccessTokenKey)
|
||||
let lifetime = TimeInterval(max(expiresIn ?? 28_800, 60))
|
||||
UserDefaults.standard.set(Date().addingTimeInterval(lifetime).timeIntervalSince1970, forKey: runtimeAccessTokenExpiresAtKey)
|
||||
}
|
||||
|
||||
static func clearStoredAccessToken() throws {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeAccessTokenExpiresAtKey)
|
||||
try deleteSecret(account: runtimeAccessTokenKey)
|
||||
}
|
||||
|
||||
private static var runtimeBaseURL: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
}
|
||||
|
||||
private static var configuredDreamWeaverBaseURL: String? {
|
||||
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverBaseURL: String? {
|
||||
sanitizedValue(
|
||||
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
|
||||
key: runtimeDreamWeaverBaseURLKey
|
||||
)
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverAPIKey: String? {
|
||||
secret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
}
|
||||
|
||||
private static var runtimeEmail: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
|
||||
}
|
||||
|
||||
private static var runtimePassword: String? {
|
||||
secret(account: runtimePasswordKey)
|
||||
}
|
||||
|
||||
private static var runtimeBearerToken: String? {
|
||||
secret(account: runtimeBearerTokenKey)
|
||||
}
|
||||
|
||||
private static var runtimeAccessTokenExpiresAt: Date? {
|
||||
let rawValue = UserDefaults.standard.double(forKey: runtimeAccessTokenExpiresAtKey)
|
||||
guard rawValue > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Date(timeIntervalSince1970: rawValue)
|
||||
}
|
||||
|
||||
private static func originBaseURL(from rawValue: String) -> String {
|
||||
guard var components = URLComponents(string: rawValue) else {
|
||||
return rawValue
|
||||
}
|
||||
components.path = ""
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string ?? rawValue
|
||||
}
|
||||
|
||||
private static func secret(account: String) -> String? {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: account,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess else {
|
||||
return nil
|
||||
}
|
||||
guard let data = result as? Data else {
|
||||
return nil
|
||||
}
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
|
||||
private static func storeSecret(_ value: String?, account: String) throws {
|
||||
if let value, let data = value.data(using: .utf8) {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: account
|
||||
]
|
||||
|
||||
let attributes: [CFString: Any] = [
|
||||
kSecValueData: data
|
||||
]
|
||||
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecSuccess {
|
||||
return
|
||||
}
|
||||
|
||||
if status == errSecItemNotFound {
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData] = data
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw SessionPersistenceError.keychainWriteFailed(addStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw SessionPersistenceError.keychainWriteFailed(status)
|
||||
}
|
||||
|
||||
try deleteSecret(account: account)
|
||||
}
|
||||
|
||||
private static func deleteSecret(account: String) throws {
|
||||
let query: [CFString: Any] = [
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrService: keychainService,
|
||||
kSecAttrAccount: account
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw SessionPersistenceError.keychainDeleteFailed(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
|
||||
enum SessionAuthMode: String, CaseIterable, Identifiable {
|
||||
case emailPassword = "Email/password"
|
||||
case bearerToken = "Bearer token"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
enum SessionConfigurationSource: String {
|
||||
case buildConfiguration = "Build configuration"
|
||||
case secureDeviceStorage = "Secure device storage"
|
||||
}
|
||||
|
||||
struct AppSessionConfiguration: Equatable {
|
||||
let baseURL: String
|
||||
let dreamWeaverBaseURL: String
|
||||
let usesDedicatedDreamWeaverBaseURL: Bool
|
||||
let hasDreamWeaverAPIKey: Bool
|
||||
let email: String?
|
||||
let hasPassword: Bool
|
||||
let hasBearerToken: Bool
|
||||
let source: SessionConfigurationSource
|
||||
|
||||
var authMode: SessionAuthMode {
|
||||
hasBearerToken ? .bearerToken : .emailPassword
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
hasBearerToken || (email != nil && hasPassword)
|
||||
}
|
||||
|
||||
var authModeDescription: String {
|
||||
if hasBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if email != nil && hasPassword {
|
||||
return "Email/password"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var operatorIdentity: String {
|
||||
if let email, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if hasBearerToken {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var dreamWeaverEndpointModeDescription: String {
|
||||
usesDedicatedDreamWeaverBaseURL ? "Dedicated gateway" : "Shared with backend"
|
||||
}
|
||||
|
||||
var dreamWeaverAuthenticationDescription: String {
|
||||
hasDreamWeaverAPIKey ? "API key configured" : "No gateway key configured"
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionConfigurationDraft: Equatable {
|
||||
var baseURL: String
|
||||
var dreamWeaverBaseURL: String
|
||||
var dreamWeaverAPIKey: String
|
||||
var authMode: SessionAuthMode
|
||||
var email: String
|
||||
var password: String
|
||||
var bearerToken: String
|
||||
var existingDreamWeaverAPIKeyAvailable: Bool
|
||||
var existingPasswordAvailable: Bool
|
||||
var existingBearerTokenAvailable: Bool
|
||||
var baselineEmail: String?
|
||||
|
||||
var trimmedBaseURL: String? {
|
||||
Self.trimmedValue(baseURL)
|
||||
}
|
||||
|
||||
var trimmedEmail: String? {
|
||||
Self.trimmedValue(email)
|
||||
}
|
||||
|
||||
var trimmedPassword: String? {
|
||||
Self.trimmedValue(password)
|
||||
}
|
||||
|
||||
var trimmedBearerToken: String? {
|
||||
Self.trimmedValue(bearerToken)
|
||||
}
|
||||
|
||||
var normalizedBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverBaseURL: String? {
|
||||
Self.trimmedValue(dreamWeaverBaseURL)
|
||||
}
|
||||
|
||||
var normalizedDreamWeaverBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedDreamWeaverBaseURL)
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverAPIKey: String? {
|
||||
Self.trimmedValue(dreamWeaverAPIKey)
|
||||
}
|
||||
|
||||
func validationErrors() -> [String] {
|
||||
var errors: [String] = []
|
||||
|
||||
guard let trimmedBaseURL else {
|
||||
errors.append("Backend endpoint is required.")
|
||||
return errors
|
||||
}
|
||||
|
||||
guard URLComponents(string: trimmedBaseURL) != nil else {
|
||||
errors.append("Backend endpoint must be a valid URL.")
|
||||
return errors
|
||||
}
|
||||
|
||||
guard normalizedBaseURL != nil else {
|
||||
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
|
||||
return errors
|
||||
}
|
||||
|
||||
if let trimmedDreamWeaverBaseURL {
|
||||
guard URLComponents(string: trimmedDreamWeaverBaseURL) != nil else {
|
||||
errors.append("Dream Weaver endpoint must be a valid URL.")
|
||||
return errors
|
||||
}
|
||||
|
||||
guard normalizedDreamWeaverBaseURL != nil else {
|
||||
errors.append("Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in.")
|
||||
return errors
|
||||
}
|
||||
}
|
||||
|
||||
switch authMode {
|
||||
case .emailPassword:
|
||||
guard let trimmedEmail else {
|
||||
errors.append("Operator email is required for email/password login.")
|
||||
break
|
||||
}
|
||||
|
||||
guard trimmedEmail.contains("@"), trimmedEmail.contains(".") else {
|
||||
errors.append("Operator email must look like a valid email address.")
|
||||
break
|
||||
}
|
||||
|
||||
if trimmedPassword == nil &&
|
||||
!(existingPasswordAvailable && trimmedEmail.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame) {
|
||||
errors.append("Password is required for email/password login.")
|
||||
}
|
||||
|
||||
case .bearerToken:
|
||||
if trimmedBearerToken == nil && !existingBearerTokenAvailable {
|
||||
errors.append("Bearer token is required when token auth is selected.")
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
func resolvedEmail(existingEmail: String?) -> String? {
|
||||
guard authMode == .emailPassword else { return nil }
|
||||
return trimmedEmail ?? existingEmail
|
||||
}
|
||||
|
||||
func resolvedPassword(existingPassword: String?) -> String? {
|
||||
guard authMode == .emailPassword else { return nil }
|
||||
if let trimmedPassword {
|
||||
return trimmedPassword
|
||||
}
|
||||
guard existingPasswordAvailable else {
|
||||
return nil
|
||||
}
|
||||
guard trimmedEmail?.caseInsensitiveCompare(baselineEmail ?? "") == .orderedSame else {
|
||||
return nil
|
||||
}
|
||||
return existingPassword
|
||||
}
|
||||
|
||||
func resolvedBearerToken(existingToken: String?) -> String? {
|
||||
guard authMode == .bearerToken else { return nil }
|
||||
return trimmedBearerToken ?? (existingBearerTokenAvailable ? existingToken : nil)
|
||||
}
|
||||
|
||||
func resolvedDreamWeaverBaseURL(normalizedBaseURL: String) -> String? {
|
||||
normalizedDreamWeaverBaseURL
|
||||
}
|
||||
|
||||
func resolvedDreamWeaverAPIKey(existingKey: String?) -> String? {
|
||||
trimmedDreamWeaverAPIKey ?? (existingDreamWeaverAPIKeyAvailable ? existingKey : nil)
|
||||
}
|
||||
|
||||
private static func trimmedValue(_ value: String?) -> String? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func normalizedHTTPSOrigin(from raw: String?) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
}
|
||||
guard var components = URLComponents(string: raw) else {
|
||||
return nil
|
||||
}
|
||||
guard let scheme = components.scheme?.lowercased(),
|
||||
let host = components.host?.lowercased() else {
|
||||
return nil
|
||||
}
|
||||
guard scheme == "https" else {
|
||||
return nil
|
||||
}
|
||||
guard components.query == nil, components.fragment == nil else {
|
||||
return nil
|
||||
}
|
||||
let path = components.path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedPath = path == "/api/" ? "/api" : path
|
||||
guard normalizedPath.isEmpty || normalizedPath == "/" || normalizedPath == "/api" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.scheme = scheme
|
||||
components.host = host
|
||||
components.path = normalizedPath == "/api" ? "/api" : ""
|
||||
components.query = nil
|
||||
components.fragment = nil
|
||||
return components.string
|
||||
}
|
||||
}
|
||||
|
||||
enum SessionPersistenceError: LocalizedError {
|
||||
case keychainWriteFailed(OSStatus)
|
||||
case keychainDeleteFailed(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .keychainWriteFailed(let status):
|
||||
return "Velocity could not save credentials securely on this iPad. Keychain status: \(status)."
|
||||
case .keychainDeleteFailed(let status):
|
||||
return "Velocity could not clear stored credentials from this iPad. Keychain status: \(status)."
|
||||
}
|
||||
}
|
||||
}
|
||||
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal file
204
iOS/velocity-ipad/velocity/Core/Config/SessionStore.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
static let shared = SessionStore()
|
||||
|
||||
private init() {
|
||||
reloadFromPersistedConfiguration()
|
||||
}
|
||||
|
||||
var currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
var draftBaseURL = ""
|
||||
var draftDreamWeaverBaseURL = ""
|
||||
var draftDreamWeaverAPIKey = ""
|
||||
var draftAuthMode: SessionAuthMode = .emailPassword
|
||||
var draftEmail = ""
|
||||
var draftPassword = ""
|
||||
var draftBearerToken = ""
|
||||
var isSaving = false
|
||||
var statusMessage: String?
|
||||
var errorMessage: String?
|
||||
|
||||
private var existingPasswordAvailable = false
|
||||
private var existingBearerTokenAvailable = false
|
||||
private var existingDreamWeaverAPIKeyAvailable = false
|
||||
private var baselineEmail: String?
|
||||
|
||||
var isConfigured: Bool {
|
||||
currentConfiguration.isConfigured
|
||||
}
|
||||
|
||||
var authModeDescription: String {
|
||||
currentConfiguration.authModeDescription
|
||||
}
|
||||
|
||||
var operatorIdentity: String {
|
||||
currentConfiguration.operatorIdentity
|
||||
}
|
||||
|
||||
var endpointDisplay: String {
|
||||
currentConfiguration.baseURL
|
||||
}
|
||||
|
||||
var dreamWeaverEndpointDisplay: String {
|
||||
currentConfiguration.dreamWeaverBaseURL
|
||||
}
|
||||
|
||||
var dreamWeaverEndpointModeDescription: String {
|
||||
currentConfiguration.dreamWeaverEndpointModeDescription
|
||||
}
|
||||
|
||||
var dreamWeaverAuthenticationDescription: String {
|
||||
currentConfiguration.dreamWeaverAuthenticationDescription
|
||||
}
|
||||
|
||||
var configurationSourceDescription: String {
|
||||
currentConfiguration.source.rawValue
|
||||
}
|
||||
|
||||
var isUsingStoredRuntimeConfiguration: Bool {
|
||||
currentConfiguration.source == .secureDeviceStorage
|
||||
}
|
||||
|
||||
var hasUnsavedChanges: Bool {
|
||||
draftBaseURL != currentConfiguration.baseURL ||
|
||||
draftDreamWeaverBaseURL != persistedDreamWeaverDraftValue ||
|
||||
!draftDreamWeaverAPIKey.isEmpty ||
|
||||
draftAuthMode != currentConfiguration.authMode ||
|
||||
draftEmail != (currentConfiguration.email ?? "") ||
|
||||
!draftPassword.isEmpty ||
|
||||
!draftBearerToken.isEmpty
|
||||
}
|
||||
|
||||
func reloadFromPersistedConfiguration() {
|
||||
currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
draftBaseURL = currentConfiguration.baseURL
|
||||
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
|
||||
draftDreamWeaverAPIKey = ""
|
||||
draftAuthMode = currentConfiguration.authMode
|
||||
draftEmail = currentConfiguration.email ?? ""
|
||||
draftPassword = ""
|
||||
draftBearerToken = ""
|
||||
existingDreamWeaverAPIKeyAvailable = currentConfiguration.hasDreamWeaverAPIKey
|
||||
existingPasswordAvailable = currentConfiguration.hasPassword
|
||||
existingBearerTokenAvailable = currentConfiguration.hasBearerToken
|
||||
baselineEmail = currentConfiguration.email
|
||||
}
|
||||
|
||||
func discardDraftChanges() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
reloadFromPersistedConfiguration()
|
||||
}
|
||||
|
||||
func saveDraft() async {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
|
||||
let draft = SessionConfigurationDraft(
|
||||
baseURL: draftBaseURL,
|
||||
dreamWeaverBaseURL: draftDreamWeaverBaseURL,
|
||||
dreamWeaverAPIKey: draftDreamWeaverAPIKey,
|
||||
authMode: draftAuthMode,
|
||||
email: draftEmail,
|
||||
password: draftPassword,
|
||||
bearerToken: draftBearerToken,
|
||||
existingDreamWeaverAPIKeyAvailable: existingDreamWeaverAPIKeyAvailable,
|
||||
existingPasswordAvailable: existingPasswordAvailable,
|
||||
existingBearerTokenAvailable: existingBearerTokenAvailable,
|
||||
baselineEmail: baselineEmail
|
||||
)
|
||||
|
||||
let errors = draft.validationErrors()
|
||||
guard errors.isEmpty else {
|
||||
errorMessage = errors.joined(separator: " ")
|
||||
return
|
||||
}
|
||||
|
||||
guard let normalizedBaseURL = draft.normalizedBaseURL else {
|
||||
errorMessage = "Backend endpoint must be a valid HTTPS origin."
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
|
||||
do {
|
||||
try AppConfig.saveRuntimeConfiguration(
|
||||
baseURL: normalizedBaseURL,
|
||||
dreamWeaverBaseURL: draft.resolvedDreamWeaverBaseURL(normalizedBaseURL: normalizedBaseURL),
|
||||
dreamWeaverAPIKey: draft.resolvedDreamWeaverAPIKey(existingKey: AppConfig.dreamWeaverAPIKey),
|
||||
email: draft.resolvedEmail(existingEmail: currentConfiguration.email),
|
||||
password: draft.resolvedPassword(existingPassword: AppConfig.apiPassword),
|
||||
bearerToken: draft.resolvedBearerToken(existingToken: AppConfig.apiBearerToken)
|
||||
)
|
||||
|
||||
await VelocityAPIClient.shared.resetSession()
|
||||
AppStore.shared.resetLiveData()
|
||||
reloadFromPersistedConfiguration()
|
||||
await AppStore.shared.refresh()
|
||||
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
|
||||
statusMessage = verificationStatusMessage(
|
||||
successPrefix: "Configuration saved.",
|
||||
backendRefreshError: AppStore.shared.errorMessage,
|
||||
dreamWeaverHealthy: dreamWeaverHealthy
|
||||
)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
func clearStoredConfiguration() async {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
isSaving = true
|
||||
|
||||
do {
|
||||
try AppConfig.clearStoredRuntimeConfiguration()
|
||||
await VelocityAPIClient.shared.resetSession()
|
||||
AppStore.shared.resetLiveData()
|
||||
reloadFromPersistedConfiguration()
|
||||
|
||||
if currentConfiguration.isConfigured {
|
||||
await AppStore.shared.refresh()
|
||||
let dreamWeaverHealthy = await ComfyClient.shared.checkHealth()
|
||||
statusMessage = verificationStatusMessage(
|
||||
successPrefix: "Stored override cleared. Velocity is now using the build configuration.",
|
||||
backendRefreshError: AppStore.shared.errorMessage,
|
||||
dreamWeaverHealthy: dreamWeaverHealthy
|
||||
)
|
||||
} else {
|
||||
statusMessage = "Stored session cleared. This iPad now requires runtime configuration before live data can load."
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isSaving = false
|
||||
}
|
||||
|
||||
private var persistedDreamWeaverDraftValue: String {
|
||||
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
|
||||
}
|
||||
|
||||
private func verificationStatusMessage(
|
||||
successPrefix: String,
|
||||
backendRefreshError: String?,
|
||||
dreamWeaverHealthy: Bool
|
||||
) -> String {
|
||||
switch (backendRefreshError, dreamWeaverHealthy) {
|
||||
case (nil, true):
|
||||
return "\(successPrefix) Core backend refresh and Dream Weaver gateway probe both succeeded."
|
||||
case (let backendRefreshError?, true):
|
||||
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe succeeded."
|
||||
case (nil, false):
|
||||
return "\(successPrefix) Core backend refresh succeeded, but the Dream Weaver gateway probe failed. Verify the dedicated generation endpoint and routing."
|
||||
case (let backendRefreshError?, false):
|
||||
return "\(successPrefix) Core backend refresh failed: \(backendRefreshError) Dream Weaver gateway probe also failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,41 @@ import UIKit
|
||||
// MARK: - ComfyClient
|
||||
|
||||
/// Handles all Dream Weaver API communication.
|
||||
/// The iPad app talks ONLY to the gateway (port 8080), never directly to ComfyUI.
|
||||
/// The iPad app talks only to the configured Dream Weaver gateway, never directly to ComfyUI.
|
||||
/// Flow: POST /dream-weaver → poll /status → GET /result
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
private var baseURL: String { AppConfig.baseURL }
|
||||
private init() {}
|
||||
private let urlSession: URLSession
|
||||
private var baseURL: String { AppConfig.dreamWeaverBaseURL }
|
||||
private var apiKey: String? { AppConfig.dreamWeaverAPIKey }
|
||||
|
||||
init(urlSession: URLSession = .shared) {
|
||||
self.urlSession = urlSession
|
||||
}
|
||||
|
||||
// MARK: - Health Check
|
||||
|
||||
/// Call on app launch to confirm gateway is reachable.
|
||||
/// Returns `true` if `{ "status": "ok" }`.
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
guard let url = URL(string: "\(baseURL)/health") else { return false }
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 30.0
|
||||
guard let (data, _) = try? await URLSession.shared.data(for: request),
|
||||
let json = try? JSONDecoder().decode(HealthResponse.self, from: data) else {
|
||||
do {
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
return false
|
||||
}
|
||||
|
||||
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return try await probeDreamWeaverRoute()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
// Server returns "healthy" (v2.0-FINAL gateway) — accept both variants
|
||||
return json.status == "ok" || json.status == "healthy"
|
||||
}
|
||||
|
||||
// MARK: - Main Generation Pipeline
|
||||
@@ -45,7 +59,7 @@ final class ComfyClient {
|
||||
let job = try await submitJob(imageData: imageData, roomType: roomType, keywords: keywords)
|
||||
|
||||
// 2. Poll status every 2s until ready (max 5 min per integration guide §3.3)
|
||||
let resultURL = try await pollUntilReady(jobId: job.jobId)
|
||||
let resultURL = try await pollUntilReady(job: job)
|
||||
|
||||
// 3. Download result PNG
|
||||
return try await downloadResult(from: resultURL)
|
||||
@@ -54,12 +68,8 @@ final class ComfyClient {
|
||||
// MARK: - Step 1: POST /dream-weaver
|
||||
|
||||
private func submitJob(imageData: Data, roomType: String, keywords: String) async throws -> GenerationJob {
|
||||
guard let url = URL(string: "\(baseURL)/dream-weaver") else {
|
||||
throw DreamWeaverError.generationFailed("Invalid gateway URL")
|
||||
}
|
||||
|
||||
let boundary = "Boundary-\(UUID().uuidString)"
|
||||
var request = URLRequest(url: url)
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/dream-weaver"))
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 180.0
|
||||
@@ -70,7 +80,7 @@ final class ComfyClient {
|
||||
boundary: boundary
|
||||
)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
@@ -83,18 +93,28 @@ final class ComfyClient {
|
||||
// MARK: - Step 2: GET /dream-weaver/status/{job_id}
|
||||
|
||||
/// Polls every 2s, max 150 attempts (5 minutes). Returns full result URL when ready.
|
||||
private func pollUntilReady(jobId: String, maxAttempts: Int = 150) async throws -> URL {
|
||||
let statusURL = URL(string: "\(baseURL)/dream-weaver/status/\(jobId)")!
|
||||
private func pollUntilReady(job: GenerationJob, maxAttempts: Int = 150) async throws -> URL {
|
||||
let statusURL = try job.resolvedPollURL(baseURL: baseURL)
|
||||
|
||||
for _ in 0..<maxAttempts {
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000) // 2s
|
||||
let (data, _) = try await URLSession.shared.data(from: statusURL)
|
||||
let (data, response) = try await urlSession.data(for: authorizedRequest(url: statusURL))
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw DreamWeaverError.generationFailed("Dream Weaver status check returned no HTTP response.")
|
||||
}
|
||||
guard 200..<300 ~= http.statusCode else {
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
throw DreamWeaverError.generationFailed(
|
||||
"Dream Weaver status check failed (HTTP \(http.statusCode))\(detail.isEmpty ? "" : ": \(detail)")"
|
||||
)
|
||||
}
|
||||
|
||||
let status = try JSONDecoder().decode(JobStatus.self, from: data)
|
||||
|
||||
if status.ready {
|
||||
return URL(string: "\(baseURL)/dream-weaver/result/\(jobId)")!
|
||||
return try status.resolvedResultURL(baseURL: baseURL, jobId: job.jobId)
|
||||
}
|
||||
if status.status == "error" {
|
||||
if status.status.lowercased() == "error" {
|
||||
throw DreamWeaverError.generationFailed(status.error ?? "Unknown server error")
|
||||
}
|
||||
}
|
||||
@@ -104,7 +124,14 @@ final class ComfyClient {
|
||||
// MARK: - Step 3: GET /dream-weaver/result/{job_id}
|
||||
|
||||
private func downloadResult(from url: URL) async throws -> UIImage {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
let (data, response) = try await urlSession.data(for: authorizedRequest(url: url, accept: "image/png"))
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
let detail = String(data: data, encoding: .utf8) ?? ""
|
||||
throw DreamWeaverError.generationFailed(
|
||||
"Dream Weaver result download failed (HTTP \(code))\(detail.isEmpty ? "" : ": \(detail)")"
|
||||
)
|
||||
}
|
||||
guard let image = UIImage(data: data) else {
|
||||
throw DreamWeaverError.invalidImageData
|
||||
}
|
||||
@@ -141,6 +168,63 @@ final class ComfyClient {
|
||||
body += "--\(boundary)--\(crlf)"
|
||||
return body
|
||||
}
|
||||
|
||||
private func probeDreamWeaverRoute() async throws -> Bool {
|
||||
let probeURL = try resolvedURL(
|
||||
candidate: nil,
|
||||
fallbackPath: "/dream-weaver/status/velocity-route-probe"
|
||||
)
|
||||
var request = authorizedRequest(url: probeURL)
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200..<300:
|
||||
return (try? JSONDecoder().decode(JobStatus.self, from: data)) != nil
|
||||
case 404:
|
||||
guard let errorResponse = try? JSONDecoder().decode(DreamWeaverErrorResponse.self, from: data) else {
|
||||
return false
|
||||
}
|
||||
return errorResponse.detail.localizedCaseInsensitiveContains("job not found")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedURL(candidate: String?, fallbackPath: String) throws -> URL {
|
||||
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let base = URL(string: gatewayBaseURL) else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
|
||||
}
|
||||
|
||||
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, !value.isEmpty {
|
||||
if let absolute = URL(string: value), absolute.scheme != nil {
|
||||
return absolute
|
||||
}
|
||||
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
|
||||
return relative
|
||||
}
|
||||
}
|
||||
|
||||
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
private func authorizedRequest(url: URL, accept: String = "application/json") -> URLRequest {
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue(accept, forHTTPHeaderField: "Accept")
|
||||
if let apiKey, !apiKey.isEmpty {
|
||||
request.setValue(apiKey, forHTTPHeaderField: "X-Dream-Weaver-API-Key")
|
||||
}
|
||||
return request
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Models (§5 of integration guide)
|
||||
@@ -148,14 +232,22 @@ final class ComfyClient {
|
||||
struct GenerationJob: Codable {
|
||||
let jobId: String
|
||||
let status: String
|
||||
let pollUrl: String
|
||||
let resultUrl: String
|
||||
let pollUrl: String?
|
||||
let resultUrl: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case jobId = "job_id"
|
||||
case status
|
||||
case pollUrl = "poll_url"
|
||||
case resultUrl = "result_url"
|
||||
}
|
||||
|
||||
func resolvedPollURL(baseURL: String) throws -> URL {
|
||||
try resolvedURL(candidate: pollUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/status/\(jobId)")
|
||||
}
|
||||
|
||||
func resolvedResultURL(baseURL: String) throws -> URL {
|
||||
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
|
||||
}
|
||||
}
|
||||
|
||||
struct JobStatus: Codable {
|
||||
@@ -168,6 +260,10 @@ struct JobStatus: Codable {
|
||||
case resultUrl = "result_url"
|
||||
case error
|
||||
}
|
||||
|
||||
func resolvedResultURL(baseURL: String, jobId: String) throws -> URL {
|
||||
try resolvedURL(candidate: resultUrl, baseURL: baseURL, fallbackPath: "/dream-weaver/result/\(jobId)")
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
@@ -175,6 +271,10 @@ struct HealthResponse: Codable {
|
||||
let comfyui: Bool?
|
||||
}
|
||||
|
||||
struct DreamWeaverErrorResponse: Codable {
|
||||
let detail: String
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum DreamWeaverError: LocalizedError {
|
||||
@@ -193,6 +293,28 @@ enum DreamWeaverError: LocalizedError {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedURL(candidate: String?, baseURL: String, fallbackPath: String) throws -> URL {
|
||||
let gatewayBaseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let base = URL(string: gatewayBaseURL) else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver gateway URL: \(gatewayBaseURL)")
|
||||
}
|
||||
|
||||
let value = candidate?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let value, !value.isEmpty {
|
||||
if let absolute = URL(string: value), absolute.scheme != nil {
|
||||
return absolute
|
||||
}
|
||||
if let relative = URL(string: value, relativeTo: base)?.absoluteURL {
|
||||
return relative
|
||||
}
|
||||
}
|
||||
|
||||
guard let fallback = URL(string: fallbackPath, relativeTo: base)?.absoluteURL else {
|
||||
throw DreamWeaverError.generationFailed("Invalid Dream Weaver route: \(fallbackPath)")
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// MARK: - UIImage Helpers
|
||||
|
||||
extension UIImage {
|
||||
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
2165
iOS/velocity-ipad/velocity/Core/Networking/VelocityAPIClient.swift
Normal file
File diff suppressed because it is too large
Load Diff
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
815
iOS/velocity-ipad/velocity/Core/State/AppStore.swift
Normal file
@@ -0,0 +1,815 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct DashboardMetrics {
|
||||
let leadCount: Int
|
||||
let whaleLeadCount: Int
|
||||
let propertyCount: Int
|
||||
let todayCalendarCount: Int
|
||||
let pendingTaskCount: Int
|
||||
let urgentTaskCount: Int
|
||||
let pendingInsights: Int
|
||||
let pendingTranscriptions: Int
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppStore {
|
||||
static let shared = AppStore()
|
||||
|
||||
private static let locallyCreatedCalendarEventsKey = "velocity.calendar.locally_created_events"
|
||||
private static let locallyMutatedTasksKey = "velocity.calendar.locally_mutated_tasks"
|
||||
private static let locallyHiddenTaskIDsKey = "velocity.calendar.locally_hidden_task_ids"
|
||||
|
||||
private init() {
|
||||
localTaskOverrides = Self.loadLocallyMutatedTasks()
|
||||
locallyResolvedTaskIDs = Self.loadLocallyHiddenTaskIDs()
|
||||
tasks = VelocityTaskDTO.sortedForOperatorReview(Array(localTaskOverrides.values))
|
||||
locallyCreatedCalendarEvents = Self.loadLocallyCreatedCalendarEvents()
|
||||
calendarEvents = locallyCreatedCalendarEvents
|
||||
}
|
||||
|
||||
private struct RefreshSnapshot {
|
||||
let contacts: [VelocityCanonicalContactListItemDTO]
|
||||
let leads: [VelocityLeadDTO]
|
||||
let tasks: [VelocityTaskDTO]
|
||||
let pendingTaskCount: Int
|
||||
let pendingTaskIDs: Set<String>
|
||||
let kanbanColumns: [VelocityKanbanColumnDTO]
|
||||
let opportunities: [VelocityOpportunityDTO]
|
||||
let properties: [VelocityPropertyDTO]
|
||||
let calendarEvents: [VelocityCalendarEventDTO]
|
||||
let alertSnapshot: VelocityAlertSnapshotDTO
|
||||
let leadEvents: [String: [VelocityCommunicationEventDTO]]
|
||||
}
|
||||
|
||||
private struct CalendarTaskRefresh {
|
||||
let tasks: [VelocityTaskDTO]
|
||||
let pendingTaskCount: Int
|
||||
let pendingTaskIDs: Set<String>
|
||||
}
|
||||
|
||||
private struct PersistedCalendarEvent: Codable {
|
||||
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
|
||||
|
||||
init(event: VelocityCalendarEventDTO) {
|
||||
calendarEventId = event.calendarEventId
|
||||
leadId = event.leadId
|
||||
title = event.title
|
||||
description = event.description
|
||||
startAt = event.startAt
|
||||
endAt = event.endAt
|
||||
allDay = event.allDay
|
||||
status = event.status
|
||||
reminderMinutes = event.reminderMinutes
|
||||
createdBy = event.createdBy
|
||||
location = event.location
|
||||
createdAt = event.createdAt
|
||||
}
|
||||
|
||||
var event: VelocityCalendarEventDTO {
|
||||
VelocityCalendarEventDTO(
|
||||
calendarEventId: calendarEventId,
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
createdBy: createdBy,
|
||||
location: location,
|
||||
createdAt: createdAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PersistedTask: Codable {
|
||||
let reminderId: String
|
||||
let reminderType: String
|
||||
let title: String
|
||||
let notes: String?
|
||||
let dueAt: String?
|
||||
let status: String
|
||||
let priority: String
|
||||
let personId: String?
|
||||
let clientName: String?
|
||||
let clientPhone: String?
|
||||
|
||||
init(task: VelocityTaskDTO) {
|
||||
reminderId = task.reminderId
|
||||
reminderType = task.reminderType
|
||||
title = task.title
|
||||
notes = task.notes
|
||||
dueAt = task.dueAt
|
||||
status = task.status
|
||||
priority = task.priority
|
||||
personId = task.personId
|
||||
clientName = task.clientName
|
||||
clientPhone = task.clientPhone
|
||||
}
|
||||
|
||||
var task: VelocityTaskDTO {
|
||||
VelocityTaskDTO(
|
||||
reminderId: reminderId,
|
||||
reminderType: reminderType,
|
||||
title: title,
|
||||
notes: notes,
|
||||
dueAt: dueAt,
|
||||
status: status,
|
||||
priority: priority,
|
||||
personId: personId,
|
||||
clientName: clientName,
|
||||
clientPhone: clientPhone
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var contacts: [VelocityCanonicalContactListItemDTO] = []
|
||||
var leads: [VelocityLeadDTO] = []
|
||||
var tasks: [VelocityTaskDTO] = []
|
||||
var kanbanColumns: [VelocityKanbanColumnDTO] = []
|
||||
var opportunities: [VelocityOpportunityDTO] = []
|
||||
var properties: [VelocityPropertyDTO] = []
|
||||
var calendarEvents: [VelocityCalendarEventDTO] = []
|
||||
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
var alertSnapshot: VelocityAlertSnapshotDTO?
|
||||
var pendingTaskMetricCount = 0
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastRefreshAt: Date?
|
||||
private var activeRefreshTask: Task<RefreshSnapshot, Error>?
|
||||
private var canonicalPendingTaskCount = 0
|
||||
private var canonicalPendingTaskIDs: Set<String> = []
|
||||
private var locallyResolvedTaskIDs: Set<String> = []
|
||||
private var localTaskOverrides: [String: VelocityTaskDTO] = [:]
|
||||
private var locallyCreatedCalendarEvents: [VelocityCalendarEventDTO] = []
|
||||
|
||||
var operatorIdentity: String {
|
||||
if let email = AppConfig.apiEmail, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if let token = AppConfig.apiBearerToken, !token.isEmpty {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var authDescription: String {
|
||||
if let _ = AppConfig.apiBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
|
||||
return "Email/password login"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
AppConfig.isLiveConfigured
|
||||
}
|
||||
|
||||
var metrics: DashboardMetrics {
|
||||
DashboardMetrics(
|
||||
leadCount: leads.count,
|
||||
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
|
||||
propertyCount: properties.count,
|
||||
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
|
||||
pendingTaskCount: pendingTaskMetricCount,
|
||||
urgentTaskCount: tasks.filter {
|
||||
$0.status.lowercased() == "pending" && ["urgent", "high"].contains($0.priority.lowercased())
|
||||
}.count,
|
||||
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
|
||||
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
var highlightedLeads: [VelocityLeadDTO] {
|
||||
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
|
||||
}
|
||||
|
||||
var highlightedContacts: [VelocityCanonicalContactListItemDTO] {
|
||||
Array(contacts.prefix(12))
|
||||
}
|
||||
|
||||
var timelineEvents: [TimelineEvent] {
|
||||
leadEvents
|
||||
.flatMap { leadId, events in
|
||||
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
|
||||
}
|
||||
.sorted(by: { $0.date > $1.date })
|
||||
}
|
||||
|
||||
var prioritizedTasks: [VelocityTaskDTO] {
|
||||
VelocityTaskDTO.sortedForOperatorReview(tasks)
|
||||
}
|
||||
|
||||
func resetLiveData() {
|
||||
contacts = []
|
||||
leads = []
|
||||
tasks = []
|
||||
kanbanColumns = []
|
||||
opportunities = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
leadEvents = [:]
|
||||
alertSnapshot = nil
|
||||
pendingTaskMetricCount = 0
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
isLoading = false
|
||||
errorMessage = nil
|
||||
lastRefreshAt = nil
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
locallyResolvedTaskIDs = []
|
||||
localTaskOverrides = [:]
|
||||
locallyCreatedCalendarEvents = []
|
||||
Self.saveLocallyHiddenTaskIDs([])
|
||||
Self.saveLocallyMutatedTasks([])
|
||||
Self.saveLocallyCreatedCalendarEvents([])
|
||||
}
|
||||
|
||||
func refresh(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
do {
|
||||
let task = activeRefreshTask ?? makeRefreshTask()
|
||||
activeRefreshTask = task
|
||||
|
||||
let snapshot = try await task.value
|
||||
activeRefreshTask = nil
|
||||
|
||||
contacts = snapshot.contacts
|
||||
leads = snapshot.leads
|
||||
tasks = mergedTasks(with: snapshot.tasks)
|
||||
canonicalPendingTaskCount = snapshot.pendingTaskCount
|
||||
canonicalPendingTaskIDs = snapshot.pendingTaskIDs
|
||||
kanbanColumns = snapshot.kanbanColumns
|
||||
opportunities = snapshot.opportunities
|
||||
properties = snapshot.properties
|
||||
calendarEvents = mergedCalendarEvents(with: snapshot.calendarEvents)
|
||||
refreshPendingTaskMetricCount()
|
||||
alertSnapshot = snapshot.alertSnapshot
|
||||
leadEvents = snapshot.leadEvents
|
||||
lastRefreshAt = Date()
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
activeRefreshTask = nil
|
||||
if !silent || lastRefreshAt == nil {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
if !silent {
|
||||
contacts = []
|
||||
leads = []
|
||||
tasks = []
|
||||
kanbanColumns = []
|
||||
opportunities = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
alertSnapshot = nil
|
||||
pendingTaskMetricCount = 0
|
||||
canonicalPendingTaskCount = 0
|
||||
canonicalPendingTaskIDs = []
|
||||
leadEvents = [:]
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func leadName(for leadId: String) -> String {
|
||||
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
|
||||
}
|
||||
|
||||
func updateTaskStatus(
|
||||
reminderId: String,
|
||||
status: String,
|
||||
dueAt: String? = nil,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityTaskDTO {
|
||||
let serverTask: VelocityTaskDTO
|
||||
do {
|
||||
serverTask = try await VelocityAPIClient.shared.updateTask(
|
||||
reminderId: reminderId,
|
||||
status: status,
|
||||
dueAt: dueAt,
|
||||
notes: notes
|
||||
)
|
||||
} catch let error as VelocityAPIError where error.statusCode == 404 {
|
||||
serverTask = locallyResolveMissingTask(
|
||||
reminderId: reminderId,
|
||||
status: status,
|
||||
dueAt: dueAt
|
||||
)
|
||||
}
|
||||
let updatedTask = locallyMutatedTask(from: serverTask, status: status, dueAt: dueAt)
|
||||
if updatedTask.status.lowercased() == "cancelled" {
|
||||
localTaskOverrides.removeValue(forKey: reminderId)
|
||||
locallyResolvedTaskIDs.insert(reminderId)
|
||||
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
|
||||
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
|
||||
tasks.removeAll { $0.reminderId == reminderId }
|
||||
} else {
|
||||
locallyResolvedTaskIDs.remove(reminderId)
|
||||
upsertLocalTaskOverride(updatedTask)
|
||||
if let index = tasks.firstIndex(where: { $0.reminderId == reminderId }) {
|
||||
tasks[index] = updatedTask
|
||||
} else {
|
||||
tasks.append(updatedTask)
|
||||
}
|
||||
tasks = VelocityTaskDTO.sortedForOperatorReview(tasks)
|
||||
}
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return updatedTask
|
||||
}
|
||||
|
||||
func createCalendarEvent(
|
||||
leadId: String?,
|
||||
title: String,
|
||||
description: String?,
|
||||
startAt: String,
|
||||
endAt: String,
|
||||
allDay: Bool,
|
||||
status: String,
|
||||
reminderMinutes: [Int],
|
||||
location: String?,
|
||||
metadata: [String: String] = [:]
|
||||
) async throws -> VelocityCalendarEventCreateResultDTO {
|
||||
let createdEvent: VelocityCalendarEventCreateResultDTO
|
||||
var shouldPersistLocalFallback = false
|
||||
do {
|
||||
createdEvent = try await VelocityAPIClient.shared.createCalendarEvent(
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
location: location,
|
||||
metadata: metadata
|
||||
)
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
createdEvent = VelocityCalendarEventCreateResultDTO(
|
||||
calendarEventId: "local-\(UUID().uuidString)",
|
||||
createdAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
shouldPersistLocalFallback = true
|
||||
}
|
||||
let optimisticEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: createdEvent.calendarEventId,
|
||||
leadId: leadId,
|
||||
title: title,
|
||||
description: description,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
allDay: allDay,
|
||||
status: status,
|
||||
reminderMinutes: reminderMinutes,
|
||||
createdBy: "user",
|
||||
location: location,
|
||||
createdAt: createdEvent.createdAt
|
||||
)
|
||||
upsertLocalCalendarEvent(optimisticEvent, persist: shouldPersistLocalFallback)
|
||||
calendarEvents = mergedCalendarEvents(with: calendarEvents)
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return createdEvent
|
||||
}
|
||||
|
||||
func updateCalendarEvent(
|
||||
_ event: VelocityCalendarEventDTO,
|
||||
status: String? = nil,
|
||||
startAt: String? = nil,
|
||||
endAt: String? = nil
|
||||
) async throws -> VelocityCalendarEventDTO {
|
||||
let shouldPersistFallback: Bool
|
||||
do {
|
||||
try await VelocityAPIClient.shared.updateCalendarEvent(
|
||||
calendarEventId: event.calendarEventId,
|
||||
startAt: startAt,
|
||||
endAt: endAt,
|
||||
status: status
|
||||
)
|
||||
shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
shouldPersistFallback = true
|
||||
}
|
||||
|
||||
let updatedEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: event.calendarEventId,
|
||||
leadId: event.leadId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
startAt: startAt ?? event.startAt,
|
||||
endAt: endAt ?? event.endAt,
|
||||
allDay: event.allDay,
|
||||
status: status ?? event.status,
|
||||
reminderMinutes: event.reminderMinutes,
|
||||
createdBy: event.createdBy,
|
||||
location: event.location,
|
||||
createdAt: event.createdAt
|
||||
)
|
||||
|
||||
if updatedEvent.status == "cancelled" {
|
||||
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents.filter { $0.calendarEventId.hasPrefix("local-") })
|
||||
} else {
|
||||
upsertLocalCalendarEvent(updatedEvent, persist: shouldPersistFallback)
|
||||
calendarEvents = mergedCalendarEvents(with: calendarEvents)
|
||||
}
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
return updatedEvent
|
||||
}
|
||||
|
||||
func cancelCalendarEvent(_ event: VelocityCalendarEventDTO) async throws {
|
||||
var shouldPersistFallback = event.calendarEventId.hasPrefix("local-")
|
||||
do {
|
||||
try await VelocityAPIClient.shared.cancelCalendarEvent(calendarEventId: event.calendarEventId)
|
||||
} catch let error as VelocityAPIError where error.isRecoverableCalendarCreateFailure {
|
||||
shouldPersistFallback = true
|
||||
}
|
||||
let cancelledEvent = VelocityCalendarEventDTO(
|
||||
calendarEventId: event.calendarEventId,
|
||||
leadId: event.leadId,
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
allDay: event.allDay,
|
||||
status: "cancelled",
|
||||
reminderMinutes: event.reminderMinutes,
|
||||
createdBy: event.createdBy,
|
||||
location: event.location,
|
||||
createdAt: event.createdAt
|
||||
)
|
||||
upsertLocalCalendarEvent(cancelledEvent, persist: shouldPersistFallback)
|
||||
calendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
refreshPendingTaskMetricCount()
|
||||
errorMessage = nil
|
||||
await refresh(silent: true)
|
||||
}
|
||||
|
||||
func updateLeadStage(
|
||||
leadId: String,
|
||||
status: String,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityLeadStageUpdateDTO {
|
||||
let updatedLead = try await VelocityAPIClient.shared.updateLeadStage(
|
||||
leadId: leadId,
|
||||
status: status,
|
||||
notes: notes
|
||||
)
|
||||
await refresh(silent: true)
|
||||
return updatedLead
|
||||
}
|
||||
|
||||
func updateOpportunity(
|
||||
opportunityId: String,
|
||||
stage: String? = nil,
|
||||
probability: Int? = nil,
|
||||
nextAction: String? = nil,
|
||||
notes: String? = nil
|
||||
) async throws -> VelocityOpportunityDTO {
|
||||
let updatedOpportunity = try await VelocityAPIClient.shared.updateOpportunity(
|
||||
opportunityId: opportunityId,
|
||||
stage: stage,
|
||||
probability: probability,
|
||||
nextAction: nextAction,
|
||||
notes: notes
|
||||
)
|
||||
await refresh(silent: true)
|
||||
return updatedOpportunity
|
||||
}
|
||||
|
||||
private func locallyResolveMissingTask(
|
||||
reminderId: String,
|
||||
status: String,
|
||||
dueAt: String?
|
||||
) -> VelocityTaskDTO {
|
||||
if status.lowercased() == "cancelled" {
|
||||
locallyResolvedTaskIDs.insert(reminderId)
|
||||
}
|
||||
let existing = tasks.first { $0.reminderId == reminderId }
|
||||
return VelocityTaskDTO(
|
||||
reminderId: reminderId,
|
||||
reminderType: existing?.reminderType ?? "follow_up",
|
||||
title: existing?.title ?? "Calendar task",
|
||||
notes: existing?.notes,
|
||||
dueAt: dueAt ?? existing?.dueAt,
|
||||
status: status,
|
||||
priority: existing?.priority ?? "normal",
|
||||
personId: existing?.personId,
|
||||
clientName: existing?.clientName,
|
||||
clientPhone: existing?.clientPhone
|
||||
)
|
||||
}
|
||||
|
||||
private func locallyMutatedTask(
|
||||
from task: VelocityTaskDTO,
|
||||
status: String,
|
||||
dueAt: String?
|
||||
) -> VelocityTaskDTO {
|
||||
VelocityTaskDTO(
|
||||
reminderId: task.reminderId,
|
||||
reminderType: task.reminderType,
|
||||
title: task.title,
|
||||
notes: task.notes,
|
||||
dueAt: dueAt ?? task.dueAt,
|
||||
status: status,
|
||||
priority: task.priority,
|
||||
personId: task.personId,
|
||||
clientName: task.clientName,
|
||||
clientPhone: task.clientPhone
|
||||
)
|
||||
}
|
||||
|
||||
private func upsertLocalTaskOverride(_ task: VelocityTaskDTO) {
|
||||
localTaskOverrides[task.reminderId] = task
|
||||
Self.saveLocallyMutatedTasks(Array(localTaskOverrides.values))
|
||||
Self.saveLocallyHiddenTaskIDs(Array(locallyResolvedTaskIDs))
|
||||
}
|
||||
|
||||
private func mergedTasks(with fetchedTasks: [VelocityTaskDTO]) -> [VelocityTaskDTO] {
|
||||
var taskByID = Dictionary(uniqueKeysWithValues: fetchedTasks.map { ($0.reminderId, $0) })
|
||||
for task in localTaskOverrides.values {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
let visibleTasks = taskByID.values.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
|
||||
return VelocityTaskDTO.sortedForOperatorReview(Array(visibleTasks))
|
||||
}
|
||||
|
||||
private func refreshPendingTaskMetricCount() {
|
||||
var localDelta = 0
|
||||
for task in localTaskOverrides.values {
|
||||
let isCanonicalPending = canonicalPendingTaskIDs.contains(task.reminderId)
|
||||
let isLocallyPending = task.status.lowercased() == "pending"
|
||||
if isCanonicalPending && !isLocallyPending {
|
||||
localDelta -= 1
|
||||
} else if !isCanonicalPending && isLocallyPending {
|
||||
localDelta += 1
|
||||
}
|
||||
}
|
||||
|
||||
let locallyHiddenPendingCount = locallyResolvedTaskIDs
|
||||
.filter { canonicalPendingTaskIDs.contains($0) }
|
||||
.count
|
||||
let normalCalendarTaskCount = calendarEvents.filter { event in
|
||||
event.status.lowercased() == "tentative"
|
||||
}.count
|
||||
|
||||
pendingTaskMetricCount = max(
|
||||
0,
|
||||
canonicalPendingTaskCount + localDelta - locallyHiddenPendingCount + normalCalendarTaskCount
|
||||
)
|
||||
}
|
||||
|
||||
private static func loadLocallyMutatedTasks() -> [String: VelocityTaskDTO] {
|
||||
guard let data = UserDefaults.standard.data(forKey: locallyMutatedTasksKey),
|
||||
let persistedTasks = try? JSONDecoder().decode([PersistedTask].self, from: data)
|
||||
else {
|
||||
return [:]
|
||||
}
|
||||
return Dictionary(uniqueKeysWithValues: persistedTasks.map { ($0.reminderId, $0.task) })
|
||||
}
|
||||
|
||||
private static func saveLocallyMutatedTasks(_ tasks: [VelocityTaskDTO]) {
|
||||
let persistedTasks = tasks.map(PersistedTask.init(task:))
|
||||
if let data = try? JSONEncoder().encode(persistedTasks) {
|
||||
UserDefaults.standard.set(data, forKey: locallyMutatedTasksKey)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadLocallyHiddenTaskIDs() -> Set<String> {
|
||||
let ids = UserDefaults.standard.stringArray(forKey: locallyHiddenTaskIDsKey) ?? []
|
||||
return Set(ids)
|
||||
}
|
||||
|
||||
private static func saveLocallyHiddenTaskIDs(_ taskIDs: [String]) {
|
||||
UserDefaults.standard.set(taskIDs, forKey: locallyHiddenTaskIDsKey)
|
||||
}
|
||||
|
||||
private func upsertLocalCalendarEvent(_ event: VelocityCalendarEventDTO, persist: Bool) {
|
||||
locallyCreatedCalendarEvents.removeAll { $0.calendarEventId == event.calendarEventId }
|
||||
locallyCreatedCalendarEvents.append(event)
|
||||
if persist {
|
||||
Self.saveLocallyCreatedCalendarEvents(locallyCreatedCalendarEvents)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergedCalendarEvents(with fetchedEvents: [VelocityCalendarEventDTO]) -> [VelocityCalendarEventDTO] {
|
||||
var eventByID = Dictionary(uniqueKeysWithValues: fetchedEvents.map { ($0.calendarEventId, $0) })
|
||||
for event in locallyCreatedCalendarEvents {
|
||||
eventByID[event.calendarEventId] = event
|
||||
}
|
||||
return eventByID.values.filter { $0.status != "cancelled" }.sorted {
|
||||
($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadLocallyCreatedCalendarEvents() -> [VelocityCalendarEventDTO] {
|
||||
guard let data = UserDefaults.standard.data(forKey: locallyCreatedCalendarEventsKey),
|
||||
let persistedEvents = try? JSONDecoder().decode([PersistedCalendarEvent].self, from: data)
|
||||
else {
|
||||
return []
|
||||
}
|
||||
return persistedEvents.map(\.event)
|
||||
}
|
||||
|
||||
private static func saveLocallyCreatedCalendarEvents(_ events: [VelocityCalendarEventDTO]) {
|
||||
let persistedEvents = events.map(PersistedCalendarEvent.init(event:))
|
||||
if let data = try? JSONEncoder().encode(persistedEvents) {
|
||||
UserDefaults.standard.set(data, forKey: locallyCreatedCalendarEventsKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeRefreshTask() -> Task<RefreshSnapshot, Error> {
|
||||
let cachedContacts = contacts
|
||||
return Task {
|
||||
async let tasksTask = fetchCalendarTasks()
|
||||
async let kanbanTask: [VelocityKanbanColumnDTO]? = try? await VelocityAPIClient.shared.fetchKanbanBoard()
|
||||
async let opportunitiesTask: [VelocityOpportunityDTO]? = try? await VelocityAPIClient.shared.fetchOpportunities()
|
||||
async let propertiesTask: [VelocityPropertyDTO]? = try? await VelocityAPIClient.shared.fetchProperties(
|
||||
limit: AppStoreRefreshPolicy.inventoryPropertyLimit
|
||||
)
|
||||
async let calendarTask: [VelocityCalendarEventDTO]? = try? await VelocityAPIClient.shared.fetchCalendarEvents()
|
||||
async let alertsTask: VelocityAlertSnapshotDTO? = try? await VelocityAPIClient.shared.fetchAlerts()
|
||||
|
||||
let fetchedContacts: [VelocityCanonicalContactListItemDTO]
|
||||
do {
|
||||
fetchedContacts = try await VelocityAPIClient.shared.fetchContacts()
|
||||
} catch let error as VelocityAPIError where error.statusCode == 404 {
|
||||
fetchedContacts = cachedContacts
|
||||
}
|
||||
let fetchedLeads = VelocityLeadDTO.activeLeadSummaries(from: fetchedContacts)
|
||||
let taskRefresh = await tasksTask
|
||||
let fetchedTasks = taskRefresh.tasks.filter { !locallyResolvedTaskIDs.contains($0.reminderId) }
|
||||
let fetchedKanban = await kanbanTask ?? []
|
||||
let fetchedOpportunities = await opportunitiesTask ?? []
|
||||
let fetchedProperties = await propertiesTask ?? []
|
||||
let fetchedCalendar = await calendarTask ?? []
|
||||
let fetchedAlerts = await alertsTask ?? VelocityAlertSnapshotDTO.empty
|
||||
let leadEvents = await fetchLeadEvents(for: fetchedLeads)
|
||||
|
||||
return RefreshSnapshot(
|
||||
contacts: fetchedContacts,
|
||||
leads: fetchedLeads,
|
||||
tasks: fetchedTasks,
|
||||
pendingTaskCount: taskRefresh.pendingTaskCount,
|
||||
pendingTaskIDs: taskRefresh.pendingTaskIDs,
|
||||
kanbanColumns: fetchedKanban,
|
||||
opportunities: fetchedOpportunities,
|
||||
properties: fetchedProperties,
|
||||
calendarEvents: fetchedCalendar,
|
||||
alertSnapshot: fetchedAlerts,
|
||||
leadEvents: leadEvents
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCalendarTasks() async -> CalendarTaskRefresh {
|
||||
async let allTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "all")
|
||||
async let pendingTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "pending")
|
||||
async let confirmedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "confirmed")
|
||||
async let doneTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "done")
|
||||
async let snoozedTasks: [VelocityTaskDTO]? = try? await VelocityAPIClient.shared.fetchTasks(status: "snoozed")
|
||||
|
||||
let fetchedAllTasks = await allTasks ?? []
|
||||
let pendingTaskResponse = await pendingTasks
|
||||
let fetchedPendingTasks = pendingTaskResponse ?? []
|
||||
let fetchedConfirmedTasks = await confirmedTasks ?? []
|
||||
let fetchedDoneTasks = await doneTasks ?? []
|
||||
let fetchedSnoozedTasks = await snoozedTasks ?? []
|
||||
|
||||
var taskByID: [String: VelocityTaskDTO] = [:]
|
||||
for task in fetchedAllTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedPendingTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedConfirmedTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedDoneTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
for task in fetchedSnoozedTasks {
|
||||
taskByID[task.reminderId] = task
|
||||
}
|
||||
let pendingTaskCount = pendingTaskResponse?.count ?? fetchedAllTasks.filter { $0.status.lowercased() == "pending" }.count
|
||||
let pendingTaskIDs = Set(fetchedPendingTasks.map(\.reminderId))
|
||||
return CalendarTaskRefresh(
|
||||
tasks: VelocityTaskDTO.sortedForOperatorReview(Array(taskByID.values)),
|
||||
pendingTaskCount: pendingTaskCount,
|
||||
pendingTaskIDs: pendingTaskIDs
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchLeadEvents(
|
||||
for leads: [VelocityLeadDTO]
|
||||
) async -> [String: [VelocityCommunicationEventDTO]] {
|
||||
let prioritizedLeadIDs = AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads)
|
||||
|
||||
return await withTaskGroup(
|
||||
of: (String, [VelocityCommunicationEventDTO]).self,
|
||||
returning: [String: [VelocityCommunicationEventDTO]].self
|
||||
) { group in
|
||||
for leadID in prioritizedLeadIDs {
|
||||
group.addTask {
|
||||
do {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(
|
||||
for: leadID,
|
||||
limit: AppStoreRefreshPolicy.leadEventLimitPerLead
|
||||
)
|
||||
return (leadID, events)
|
||||
} catch {
|
||||
return (leadID, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
for await (leadID, events) in group {
|
||||
eventMap[leadID] = events
|
||||
}
|
||||
return eventMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineEvent: Identifiable {
|
||||
let leadId: String
|
||||
let event: VelocityCommunicationEventDTO
|
||||
let leadName: String
|
||||
|
||||
var id: String { event.id }
|
||||
var date: Date { event.timestampDate ?? .distantPast }
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startsToday: Bool {
|
||||
guard let date = startDate else { return false }
|
||||
return Calendar.current.isDateInToday(date)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let delta = Int(Date().timeIntervalSince(self))
|
||||
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"
|
||||
}
|
||||
|
||||
var taskDueLabel: String {
|
||||
if Calendar.current.isDateInToday(self) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "'Today' · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
if Calendar.current.isDateInTomorrow(self) {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "'Tomorrow' · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE, MMM d · h:mm a"
|
||||
return formatter.string(from: self)
|
||||
}
|
||||
}
|
||||
|
||||
private extension VelocityAPIError {
|
||||
var isRecoverableCalendarCreateFailure: Bool {
|
||||
if let statusCode {
|
||||
return statusCode == 404 || (500...599).contains(statusCode)
|
||||
}
|
||||
if case .invalidResponse = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
enum AppStoreRefreshPolicy {
|
||||
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
|
||||
/// are based on the same production property slice by default.
|
||||
static let inventoryPropertyLimit = 100
|
||||
|
||||
/// Keep the canonical CRM follow-up inbox bounded while still representing
|
||||
/// the operator's active task load on iPad surfaces.
|
||||
static let canonicalTaskLimit = 50
|
||||
|
||||
/// iPad surfaces only render a small operator-focused timeline, so keep the
|
||||
/// lead-event hydration set intentionally narrower than WebOS.
|
||||
static let leadTimelineHydrationLimit = 6
|
||||
|
||||
/// Fetch enough recent communication context for the visible iPad rails
|
||||
/// without inflating each refresh unnecessarily.
|
||||
static let leadEventLimitPerLead = 4
|
||||
|
||||
static func prioritizedLeadIDs(
|
||||
from leads: [VelocityLeadDTO],
|
||||
limit: Int = leadTimelineHydrationLimit
|
||||
) -> [String] {
|
||||
Array(
|
||||
leads
|
||||
.sorted(by: { $0.score > $1.score })
|
||||
.prefix(limit)
|
||||
.map(\.id)
|
||||
)
|
||||
}
|
||||
}
|
||||
1260
iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Normal file
1260
iOS/velocity-ipad/velocity/Features/Calendar/CalendarView.swift
Normal file
File diff suppressed because it is too large
Load Diff
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
490
iOS/velocity-ipad/velocity/Features/Clients/ClientsView.swift
Normal file
@@ -0,0 +1,490 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct ClientsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var searchText = ""
|
||||
@State private var selectedClient360: VelocityClient360DTO?
|
||||
@State private var selectedPersonID: String?
|
||||
@State private var isClient360Loading = false
|
||||
@State private var client360Error: String?
|
||||
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
summaryPanel
|
||||
searchPanel
|
||||
contactsPanel
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
.sheet(isPresented: client360PresentationBinding) {
|
||||
client360Sheet
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Clients")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Canonical CRM contact workspace backed by `/api/crm/client-data` and client detail APIs.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryPanel: some View {
|
||||
HStack(spacing: 12) {
|
||||
metricCard("Contacts", value: "\(store.contacts.count)", color: VelocityTheme.accent)
|
||||
metricCard("Active Leads", value: "\(store.leads.count)", color: VelocityTheme.success)
|
||||
metricCard("Open Tasks", value: "\(store.metrics.pendingTaskCount)", color: VelocityTheme.warning)
|
||||
metricCard("High Intent", value: "\(highIntentCount)", color: VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
|
||||
private var searchPanel: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
TextField("Search by name, phone, interest, budget, or status", text: $searchText)
|
||||
.textInputAutocapitalization(.words)
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
if !searchText.isEmpty {
|
||||
Button("Clear") {
|
||||
searchText = ""
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
|
||||
private var contactsPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Canonical Contacts")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(filteredContacts.count) shown")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if store.isLoading && store.lastRefreshAt == nil {
|
||||
loadingCard
|
||||
} else if store.contacts.isEmpty {
|
||||
emptyCard("No canonical contacts were returned for this operator scope yet.")
|
||||
} else if filteredContacts.isEmpty {
|
||||
emptyCard("No canonical contacts match this search.")
|
||||
} else {
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(filteredContacts) { contact in
|
||||
contactCard(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func contactCard(_ contact: VelocityCanonicalContactListItemDTO) -> some View {
|
||||
Button {
|
||||
openClient360(for: contact.personId)
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(VelocityTheme.accent.opacity(0.14))
|
||||
.frame(width: 42, height: 42)
|
||||
Text(initials(for: contact.fullName))
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
HStack {
|
||||
Text(contact.fullName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(contact.displayIntentScore)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text("\(contact.buyerTypeLabel) · \(contact.leadStatusLabel)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(contact.budgetSummary) · \(contact.interestSummary)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("\(contact.contactLine) · \(contact.pendingTasks) pending tasks · \(contact.interactionCount) interactions")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var loadingCard: some View {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text("Loading canonical contacts...")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func emptyCard(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func metricCard(_ label: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 21, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(color)
|
||||
.frame(width: 42, height: 4)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
|
||||
private var filteredContacts: [VelocityCanonicalContactListItemDTO] {
|
||||
let normalized = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !normalized.isEmpty else {
|
||||
return store.contacts
|
||||
}
|
||||
return store.contacts.filter { contact in
|
||||
[
|
||||
contact.fullName,
|
||||
contact.primaryPhone ?? "",
|
||||
contact.buyerType ?? "",
|
||||
contact.leadStatus ?? "",
|
||||
contact.budgetBand ?? "",
|
||||
contact.primaryInterest ?? "",
|
||||
contact.urgency ?? "",
|
||||
]
|
||||
.joined(separator: " ")
|
||||
.lowercased()
|
||||
.contains(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private var highIntentCount: Int {
|
||||
store.contacts.filter { $0.displayIntentScore >= 80 }.count
|
||||
}
|
||||
|
||||
private func initials(for name: String) -> String {
|
||||
let initials = name
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
.compactMap(\.first)
|
||||
return initials.isEmpty ? "C" : String(initials)
|
||||
}
|
||||
|
||||
private var client360PresentationBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { selectedPersonID != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
selectedPersonID = nil
|
||||
selectedClient360 = nil
|
||||
client360Error = nil
|
||||
isClient360Loading = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var client360Sheet: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
if isClient360Loading {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.top, 40)
|
||||
} else if let client360Error {
|
||||
errorBanner(client360Error)
|
||||
} else if let snapshot = selectedClient360 {
|
||||
client360Snapshot(snapshot)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.navigationTitle("Client 360")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
selectedPersonID = nil
|
||||
selectedClient360 = nil
|
||||
client360Error = nil
|
||||
isClient360Loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func client360Snapshot(_ snapshot: VelocityClient360DTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(snapshot.identity.fullName)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(snapshot.identity.primaryPhone ?? "No phone") · \(snapshot.identity.buyerType?.replacingOccurrences(of: "_", with: " ").capitalized ?? "CRM contact")")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
if let email = snapshot.identity.primaryEmail {
|
||||
Text(email)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
if !snapshot.identity.personaLabels.isEmpty {
|
||||
Text(snapshot.identity.personaLabels.joined(separator: " · "))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let lead = snapshot.currentLead {
|
||||
sectionLine("Lead", value: "\(lead.status.replacingOccurrences(of: "_", with: " ").capitalized) · \(lead.budgetBand ?? "Budget pending")")
|
||||
sectionLine("Urgency", value: lead.urgency?.replacingOccurrences(of: "_", with: " ").capitalized ?? "Normal")
|
||||
if !lead.motivations.isEmpty {
|
||||
Text("Motivations: \(lead.motivations.joined(separator: ", "))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
if !lead.objections.isEmpty {
|
||||
Text("Objections: \(lead.objections.joined(separator: ", "))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
} else {
|
||||
Text("No active canonical lead context was returned for this client.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
sectionLine("Opportunities", value: "\(snapshot.activeOpportunities.count)")
|
||||
sectionLine("Tasks", value: "\(snapshot.tasks.count)")
|
||||
sectionLine("Interactions", value: "\(snapshot.recentInteractions.count)")
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
|
||||
if !snapshot.activeOpportunities.isEmpty {
|
||||
client360ListCard(title: "Active Opportunities") {
|
||||
ForEach(snapshot.activeOpportunities) { opportunity in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(opportunity.formattedValue) · \(opportunity.probabilityLabel)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(opportunity.nextAction ?? "Next action pending")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !snapshot.propertyInterests.isEmpty {
|
||||
client360ListCard(title: "Property Interests") {
|
||||
ForEach(snapshot.propertyInterests) { interest in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(interest.projectName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text([interest.configuration, interest.unitPreference].compactMap { nonEmpty($0) }.joined(separator: " · "))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client360ListCard(title: "Recent Interactions") {
|
||||
if snapshot.recentInteractions.isEmpty {
|
||||
Text("No recent canonical interactions were returned for this client.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else {
|
||||
ForEach(snapshot.recentInteractions) { interaction in
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("\(interaction.channel.capitalized) · \(interaction.interactionType.replacingOccurrences(of: "_", with: " ").capitalized)")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(interaction.summary ?? "No summary captured")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !snapshot.tasks.isEmpty || !snapshot.recommendedNextActions.isEmpty || !snapshot.riskFlags.isEmpty {
|
||||
client360ListCard(title: "Operator Actions") {
|
||||
ForEach(snapshot.tasks) { task in
|
||||
sectionLine(task.title, value: "\(task.priorityLabel) · \(task.dueLabel)")
|
||||
}
|
||||
ForEach(snapshot.recommendedNextActions, id: \.self) { action in
|
||||
Text(action)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
ForEach(snapshot.riskFlags, id: \.self) { flag in
|
||||
Text(flag.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func client360ListCard<Content: View>(
|
||||
title: String,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
content()
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func sectionLine(_ title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func openClient360(for personId: String) {
|
||||
selectedPersonID = personId
|
||||
selectedClient360 = nil
|
||||
client360Error = nil
|
||||
isClient360Loading = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
|
||||
await MainActor.run {
|
||||
selectedClient360 = snapshot
|
||||
isClient360Loading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
selectedClient360 = nil
|
||||
client360Error = error.localizedDescription
|
||||
isClient360Loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ClientsView()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
private struct CommunicationThread: Identifiable {
|
||||
@@ -65,7 +66,7 @@ struct CommunicationsView: View {
|
||||
Text("Communications")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Phone, WhatsApp, transcript, and memory edge across active leads.")
|
||||
Text("Phone, WhatsApp, transcript, and memory edge across canonical CRM contacts with active lead context.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -116,7 +117,7 @@ struct CommunicationsView: View {
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if threads.isEmpty {
|
||||
detailRow(title: "Live data", value: "No communication events have been captured for current leads yet.")
|
||||
detailRow(title: "Live data", value: "No communication events have been captured for the current canonical CRM lead set yet.")
|
||||
}
|
||||
|
||||
ForEach(threads) { thread in
|
||||
@@ -279,7 +280,7 @@ struct CommunicationsView: View {
|
||||
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.")
|
||||
Text("Velocity is fetching canonical CRM contact summaries, communication events, and alert state from the backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -318,7 +319,7 @@ struct CommunicationsView: View {
|
||||
var fetchedThreads: [CommunicationThread] = []
|
||||
|
||||
for lead in topLeads {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)
|
||||
let events = (try? await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)) ?? []
|
||||
let latest = events.first
|
||||
fetchedThreads.append(
|
||||
CommunicationThread(
|
||||
@@ -339,7 +340,7 @@ struct CommunicationsView: View {
|
||||
await MainActor.run {
|
||||
threads = fetchedThreads
|
||||
alerts = fetchedAlerts
|
||||
if selectedThread == nil {
|
||||
if selectedThread == nil || !fetchedThreads.contains(where: { $0.id == selectedThread }) {
|
||||
selectedThread = fetchedThreads.first?.id
|
||||
}
|
||||
errorMessage = nil
|
||||
@@ -383,6 +384,9 @@ struct CommunicationsView: View {
|
||||
|
||||
private func statusLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
|
||||
if event == nil {
|
||||
if lead.pendingTaskCount > 0 {
|
||||
return "Task pending"
|
||||
}
|
||||
return "No events yet"
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
@@ -395,6 +399,9 @@ struct CommunicationsView: View {
|
||||
if event?.recordingRef != nil {
|
||||
return "Review transcript"
|
||||
}
|
||||
if lead.pendingTaskCount > 0 {
|
||||
return lead.pendingTaskCount == 1 ? "Review pending task" : "Review \(lead.pendingTaskCount) tasks"
|
||||
}
|
||||
if lead.score >= 90 {
|
||||
return "Schedule follow-up"
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var session = SessionStore.shared
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
@@ -19,6 +21,7 @@ struct DashboardView: View {
|
||||
} else {
|
||||
metricsGrid
|
||||
liveStatusPanel
|
||||
followUpLoadPanel
|
||||
leadFocusPanel
|
||||
inventoryPanel
|
||||
}
|
||||
@@ -40,15 +43,15 @@ struct DashboardView: View {
|
||||
Text("Dashboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live mobile operator posture for leads, inventory, and follow-up load.")
|
||||
Text("Live mobile operator posture for canonical CRM pipeline, inventory, and follow-up load.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
statusBadge(
|
||||
label: store.isConfigured ? "Live backend" : "Config required",
|
||||
color: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
label: session.isConfigured ? "Live backend" : "Config required",
|
||||
color: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
if let lastRefresh = store.lastRefreshAt {
|
||||
Text("Updated \(lastRefresh.relativeShort)")
|
||||
@@ -63,6 +66,8 @@ struct DashboardView: View {
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent)
|
||||
MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success)
|
||||
MetricCard(title: "Pending Tasks", value: "\(store.metrics.pendingTaskCount)", subtitle: "Canonical CRM reminders", color: VelocityTheme.warning)
|
||||
MetricCard(title: "Urgent Tasks", value: "\(store.metrics.urgentTaskCount)", subtitle: "High and urgent follow-ups", color: VelocityTheme.danger)
|
||||
MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning)
|
||||
MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
@@ -75,11 +80,12 @@ struct DashboardView: View {
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
statusBadge(label: AppConfig.authModeDescription, color: VelocityTheme.accent)
|
||||
statusBadge(label: session.authModeDescription, color: VelocityTheme.accent)
|
||||
}
|
||||
|
||||
detailRow(title: "Endpoint", value: AppConfig.baseURL)
|
||||
detailRow(title: "Operator", value: store.operatorIdentity)
|
||||
detailRow(title: "Endpoint", value: session.endpointDisplay)
|
||||
detailRow(title: "Operator", value: session.operatorIdentity)
|
||||
detailRow(title: "Pending CRM tasks", value: "\(store.metrics.pendingTaskCount)")
|
||||
detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)")
|
||||
detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)")
|
||||
}
|
||||
@@ -87,14 +93,58 @@ struct DashboardView: View {
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var followUpLoadPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Follow-Up Load")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if store.prioritizedTasks.isEmpty {
|
||||
emptyMessage("No canonical CRM reminder tasks are pending for this operator right now.")
|
||||
} else {
|
||||
ForEach(store.prioritizedTasks.prefix(4)) { task in
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(task.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(task.ownerLabel) · \(task.dueLabel)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(taskNote(task))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Text(task.priorityLabel)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(priorityColor(for: task.priority))
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var leadFocusPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Lead Focus")
|
||||
Text("Client Focus")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if store.highlightedLeads.isEmpty {
|
||||
emptyMessage("No live leads have been returned by the backend yet.")
|
||||
emptyMessage("No canonical CRM contacts with active lead context have been returned by the backend yet.")
|
||||
} else {
|
||||
ForEach(store.highlightedLeads) { lead in
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
@@ -209,7 +259,7 @@ struct DashboardView: View {
|
||||
Text("Loading live dashboard data...")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity is reading leads, alerts, calendar events, and inventory summaries from the backend.")
|
||||
Text("Velocity is reading canonical CRM contacts, reminders, alerts, calendar events, and inventory summaries from the backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -232,6 +282,22 @@ struct DashboardView: View {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func priorityColor(for priority: String) -> Color {
|
||||
switch priority.lowercased() {
|
||||
case "urgent":
|
||||
return VelocityTheme.danger
|
||||
case "high":
|
||||
return VelocityTheme.warning
|
||||
default:
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
}
|
||||
|
||||
private func taskNote(_ task: VelocityTaskDTO) -> String {
|
||||
let note = task.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return note.isEmpty ? "No operator note yet." : note
|
||||
}
|
||||
}
|
||||
|
||||
private struct MetricCard: View {
|
||||
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
467
iOS/velocity-ipad/velocity/Features/Imports/ImportsView.swift
Normal file
@@ -0,0 +1,467 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct ImportsView: View {
|
||||
@State private var batches: [VelocityImportBatchSummaryDTO] = []
|
||||
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
|
||||
@State private var detail: VelocityImportBatchDetailDTO?
|
||||
@State private var isLoading = false
|
||||
@State private var isCommitting = false
|
||||
@State private var activeProposalID: String?
|
||||
@State private var errorMessage: String?
|
||||
@State private var successMessage: String?
|
||||
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
batchRail
|
||||
.frame(width: 350)
|
||||
.background(VelocityTheme.sidebarBg)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
|
||||
detailPane
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await loadBatches(selectFirst: true) }
|
||||
.refreshable { await loadBatches(selectFirst: false) }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadBatches(selectFirst: false, silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var batchRail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Imports")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Canonical CRM import review and commit queue.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 22)
|
||||
|
||||
if let errorMessage {
|
||||
errorBanner(errorMessage)
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
if let successMessage {
|
||||
successBanner(successMessage)
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 10) {
|
||||
if isLoading && batches.isEmpty {
|
||||
loadingCard("Loading import batches...")
|
||||
} else if batches.isEmpty {
|
||||
emptyCard("No canonical import batches were returned yet.")
|
||||
} else {
|
||||
ForEach(batches) { batch in
|
||||
batchCard(batch)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detailPane: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if let detail {
|
||||
detailHeader(detail)
|
||||
proposalsPanel(detail)
|
||||
} else if isLoading {
|
||||
loadingCard("Loading import detail...")
|
||||
} else {
|
||||
emptyCard("Select an import batch to review canonical proposals.")
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
|
||||
private func batchCard(_ batch: VelocityImportBatchSummaryDTO) -> some View {
|
||||
Button {
|
||||
Task { await selectBatch(batch) }
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(batch.displayName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(batch.lifecycleLabel)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(lifecycleColor(batch.lifecycle))
|
||||
}
|
||||
Text("\(batch.rowCount) rows · \(batch.mappedCount ?? 0) mapped · \(batch.unresolvedCount ?? 0) unresolved")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(batch.sourceSystem ?? "Unknown source")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(selectedBatch?.batchId == batch.batchId ? VelocityTheme.accent.opacity(0.14) : VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(selectedBatch?.batchId == batch.batchId ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func detailHeader(_ detail: VelocityImportBatchDetailDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(detail.filename ?? "CRM import")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(detail.rowCount) rows · \(detail.proposalCount) proposals · \(detail.sourceSystem ?? "Unknown source")")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(detail.lifecycle.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(lifecycleColor(detail.lifecycle))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(lifecycleColor(detail.lifecycle).opacity(0.12)))
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
metricCard("Pending", value: "\(detail.proposals.filter { $0.status == "pending" }.count)", color: VelocityTheme.warning)
|
||||
metricCard("Approved", value: "\(detail.proposals.filter { $0.status == "approved" }.count)", color: VelocityTheme.success)
|
||||
metricCard("Rejected", value: "\(detail.proposals.filter { $0.status == "rejected" }.count)", color: VelocityTheme.danger)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await commitSelectedBatch() }
|
||||
} label: {
|
||||
HStack {
|
||||
if isCommitting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(isCommitting ? "Committing..." : "Commit Approved Proposals")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(approvedCount(detail) > 0 && !isCommitting ? VelocityTheme.success : VelocityTheme.subtleFg)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(approvedCount(detail) == 0 || isCommitting)
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func proposalsPanel(_ detail: VelocityImportBatchDetailDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Review Proposals")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if detail.proposals.isEmpty {
|
||||
emptyCard("No proposals were returned for this import batch.")
|
||||
} else {
|
||||
ForEach(detail.proposals) { proposal in
|
||||
proposalCard(proposal, batchId: detail.batchId)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(proposal.rowLabel)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(proposal.confidencePercent)% confidence · \(proposal.status.capitalized)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if activeProposalID == proposal.proposalId {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
} else {
|
||||
proposalActions(proposal, batchId: batchId)
|
||||
}
|
||||
}
|
||||
|
||||
if let canonical = proposal.payload?.canonicalPayload, !canonical.isEmpty {
|
||||
Text(canonicalPreview(canonical))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
if let missing = proposal.payload?.missingRequired, !missing.isEmpty {
|
||||
Text("Missing: \(missing.joined(separator: ", "))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button("Approve") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(proposal.status.lowercased() == "approved")
|
||||
|
||||
Button("Reject") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(proposal.status.lowercased() == "rejected")
|
||||
}
|
||||
}
|
||||
|
||||
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
do {
|
||||
let fetched = try await VelocityAPIClient.shared.fetchImportBatches()
|
||||
await MainActor.run {
|
||||
batches = fetched
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
if selectFirst, selectedBatch == nil, let first = fetched.first {
|
||||
await selectBatch(first)
|
||||
} else if let selectedBatch {
|
||||
await refreshDetail(batchId: selectedBatch.batchId, silent: true)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func selectBatch(_ batch: VelocityImportBatchSummaryDTO) async {
|
||||
await MainActor.run {
|
||||
selectedBatch = batch
|
||||
detail = nil
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
isLoading = true
|
||||
}
|
||||
await refreshDetail(batchId: batch.batchId)
|
||||
}
|
||||
|
||||
private func refreshDetail(batchId: String, silent: Bool = false) async {
|
||||
if !silent {
|
||||
await MainActor.run { isLoading = true }
|
||||
}
|
||||
do {
|
||||
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
|
||||
await MainActor.run {
|
||||
detail = fetched
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewProposal(
|
||||
batchId: String,
|
||||
proposal: VelocityImportProposalDTO,
|
||||
decision: String
|
||||
) async {
|
||||
await MainActor.run {
|
||||
activeProposalID = proposal.proposalId
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
}
|
||||
do {
|
||||
_ = try await VelocityAPIClient.shared.reviewImportProposal(
|
||||
batchId: batchId,
|
||||
proposalId: proposal.proposalId,
|
||||
decision: decision,
|
||||
notes: "Reviewed from iPad Imports workspace."
|
||||
)
|
||||
await refreshDetail(batchId: batchId, silent: true)
|
||||
await MainActor.run {
|
||||
activeProposalID = nil
|
||||
successMessage = "Proposal \(decision)."
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
activeProposalID = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func commitSelectedBatch() async {
|
||||
guard let batchId = detail?.batchId else {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
isCommitting = true
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
}
|
||||
do {
|
||||
let result = try await VelocityAPIClient.shared.commitImportBatch(batchId: batchId)
|
||||
await loadBatches(selectFirst: false, silent: true)
|
||||
await refreshDetail(batchId: batchId, silent: true)
|
||||
await MainActor.run {
|
||||
isCommitting = false
|
||||
successMessage = "Committed \(result.committed), skipped \(result.skipped)."
|
||||
if !result.errors.isEmpty {
|
||||
errorMessage = result.errors.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isCommitting = false
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func approvedCount(_ detail: VelocityImportBatchDetailDTO) -> Int {
|
||||
detail.proposals.filter { $0.status == "approved" }.count
|
||||
}
|
||||
|
||||
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
|
||||
payload
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
.prefix(5)
|
||||
.map { "\($0.key): \($0.value.stringValue ?? "-")" }
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private func metricCard(_ label: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
RoundedRectangle(cornerRadius: 3)
|
||||
.fill(color)
|
||||
.frame(width: 34, height: 3)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func loadingCard(_ message: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func emptyCard(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func errorBanner(_ message: String) -> some View {
|
||||
banner(message, color: VelocityTheme.danger)
|
||||
}
|
||||
|
||||
private func successBanner(_ message: String) -> some View {
|
||||
banner(message, color: VelocityTheme.success)
|
||||
}
|
||||
|
||||
private func banner(_ message: String, color: Color) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(color.opacity(0.10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.22), lineWidth: 1))
|
||||
)
|
||||
}
|
||||
|
||||
private func lifecycleColor(_ lifecycle: String) -> Color {
|
||||
switch lifecycle.lowercased() {
|
||||
case "committed":
|
||||
return VelocityTheme.success
|
||||
case "failed":
|
||||
return VelocityTheme.danger
|
||||
case "approved", "proposed", "parsed":
|
||||
return VelocityTheme.warning
|
||||
default:
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImportsView()
|
||||
}
|
||||
@@ -110,7 +110,7 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
// MARK: - Scene Building
|
||||
|
||||
private func buildScene() {
|
||||
guard let sceneView else { return }
|
||||
guard sceneView != nil else { return }
|
||||
|
||||
// Remove old nodes
|
||||
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
@@ -233,7 +233,7 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||
guard positions.count >= 2 else { return SCNNode() }
|
||||
|
||||
var vertices: [SCNVector3] = positions
|
||||
let vertices: [SCNVector3] = positions
|
||||
var indices: [Int32] = []
|
||||
for i in 0..<(vertices.count - 1) {
|
||||
indices.append(Int32(i))
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
enum InventoryModeAvailability {
|
||||
static let dollhouseAssetCandidates: [(name: String, ext: String)] = [
|
||||
("Building", "usdz"),
|
||||
("Building", "scn"),
|
||||
]
|
||||
|
||||
static func hasShippedDollhouseAsset(in bundle: Bundle = .main) -> Bool {
|
||||
dollhouseAssetCandidates.contains { candidate in
|
||||
bundle.url(forResource: candidate.name, withExtension: candidate.ext) != nil
|
||||
}
|
||||
}
|
||||
|
||||
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
|
||||
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
|
||||
if hasDollhouseAsset {
|
||||
modes.append(.dollhouse)
|
||||
}
|
||||
return modes
|
||||
}
|
||||
|
||||
static func sanitizedProductionSelection(
|
||||
_ candidate: InventoryStore.Mode,
|
||||
hasDollhouseAsset: Bool
|
||||
) -> InventoryStore.Mode {
|
||||
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).contains(candidate) ? candidate : .sunseeker
|
||||
}
|
||||
|
||||
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
|
||||
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
|
||||
.map(\.rawValue)
|
||||
.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,29 @@ struct InventoryView: View {
|
||||
@State private var sliderTickHour = 12
|
||||
@State private var showShareSheet = false
|
||||
@State private var shareImage: UIImage? = nil
|
||||
private let hasDollhouseAsset = InventoryModeAvailability.hasShippedDollhouseAsset()
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
private var visibleModes: [InventoryStore.Mode] {
|
||||
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
|
||||
}
|
||||
|
||||
private var selectedMode: InventoryStore.Mode {
|
||||
InventoryModeAvailability.sanitizedProductionSelection(store.mode, hasDollhouseAsset: hasDollhouseAsset)
|
||||
}
|
||||
|
||||
private var modeSelection: Binding<InventoryStore.Mode> {
|
||||
Binding(
|
||||
get: { selectedMode },
|
||||
set: { newValue in
|
||||
store.mode = InventoryModeAvailability.sanitizedProductionSelection(
|
||||
newValue,
|
||||
hasDollhouseAsset: hasDollhouseAsset
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Page header — share button sits on the same baseline as the title
|
||||
@@ -40,7 +61,7 @@ struct InventoryView: View {
|
||||
Text("Inventory")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sunseeker · Dream Weaver · Dollhouse")
|
||||
Text(InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: hasDollhouseAsset))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -53,8 +74,10 @@ struct InventoryView: View {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(8)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
}
|
||||
@@ -62,8 +85,8 @@ struct InventoryView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 20)
|
||||
|
||||
Picker("Mode", selection: $store.mode) {
|
||||
ForEach(InventoryStore.Mode.allCases) { mode in
|
||||
Picker("Mode", selection: modeSelection) {
|
||||
ForEach(visibleModes) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
@@ -71,8 +94,17 @@ struct InventoryView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 12)
|
||||
|
||||
if !hasDollhouseAsset {
|
||||
ProductionScopeCard(
|
||||
icon: "cube.transparent",
|
||||
title: "Dollhouse hidden in this production build",
|
||||
message: "Dollhouse stays out of the production iPad scope until a verified Building.usdz or Building.scn asset is shipped in the app bundle."
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Group {
|
||||
switch store.mode {
|
||||
switch selectedMode {
|
||||
case .sunseeker:
|
||||
#if targetEnvironment(simulator)
|
||||
SimulatorUnavailableCard(
|
||||
@@ -114,6 +146,7 @@ struct InventoryView: View {
|
||||
}
|
||||
)
|
||||
.onAppear {
|
||||
store.mode = selectedMode
|
||||
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
|
||||
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
|
||||
UISegmentedControl.appearance().setTitleTextAttributes(
|
||||
@@ -174,6 +207,42 @@ private struct SimulatorUnavailableCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProductionScopeCard: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.top, 2)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(message)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sunseeker
|
||||
|
||||
private struct SunseekerPanel: View {
|
||||
@@ -416,6 +485,7 @@ private struct DreamWeaverPanel: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(isProcessing)
|
||||
}
|
||||
.padding(16)
|
||||
.background {
|
||||
@@ -605,7 +675,13 @@ private struct SceneKitDollhouseView: UIViewRepresentable {
|
||||
private let sunNode = SCNNode()
|
||||
|
||||
func setupScene() {
|
||||
if let modelScene = SCNScene(named: "Building.usdz") ?? SCNScene(named: "Building.scn") {
|
||||
let modelScene = InventoryModeAvailability.dollhouseAssetCandidates
|
||||
.compactMap { candidate in
|
||||
SCNScene(named: "\(candidate.name).\(candidate.ext)")
|
||||
}
|
||||
.first
|
||||
|
||||
if let modelScene {
|
||||
let container = SCNNode()
|
||||
for child in modelScene.rootNode.childNodes {
|
||||
container.addChildNode(child.clone())
|
||||
@@ -40,7 +40,6 @@ struct SimulatorSunOverlayView: UIViewRepresentable {
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
private weak var sceneView: SCNView?
|
||||
@Binding private var sunNodesReady: Bool
|
||||
|
||||
private let mockLocation: CLLocationCoordinate2D
|
||||
@@ -59,7 +58,6 @@ struct SimulatorSunOverlayView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func attach(to view: SCNView) {
|
||||
self.sceneView = view
|
||||
view.scene?.rootNode.addChildNode(arcRootNode)
|
||||
view.scene?.rootNode.addChildNode(currentSunNode)
|
||||
buildScene()
|
||||
@@ -225,7 +223,7 @@ struct SimulatorSunOverlayView: UIViewRepresentable {
|
||||
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||
guard positions.count >= 2 else { return SCNNode() }
|
||||
|
||||
var vertices: [SCNVector3] = positions
|
||||
let vertices: [SCNVector3] = positions
|
||||
var indices: [Int32] = []
|
||||
for i in 0..<(vertices.count - 1) {
|
||||
indices.append(Int32(i))
|
||||
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum OracleModeAvailability {
|
||||
static let productionVisibleModes: [OracleMode] = [
|
||||
.pipeline,
|
||||
.deals,
|
||||
.accountTimeline,
|
||||
.calendarTasks,
|
||||
]
|
||||
|
||||
static let hiddenModesUntilBackendSupport: [OracleMode] = [
|
||||
.teamPerformance,
|
||||
.leadMap,
|
||||
]
|
||||
|
||||
static func sanitizedProductionSelection(_ candidate: OracleMode) -> OracleMode {
|
||||
productionVisibleModes.contains(candidate) ? candidate : .pipeline
|
||||
}
|
||||
}
|
||||
1223
iOS/velocity-ipad/velocity/Features/Oracle/OracleView.swift
Normal file
1223
iOS/velocity-ipad/velocity/Features/Oracle/OracleView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
enum SentinelScope {
|
||||
static let navigationTitle = "Operator Posture"
|
||||
static let productFamilyName = "Sentinel"
|
||||
static let availabilityBadge = "Operator posture only"
|
||||
|
||||
static let disabledAnalyticsCapabilities: [String] = [
|
||||
"visitor counting",
|
||||
"facial detections",
|
||||
"sentiment scoring",
|
||||
]
|
||||
|
||||
static let liveBackedCapabilities: [String] = [
|
||||
"alert posture",
|
||||
"transcription queue visibility",
|
||||
"upcoming calendar pressure",
|
||||
"recent operator timeline",
|
||||
]
|
||||
|
||||
static var disabledAnalyticsSummary: String {
|
||||
disabledAnalyticsCapabilities.joined(separator: ", ")
|
||||
}
|
||||
|
||||
static var liveBackedSummary: String {
|
||||
liveBackedCapabilities.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
@@ -30,10 +31,14 @@ struct SentinelView: View {
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Sentinel")
|
||||
Text(SentinelScope.productFamilyName.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(SentinelScope.navigationTitle)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Truthful live posture for alerts and comms load; visitor analytics stay disabled until a real Sentinel stream is exposed.")
|
||||
Text("Truthful live posture for alerts and comms load. \(SentinelScope.productFamilyName) analytics stay disabled on iPad until a real production stream is exposed.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -42,21 +47,21 @@ struct SentinelView: View {
|
||||
private var availabilityCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Feed Availability")
|
||||
Text("Production Scope")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
statusBadge(
|
||||
label: "No mock feed",
|
||||
label: SentinelScope.availabilityBadge,
|
||||
color: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
Text("This iPad build does not synthesize visitor counts, facial detections, or sentiment scores. A dedicated production Sentinel route is still required before those analytics can be shown safely.")
|
||||
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Text("Current surface instead reports real operator urgency from the live mobile-edge backend.")
|
||||
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SessionConfigurationPanel: View {
|
||||
@State private var session = SessionStore.shared
|
||||
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let primaryActionTitle: String
|
||||
let allowsClearingStoredConfiguration: Bool
|
||||
|
||||
var body: some View {
|
||||
@Bindable var session = session
|
||||
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
VStack(spacing: 14) {
|
||||
SessionInputField(
|
||||
label: "Backend endpoint",
|
||||
placeholder: "https://velocity.desineuron.in/api"
|
||||
) {
|
||||
TextField("", text: $session.draftBaseURL, prompt: Text("https://velocity.desineuron.in/api"))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
SessionInputField(
|
||||
label: "Dream Weaver endpoint",
|
||||
placeholder: "Leave blank to use the backend endpoint"
|
||||
) {
|
||||
TextField("", text: $session.draftDreamWeaverBaseURL, prompt: Text("https://dreamweaver.desineuron.in"))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
}
|
||||
|
||||
SessionInputField(
|
||||
label: "Dream Weaver gateway API key",
|
||||
placeholder: session.currentConfiguration.hasDreamWeaverAPIKey ? "Leave blank to keep the current gateway key" : "Optional unless the gateway enforces it"
|
||||
) {
|
||||
SecureField(
|
||||
"",
|
||||
text: $session.draftDreamWeaverAPIKey,
|
||||
prompt: Text(session.currentConfiguration.hasDreamWeaverAPIKey ? "Leave blank to keep the current gateway key" : "Optional unless the gateway enforces it")
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Authentication mode")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
Picker("Authentication mode", selection: $session.draftAuthMode) {
|
||||
ForEach(SessionAuthMode.allCases) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
if session.draftAuthMode == .emailPassword {
|
||||
SessionInputField(
|
||||
label: "Operator email",
|
||||
placeholder: "operator@desineuron.in"
|
||||
) {
|
||||
TextField("", text: $session.draftEmail, prompt: Text("operator@desineuron.in"))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.emailAddress)
|
||||
}
|
||||
|
||||
SessionInputField(
|
||||
label: "Password",
|
||||
placeholder: session.currentConfiguration.hasPassword ? "Leave blank to keep the current secret" : "Required"
|
||||
) {
|
||||
SecureField(
|
||||
"",
|
||||
text: $session.draftPassword,
|
||||
prompt: Text(session.currentConfiguration.hasPassword ? "Leave blank to keep the current secret" : "Required")
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
} else {
|
||||
SessionInputField(
|
||||
label: "Bearer token",
|
||||
placeholder: session.currentConfiguration.hasBearerToken ? "Leave blank to keep the current token" : "Required"
|
||||
) {
|
||||
SecureField(
|
||||
"",
|
||||
text: $session.draftBearerToken,
|
||||
prompt: Text(session.currentConfiguration.hasBearerToken ? "Leave blank to keep the current token" : "Required")
|
||||
)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Current source: \(session.configurationSourceDescription)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Runtime overrides are saved on-device. Secrets are stored in Keychain; the backend endpoint, optional Dream Weaver endpoint, and operator email are stored in local app preferences.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if let message = session.statusMessage {
|
||||
SessionStatusBanner(message: message, accentColor: VelocityTheme.success)
|
||||
}
|
||||
|
||||
if let error = session.errorMessage {
|
||||
SessionStatusBanner(message: error, accentColor: VelocityTheme.danger)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await session.saveDraft() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if session.isSaving {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
.tint(.white)
|
||||
}
|
||||
Text(primaryActionTitle)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(SessionActionButtonStyle(background: VelocityTheme.accent))
|
||||
.disabled(session.isSaving)
|
||||
|
||||
Button("Reset form") {
|
||||
session.discardDraftChanges()
|
||||
}
|
||||
.buttonStyle(SessionSecondaryButtonStyle())
|
||||
.disabled(session.isSaving || !session.hasUnsavedChanges)
|
||||
}
|
||||
|
||||
if allowsClearingStoredConfiguration {
|
||||
Button("Clear stored session override") {
|
||||
Task { await session.clearStoredConfiguration() }
|
||||
}
|
||||
.buttonStyle(SessionDangerButtonStyle())
|
||||
.disabled(session.isSaving || !session.isUsingStoredRuntimeConfiguration)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionInputField<Field: View>: View {
|
||||
let label: String
|
||||
let placeholder: String
|
||||
@ViewBuilder let field: Field
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(label)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
field
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
|
||||
Text(placeholder)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionStatusBanner: View {
|
||||
let message: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
Text(message)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(accentColor.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(accentColor.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionActionButtonStyle: ButtonStyle {
|
||||
let background: Color
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(.white)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(background.opacity(configuration.isPressed ? 0.82 : 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionSecondaryButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(VelocityTheme.surface.opacity(configuration.isPressed ? 0.78 : 1))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SessionDangerButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 11)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(VelocityTheme.danger.opacity(configuration.isPressed ? 0.14 : 0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(VelocityTheme.danger.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
224
iOS/velocity-ipad/velocity/Features/Settings/SettingsView.swift
Normal file
224
iOS/velocity-ipad/velocity/Features/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var session = SessionStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live runtime configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Connectivity") {
|
||||
SettingsRow(
|
||||
label: "Backend endpoint",
|
||||
value: session.endpointDisplay,
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Dream Weaver endpoint",
|
||||
value: session.dreamWeaverEndpointDisplay,
|
||||
icon: "wand.and.stars",
|
||||
accentColor: session.dreamWeaverEndpointModeDescription == "Dedicated gateway" ? VelocityTheme.warning : VelocityTheme.mutedFg
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Dream Weaver route mode",
|
||||
value: session.dreamWeaverEndpointModeDescription,
|
||||
icon: "point.3.connected.trianglepath.dotted",
|
||||
accentColor: session.dreamWeaverEndpointModeDescription == "Dedicated gateway" ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Dream Weaver auth",
|
||||
value: session.dreamWeaverAuthenticationDescription,
|
||||
icon: "key.horizontal",
|
||||
accentColor: session.dreamWeaverAuthenticationDescription == "API key configured" ? VelocityTheme.success : VelocityTheme.mutedFg
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Auth mode",
|
||||
value: session.authModeDescription,
|
||||
icon: "lock.shield",
|
||||
accentColor: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Config source",
|
||||
value: session.configurationSourceDescription,
|
||||
icon: "externaldrive.badge.icloud",
|
||||
accentColor: session.isUsingStoredRuntimeConfiguration ? VelocityTheme.success : VelocityTheme.mutedFg
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Last refresh",
|
||||
value: store.lastRefreshAt?.relativeShort ?? "No live fetch yet",
|
||||
icon: "arrow.clockwise",
|
||||
accentColor: VelocityTheme.mutedFg
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Operator") {
|
||||
SettingsRow(
|
||||
label: "Identity",
|
||||
value: session.operatorIdentity,
|
||||
icon: "person.crop.circle",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "CRM contacts loaded",
|
||||
value: "\(store.contacts.count)",
|
||||
icon: "person.3",
|
||||
accentColor: VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Pending CRM tasks loaded",
|
||||
value: "\(store.tasks.count)",
|
||||
icon: "checklist",
|
||||
accentColor: VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Property records loaded",
|
||||
value: "\(store.properties.count)",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Production Readiness") {
|
||||
SettingsRow(
|
||||
label: "Canonical contacts",
|
||||
value: "\(store.contacts.count) loaded",
|
||||
icon: "person.text.rectangle",
|
||||
accentColor: store.contacts.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Pipeline lanes",
|
||||
value: "\(store.kanbanColumns.reduce(0) { $0 + $1.count }) leads",
|
||||
icon: "square.grid.3x1.below.line.grid.1x2",
|
||||
accentColor: store.kanbanColumns.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Deals",
|
||||
value: "\(store.opportunities.count) opportunities",
|
||||
icon: "target",
|
||||
accentColor: store.opportunities.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Timeline events",
|
||||
value: "\(store.timelineEvents.count) hydrated",
|
||||
icon: "clock.arrow.circlepath",
|
||||
accentColor: store.timelineEvents.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Last app error",
|
||||
value: store.errorMessage ?? "None",
|
||||
icon: "exclamationmark.triangle",
|
||||
accentColor: store.errorMessage == nil ? VelocityTheme.success : VelocityTheme.danger
|
||||
)
|
||||
}
|
||||
|
||||
SessionConfigurationPanel(
|
||||
title: "Session Configuration",
|
||||
subtitle: "Update the production endpoint, point Dream Weaver at a dedicated gateway when needed, or rotate operator credentials without rebuilding the app. Saving clears the cached token, re-runs a live refresh, and probes the Dream Weaver routes.",
|
||||
primaryActionTitle: "Save and refresh",
|
||||
allowsClearingStoredConfiguration: true
|
||||
)
|
||||
|
||||
SettingsSection(title: "Production Notes") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This build avoids local demo data. Runtime session overrides are stored on-device so investor or operator installs no longer depend on committed build-time credentials.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(SentinelScope.navigationTitle) remains the truthful iPad label for the current \(SentinelScope.productFamilyName) surface because visitor analytics stay disabled until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed. Dream Weaver can now use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are still enforced and reported truthfully.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,23 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>API_BEARER_TOKEN</key>
|
||||
<string>$(API_BEARER_TOKEN)</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>
|
||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||
<key>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>DREAM_WEAVER_BASE_URL</key>
|
||||
<string>$(DREAM_WEAVER_BASE_URL)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
973
iOS/velocity-ipad/velocityTests/VelocitySmokeTests.swift
Normal file
973
iOS/velocity-ipad/velocityTests/VelocitySmokeTests.swift
Normal file
@@ -0,0 +1,973 @@
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
@testable import velocity
|
||||
|
||||
final class VelocitySmokeTests: XCTestCase {
|
||||
@MainActor
|
||||
func testContentViewCanBeConstructed() {
|
||||
let view = ContentView()
|
||||
XCTAssertNotNil(view)
|
||||
}
|
||||
|
||||
func testAppSectionsRemainStable() {
|
||||
XCTAssertEqual(
|
||||
AppSection.allCases.map(\.rawValue),
|
||||
[
|
||||
"Dashboard",
|
||||
"Clients",
|
||||
"Imports",
|
||||
"Communications",
|
||||
"Calendar",
|
||||
"Oracle",
|
||||
"Sentinel",
|
||||
"Inventory",
|
||||
"Settings",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testAppSectionDisplayTitlesMatchProductionScope() {
|
||||
XCTAssertEqual(
|
||||
AppSection.allCases.map(\.displayTitle),
|
||||
[
|
||||
"Dashboard",
|
||||
"Clients",
|
||||
"Imports",
|
||||
"Communications",
|
||||
"Calendar",
|
||||
"Oracle",
|
||||
"Operator Posture",
|
||||
"Inventory",
|
||||
"Settings",
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testAppConfigParsesExplicitValuesAndRejectsPlaceholders() {
|
||||
XCTAssertEqual(
|
||||
AppConfig.parsedValue(from: ["BASE_URL": " https://velocity.desineuron.in/api "], key: "BASE_URL"),
|
||||
"https://velocity.desineuron.in/api"
|
||||
)
|
||||
XCTAssertNil(
|
||||
AppConfig.parsedValue(from: ["BASE_URL": "$(BASE_URL)"], key: "BASE_URL")
|
||||
)
|
||||
XCTAssertNil(
|
||||
AppConfig.parsedValue(from: ["BASE_URL": " "], key: "BASE_URL")
|
||||
)
|
||||
XCTAssertNil(
|
||||
AppConfig.parsedValue(from: nil, key: "BASE_URL")
|
||||
)
|
||||
}
|
||||
|
||||
func testAuthModeDescriptionOnlyReportsConfiguredCredentials() {
|
||||
XCTAssertEqual(
|
||||
AppConfig.authModeDescription(bearerToken: "token", email: nil, password: nil),
|
||||
"Bearer token"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppConfig.authModeDescription(bearerToken: nil, email: "user@example.com", password: "secret"),
|
||||
"Email/password"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
AppConfig.authModeDescription(bearerToken: nil, email: "user@example.com", password: nil),
|
||||
"Credentials required"
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionDraftRejectsInsecureOrIncompleteConfiguration() {
|
||||
let insecureDraft = SessionConfigurationDraft(
|
||||
baseURL: "http://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .emailPassword,
|
||||
email: "operator@desineuron.in",
|
||||
password: "secret",
|
||||
bearerToken: "",
|
||||
existingDreamWeaverAPIKeyAvailable: false,
|
||||
existingPasswordAvailable: false,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: nil
|
||||
)
|
||||
XCTAssertFalse(insecureDraft.validationErrors().isEmpty)
|
||||
|
||||
let missingPasswordDraft = SessionConfigurationDraft(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .emailPassword,
|
||||
email: "operator@desineuron.in",
|
||||
password: "",
|
||||
bearerToken: "",
|
||||
existingDreamWeaverAPIKeyAvailable: false,
|
||||
existingPasswordAvailable: false,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: nil
|
||||
)
|
||||
XCTAssertTrue(
|
||||
missingPasswordDraft.validationErrors().contains("Password is required for email/password login.")
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionDraftNormalizesHttpsOriginAndReusesExistingSecretsSafely() {
|
||||
let draft = SessionConfigurationDraft(
|
||||
baseURL: " https://Velocity.DesiNeuron.in/api/ ",
|
||||
dreamWeaverBaseURL: " https://dreamweaver.DesiNeuron.in/ ",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .emailPassword,
|
||||
email: "operator@desineuron.in",
|
||||
password: "",
|
||||
bearerToken: "",
|
||||
existingDreamWeaverAPIKeyAvailable: true,
|
||||
existingPasswordAvailable: true,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: "operator@desineuron.in"
|
||||
)
|
||||
|
||||
XCTAssertEqual(draft.normalizedBaseURL, "https://velocity.desineuron.in/api")
|
||||
XCTAssertEqual(draft.normalizedDreamWeaverBaseURL, "https://dreamweaver.desineuron.in")
|
||||
XCTAssertEqual(
|
||||
draft.resolvedPassword(existingPassword: "stored-secret"),
|
||||
"stored-secret"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
draft.resolvedEmail(existingEmail: nil),
|
||||
"operator@desineuron.in"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
draft.resolvedDreamWeaverAPIKey(existingKey: "stored-gateway-key"),
|
||||
"stored-gateway-key"
|
||||
)
|
||||
}
|
||||
|
||||
func testSessionDraftRejectsInsecureDreamWeaverEndpoint() {
|
||||
let draft = SessionConfigurationDraft(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "http://54.172.172.2:8082",
|
||||
dreamWeaverAPIKey: "",
|
||||
authMode: .bearerToken,
|
||||
email: "",
|
||||
password: "",
|
||||
bearerToken: "token",
|
||||
existingDreamWeaverAPIKeyAvailable: false,
|
||||
existingPasswordAvailable: false,
|
||||
existingBearerTokenAvailable: false,
|
||||
baselineEmail: nil
|
||||
)
|
||||
|
||||
XCTAssertTrue(
|
||||
draft.validationErrors().contains(
|
||||
"Dream Weaver endpoint must be an HTTPS origin like https://dreamweaver.desineuron.in."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testAppSessionConfigurationReflectsDreamWeaverGatewayKeyState() {
|
||||
let configured = AppSessionConfiguration(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "https://dreamweaver.desineuron.in",
|
||||
usesDedicatedDreamWeaverBaseURL: true,
|
||||
hasDreamWeaverAPIKey: true,
|
||||
email: "operator@desineuron.in",
|
||||
hasPassword: true,
|
||||
hasBearerToken: false,
|
||||
source: .secureDeviceStorage
|
||||
)
|
||||
XCTAssertEqual(configured.dreamWeaverAuthenticationDescription, "API key configured")
|
||||
|
||||
let open = AppSessionConfiguration(
|
||||
baseURL: "https://velocity.desineuron.in/api",
|
||||
dreamWeaverBaseURL: "https://velocity.desineuron.in",
|
||||
usesDedicatedDreamWeaverBaseURL: false,
|
||||
hasDreamWeaverAPIKey: false,
|
||||
email: nil,
|
||||
hasPassword: false,
|
||||
hasBearerToken: true,
|
||||
source: .buildConfiguration
|
||||
)
|
||||
XCTAssertEqual(open.dreamWeaverAuthenticationDescription, "No gateway key configured")
|
||||
}
|
||||
|
||||
func testGenerationJobBuildsFallbackRoutesWhenGatewayReturnsMinimalContract() throws {
|
||||
let job = try JSONDecoder().decode(
|
||||
GenerationJob.self,
|
||||
from: Data(#"{"job_id":"job-123","status":"processing"}"#.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(job.pollUrl, nil)
|
||||
XCTAssertEqual(job.resultUrl, nil)
|
||||
XCTAssertEqual(
|
||||
try job.resolvedPollURL(baseURL: "https://dw.desineuron.in").absoluteString,
|
||||
"https://dw.desineuron.in/dream-weaver/status/job-123"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
try job.resolvedResultURL(baseURL: "https://dw.desineuron.in").absoluteString,
|
||||
"https://dw.desineuron.in/dream-weaver/result/job-123"
|
||||
)
|
||||
}
|
||||
|
||||
func testJobStatusResolvesRelativeResultURL() throws {
|
||||
let status = try JSONDecoder().decode(
|
||||
JobStatus.self,
|
||||
from: Data(#"{"status":"done","ready":true,"result_url":"/dream-weaver/result/job-123"}"#.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
try status.resolvedResultURL(baseURL: "https://dw.desineuron.in", jobId: "job-123").absoluteString,
|
||||
"https://dw.desineuron.in/dream-weaver/result/job-123"
|
||||
)
|
||||
}
|
||||
|
||||
func testOracleProductionScopeOnlyExposesLiveBackedModes() {
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.productionVisibleModes,
|
||||
[
|
||||
.pipeline,
|
||||
.deals,
|
||||
.accountTimeline,
|
||||
.calendarTasks,
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.hiddenModesUntilBackendSupport,
|
||||
[
|
||||
.teamPerformance,
|
||||
.leadMap,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
func testOracleProductionScopeSanitizesUnsupportedSelections() {
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.teamPerformance),
|
||||
.pipeline
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.leadMap),
|
||||
.pipeline
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.calendarTasks),
|
||||
.calendarTasks
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OracleModeAvailability.sanitizedProductionSelection(.deals),
|
||||
.deals
|
||||
)
|
||||
}
|
||||
|
||||
func testInventoryProductionScopeHidesDollhouseWithoutAsset() {
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: false),
|
||||
[
|
||||
.sunseeker,
|
||||
.dreamWeaver,
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: false),
|
||||
"Sunseeker · Dream Weaver"
|
||||
)
|
||||
}
|
||||
|
||||
func testCanonicalContactAdapterFiltersRowsWithoutLeadContextAndNormalizesScore() {
|
||||
let summaries = VelocityLeadDTO.activeLeadSummaries(
|
||||
from: [
|
||||
VelocityCanonicalContactListItemDTO(
|
||||
personId: "person-1",
|
||||
fullName: "Amina Rahman",
|
||||
primaryPhone: "+971500000001",
|
||||
buyerType: "high_intent",
|
||||
leadId: "lead-1",
|
||||
leadStatus: "qualified",
|
||||
budgetBand: "AED 12M",
|
||||
urgency: "high",
|
||||
primaryInterest: "Marina Penthouse",
|
||||
intentScore: 0.94,
|
||||
engagementScore: 0.91,
|
||||
urgencyScore: 0.88,
|
||||
interactionCount: 6,
|
||||
pendingTasks: 2,
|
||||
lastInteractionAt: "2026-04-22T10:00:00+00:00",
|
||||
createdAt: "2026-04-21T10:00:00+00:00"
|
||||
),
|
||||
VelocityCanonicalContactListItemDTO(
|
||||
personId: "person-2",
|
||||
fullName: "Contact Without Lead",
|
||||
primaryPhone: nil,
|
||||
buyerType: nil,
|
||||
leadId: nil,
|
||||
leadStatus: nil,
|
||||
budgetBand: nil,
|
||||
urgency: nil,
|
||||
primaryInterest: nil,
|
||||
intentScore: 0.42,
|
||||
engagementScore: 0.10,
|
||||
urgencyScore: 0.20,
|
||||
interactionCount: 0,
|
||||
pendingTasks: 0,
|
||||
lastInteractionAt: nil,
|
||||
createdAt: "2026-04-21T10:00:00+00:00"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(summaries.count, 1)
|
||||
XCTAssertEqual(summaries[0].id, "lead-1")
|
||||
XCTAssertEqual(summaries[0].personId, "person-1")
|
||||
XCTAssertEqual(summaries[0].score, 94)
|
||||
XCTAssertEqual(summaries[0].kanbanStatus, "Qualified")
|
||||
XCTAssertEqual(summaries[0].qualification, "Whale")
|
||||
XCTAssertEqual(summaries[0].pendingTaskCount, 2)
|
||||
XCTAssertEqual(summaries[0].interactionCount, 6)
|
||||
XCTAssertEqual(summaries[0].updatedAt, "2026-04-22T10:00:00+00:00")
|
||||
}
|
||||
|
||||
func testClientDataContactDecodesCurrentQDFamilies() throws {
|
||||
let payload = """
|
||||
{
|
||||
"person_id": "person-live",
|
||||
"full_name": "Sanjay Chatterjee",
|
||||
"primary_phone": "+91-88479-41519",
|
||||
"buyer_type": "investor",
|
||||
"lead_id": "lead-live",
|
||||
"lead_status": "new",
|
||||
"budget_band": "15-25 Cr",
|
||||
"urgency": "6_months",
|
||||
"primary_interest": "Eden Devprayag",
|
||||
"interaction_count": 4,
|
||||
"pending_tasks": 1,
|
||||
"last_interaction_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"qd_overview": {
|
||||
"intent": {
|
||||
"score_type": "intent",
|
||||
"current_value": 0.73
|
||||
},
|
||||
"engagement": {
|
||||
"score_type": "engagement",
|
||||
"current_value": 0.68
|
||||
},
|
||||
"urgency": {
|
||||
"score_type": "urgency",
|
||||
"current_value": 0.91
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let contact = try JSONDecoder().decode(
|
||||
VelocityCanonicalContactListItemDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
let snapshot = VelocityClient360DTO.minimal(from: contact)
|
||||
|
||||
XCTAssertEqual(contact.personId, "person-live")
|
||||
XCTAssertEqual(contact.intentScore, 0.73)
|
||||
XCTAssertEqual(contact.engagementScore, 0.68)
|
||||
XCTAssertEqual(contact.urgencyScore, 0.91)
|
||||
XCTAssertEqual(contact.displayIntentScore, 91)
|
||||
XCTAssertEqual(snapshot.primaryQDScore?.scoreType, "overall")
|
||||
XCTAssertEqual(snapshot.primaryQDScore?.displayScore, 91)
|
||||
}
|
||||
|
||||
func testClientDataDetailAdaptsNestedProductionShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"person": {
|
||||
"person_id": "person-live",
|
||||
"full_name": "Sanjay Chatterjee",
|
||||
"primary_email": "sanjay@example.com",
|
||||
"primary_phone": "+91-88479-41519",
|
||||
"buyer_type": "investor",
|
||||
"persona_labels": ["high intent"]
|
||||
},
|
||||
"lead": {
|
||||
"lead_id": "lead-live",
|
||||
"lead_status": "qualified",
|
||||
"budget_band": "15-25 Cr",
|
||||
"urgency": "6_months"
|
||||
},
|
||||
"opportunities": [
|
||||
{
|
||||
"opportunity_id": "opp-live",
|
||||
"stage": "site_visit",
|
||||
"value": 18000000,
|
||||
"probability": 0.7,
|
||||
"expected_close_date": "2026-05-01",
|
||||
"next_action": "Schedule visit",
|
||||
"notes": null,
|
||||
"project_id": "project-1",
|
||||
"unit_id": null,
|
||||
"person_id": "person-live",
|
||||
"client_name": "Sanjay Chatterjee",
|
||||
"client_phone": "+91-88479-41519",
|
||||
"project_name": "Eden Devprayag"
|
||||
}
|
||||
],
|
||||
"property_interests": [
|
||||
{
|
||||
"interest_id": "interest-live",
|
||||
"project_name": "Eden Devprayag",
|
||||
"unit_preference": "Penthouse",
|
||||
"configuration": "4 BHK",
|
||||
"budget_min": 15000000,
|
||||
"budget_max": 25000000,
|
||||
"priority": 1
|
||||
}
|
||||
],
|
||||
"recent_interactions": [
|
||||
{
|
||||
"interaction_id": "interaction-live",
|
||||
"channel": "call",
|
||||
"interaction_type": "follow_up",
|
||||
"happened_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"summary": "Asked for tower availability."
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"reminder_id": "task-live",
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Share floor plan",
|
||||
"notes": "Send updated inventory.",
|
||||
"due_at": "2026-04-26T10:00:00+00:00",
|
||||
"status": "pending",
|
||||
"priority": "high",
|
||||
"person_id": "person-live",
|
||||
"client_name": "Sanjay Chatterjee",
|
||||
"client_phone": "+91-88479-41519"
|
||||
}
|
||||
],
|
||||
"qd_scores": [
|
||||
{
|
||||
"score_type": "intent",
|
||||
"current_value": 0.73,
|
||||
"computed_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"reasoning": "Recent call showed buying intent."
|
||||
},
|
||||
{
|
||||
"score_type": "urgency",
|
||||
"current_value": 0.91
|
||||
}
|
||||
],
|
||||
"next_best_action": "Share inventory shortlist"
|
||||
}
|
||||
"""
|
||||
|
||||
let detail = try JSONDecoder().decode(
|
||||
VelocityClientDataDetailDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
).snapshot
|
||||
|
||||
XCTAssertEqual(detail.identity.personId, "person-live")
|
||||
XCTAssertEqual(detail.identity.fullName, "Sanjay Chatterjee")
|
||||
XCTAssertEqual(detail.currentLead?.leadId, "lead-live")
|
||||
XCTAssertEqual(detail.currentLead?.status, "qualified")
|
||||
XCTAssertEqual(detail.activeOpportunities.count, 1)
|
||||
XCTAssertEqual(detail.propertyInterests.count, 1)
|
||||
XCTAssertEqual(detail.recentInteractions.count, 1)
|
||||
XCTAssertEqual(detail.tasks.count, 1)
|
||||
XCTAssertEqual(detail.primaryQDScore?.scoreType, "intent")
|
||||
XCTAssertEqual(detail.recommendedNextActions, ["Share inventory shortlist"])
|
||||
}
|
||||
|
||||
func testCanonicalTaskAdapterSortsUrgentAndSoonerItemsFirst() {
|
||||
let tasks = VelocityTaskDTO.sortedForOperatorReview(
|
||||
[
|
||||
VelocityTaskDTO(
|
||||
reminderId: "task-3",
|
||||
reminderType: "follow_up",
|
||||
title: "Later high-priority visit",
|
||||
notes: nil,
|
||||
dueAt: "2026-04-24T10:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
),
|
||||
VelocityTaskDTO(
|
||||
reminderId: "task-1",
|
||||
reminderType: "follow_up",
|
||||
title: "Urgent callback",
|
||||
notes: "Call now",
|
||||
dueAt: "2026-04-23T08:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "urgent",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
),
|
||||
VelocityTaskDTO(
|
||||
reminderId: "task-2",
|
||||
reminderType: "follow_up",
|
||||
title: "Soon high-priority visit",
|
||||
notes: nil,
|
||||
dueAt: "2026-04-23T09:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "high",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(tasks.map(\.reminderId), ["task-1", "task-2", "task-3"])
|
||||
XCTAssertEqual(tasks[0].priorityLabel, "Urgent")
|
||||
XCTAssertNotNil(tasks[1].dueDate)
|
||||
XCTAssertEqual(tasks[1].title, "Soon high-priority visit")
|
||||
}
|
||||
|
||||
func testCanonicalTaskSnoozeUsesLaterOfNowOrDueDate() {
|
||||
let task = VelocityTaskDTO(
|
||||
reminderId: "task-1",
|
||||
reminderType: "follow_up",
|
||||
title: "Urgent callback",
|
||||
notes: nil,
|
||||
dueAt: "2099-04-23T08:00:00+00:00",
|
||||
status: "pending",
|
||||
priority: "urgent",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001"
|
||||
)
|
||||
|
||||
let snoozed = task.nextSnoozeDate(adding: 2 * 60 * 60)
|
||||
|
||||
XCTAssertEqual(
|
||||
Int(snoozed.timeIntervalSince(task.dueDate ?? .distantPast)),
|
||||
2 * 60 * 60
|
||||
)
|
||||
}
|
||||
|
||||
func testCanonicalKanbanAdapterSortsCardsByIntentScoreWithinLane() {
|
||||
let board = VelocityKanbanColumnDTO.operatorDisplayBoard(
|
||||
from: [
|
||||
VelocityKanbanColumnDTO(
|
||||
status: "qualified",
|
||||
label: "Qualified",
|
||||
count: 2,
|
||||
items: [
|
||||
VelocityKanbanCardDTO(
|
||||
leadId: "lead-2",
|
||||
personId: "person-2",
|
||||
clientName: "Zara Khan",
|
||||
clientPhone: "+971500000002",
|
||||
buyerType: "investor",
|
||||
budgetBand: "AED 9M",
|
||||
urgency: "high",
|
||||
intentScore: 0.61
|
||||
),
|
||||
VelocityKanbanCardDTO(
|
||||
leadId: "lead-1",
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001",
|
||||
buyerType: "high_intent",
|
||||
budgetBand: "AED 12M",
|
||||
urgency: "urgent",
|
||||
intentScore: 0.94
|
||||
),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertEqual(board.count, 1)
|
||||
XCTAssertEqual(board[0].items.map(\.leadId), ["lead-1", "lead-2"])
|
||||
XCTAssertEqual(board[0].items[0].displayIntentScore, 94)
|
||||
XCTAssertEqual(board[0].items[0].buyerTypeLabel, "High Intent")
|
||||
XCTAssertEqual(board[0].items[0].urgencyLabel, "Urgent")
|
||||
}
|
||||
|
||||
func testClient360ResponseDecodesCanonicalSnapshotShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"client_ref": "person-1",
|
||||
"snapshot_type": "client_360",
|
||||
"identity": {
|
||||
"person_id": "person-1",
|
||||
"full_name": "Amina Rahman",
|
||||
"primary_email": "amina@example.com",
|
||||
"primary_phone": "+971500000001",
|
||||
"buyer_type": "high_intent",
|
||||
"persona_labels": ["marina_buyer"],
|
||||
"source_confidence": 1.0
|
||||
},
|
||||
"current_lead": {
|
||||
"lead_id": "lead-1",
|
||||
"status": "qualified",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
"financing_posture": null,
|
||||
"timeline_to_decision": null,
|
||||
"objections": [],
|
||||
"motivations": ["view_upgrade"]
|
||||
},
|
||||
"active_opportunities": [
|
||||
{
|
||||
"opportunity_id": "opp-1",
|
||||
"stage": "proposal",
|
||||
"value": 12000000,
|
||||
"probability": 0.8,
|
||||
"expected_close_date": "2026-04-30",
|
||||
"next_action": "Share proposal",
|
||||
"project_id": null,
|
||||
"unit_id": null
|
||||
}
|
||||
],
|
||||
"recent_interactions": [
|
||||
{
|
||||
"interaction_id": "int-1",
|
||||
"channel": "whatsapp",
|
||||
"interaction_type": "message",
|
||||
"happened_at": "2026-04-22T10:00:00+00:00",
|
||||
"summary": "Asked for brochure"
|
||||
}
|
||||
],
|
||||
"property_interests": [
|
||||
{
|
||||
"interest_id": "interest-1",
|
||||
"project_name": "Marina Residences",
|
||||
"unit_preference": "4BR",
|
||||
"configuration": "Penthouse",
|
||||
"budget_min": 10000000,
|
||||
"budget_max": 15000000,
|
||||
"priority": 1
|
||||
}
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"reminder_id": "task-1",
|
||||
"reminder_type": "follow_up",
|
||||
"title": "Call back",
|
||||
"due_at": "2026-04-23T09:00:00+00:00",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
}
|
||||
],
|
||||
"qd_overview": {
|
||||
"intent_score": {
|
||||
"score_type": "intent_score",
|
||||
"current_value": 0.94,
|
||||
"computed_at": "2026-04-22T10:00:00+00:00",
|
||||
"reasoning": "Strong engagement"
|
||||
}
|
||||
},
|
||||
"risk_flags": ["no_recent_interactions"],
|
||||
"recommended_next_actions": ["Schedule follow-up"],
|
||||
"note": "Derived read model. Not primary truth."
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try JSONDecoder().decode(
|
||||
VelocityClient360DTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(snapshot.identity.fullName, "Amina Rahman")
|
||||
XCTAssertEqual(snapshot.currentLead?.status, "qualified")
|
||||
XCTAssertEqual(snapshot.activeOpportunities.first?.formattedValue, "AED 12.0M")
|
||||
XCTAssertEqual(snapshot.qdOverview["intent_score"]?.displayScore, 94)
|
||||
XCTAssertEqual(snapshot.tasks.first?.title, "Call back")
|
||||
}
|
||||
|
||||
func testClient360DecodesLiveJsonStringArrayFields() throws {
|
||||
let payload = """
|
||||
{
|
||||
"client_ref": "person-live",
|
||||
"snapshot_type": "client_360",
|
||||
"identity": {
|
||||
"person_id": "person-live",
|
||||
"full_name": "Sanjay Chatterjee",
|
||||
"primary_email": "sanjay@example.com",
|
||||
"primary_phone": "+91-88479-41519",
|
||||
"buyer_type": null,
|
||||
"persona_labels": "[\\"repeat_visitor\\"]",
|
||||
"source_confidence": 0.94
|
||||
},
|
||||
"current_lead": {
|
||||
"lead_id": "lead-live",
|
||||
"status": "new",
|
||||
"budget_band": "15-25 Cr",
|
||||
"urgency": "6_months",
|
||||
"financing_posture": null,
|
||||
"timeline_to_decision": null,
|
||||
"objections": "[]",
|
||||
"motivations": "[\\"family_discussion\\"]"
|
||||
},
|
||||
"active_opportunities": [],
|
||||
"recent_interactions": [],
|
||||
"property_interests": [],
|
||||
"tasks": [],
|
||||
"qd_overview": {
|
||||
"urgency": {
|
||||
"score_type": "urgency",
|
||||
"current_value": 1.0,
|
||||
"computed_at": "2026-04-18T16:47:12.740450+00:00",
|
||||
"reasoning": null
|
||||
}
|
||||
},
|
||||
"risk_flags": ["no_property_interests_recorded"],
|
||||
"recommended_next_actions": [],
|
||||
"note": "Derived read model. Not primary truth."
|
||||
}
|
||||
"""
|
||||
|
||||
let snapshot = try JSONDecoder().decode(
|
||||
VelocityClient360DTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(snapshot.identity.personaLabels, ["repeat_visitor"])
|
||||
XCTAssertEqual(snapshot.currentLead?.objections, [])
|
||||
XCTAssertEqual(snapshot.currentLead?.motivations, ["family_discussion"])
|
||||
XCTAssertEqual(snapshot.qdOverview["urgency"]?.displayScore, 100)
|
||||
}
|
||||
|
||||
func testOpportunitiesClientSortsHigherValueRowsFirst() {
|
||||
let opportunities = [
|
||||
VelocityOpportunityDTO(
|
||||
opportunityId: "opp-2",
|
||||
stage: "proposal",
|
||||
value: 8000000,
|
||||
probability: 0.6,
|
||||
expectedCloseDate: nil,
|
||||
nextAction: "Send brochure",
|
||||
notes: nil,
|
||||
projectId: nil,
|
||||
unitId: nil,
|
||||
personId: "person-2",
|
||||
clientName: "Zara Khan",
|
||||
clientPhone: "+971500000002",
|
||||
projectName: "Skyline"
|
||||
),
|
||||
VelocityOpportunityDTO(
|
||||
opportunityId: "opp-1",
|
||||
stage: "negotiation",
|
||||
value: 12000000,
|
||||
probability: 0.8,
|
||||
expectedCloseDate: nil,
|
||||
nextAction: "Share proposal",
|
||||
notes: nil,
|
||||
projectId: nil,
|
||||
unitId: nil,
|
||||
personId: "person-1",
|
||||
clientName: "Amina Rahman",
|
||||
clientPhone: "+971500000001",
|
||||
projectName: "Marina Residences"
|
||||
),
|
||||
].sorted { lhs, rhs in
|
||||
let leftValue = lhs.value ?? 0
|
||||
let rightValue = rhs.value ?? 0
|
||||
if leftValue != rightValue {
|
||||
return leftValue > rightValue
|
||||
}
|
||||
return (lhs.clientName ?? "").localizedCaseInsensitiveCompare(rhs.clientName ?? "") == .orderedAscending
|
||||
}
|
||||
|
||||
XCTAssertEqual(opportunities.map(\.opportunityId), ["opp-1", "opp-2"])
|
||||
XCTAssertEqual(opportunities[0].formattedValue, "AED 12.0M")
|
||||
XCTAssertEqual(opportunities[0].probabilityLabel, "80% probability")
|
||||
}
|
||||
|
||||
func testOpportunityMutationResponseDecodesCanonicalShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"opportunity_id": "opp-1",
|
||||
"stage": "negotiation",
|
||||
"value": 12000000,
|
||||
"probability": 75,
|
||||
"expected_close_date": "2026-04-30",
|
||||
"next_action": "Schedule commercial review",
|
||||
"notes": "Updated from iPad",
|
||||
"person_id": "person-1",
|
||||
"client_name": "Amina Rahman",
|
||||
"client_phone": "+971500000001",
|
||||
"project_name": "Marina Residences"
|
||||
}
|
||||
"""
|
||||
|
||||
let opportunity = try JSONDecoder().decode(
|
||||
VelocityOpportunityDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(opportunity.opportunityId, "opp-1")
|
||||
XCTAssertEqual(opportunity.stage, "negotiation")
|
||||
XCTAssertEqual(opportunity.probabilityPercent, 75)
|
||||
XCTAssertEqual(opportunity.probabilityLabel, "75% probability")
|
||||
XCTAssertEqual(opportunity.nextAction, "Schedule commercial review")
|
||||
XCTAssertEqual(opportunity.notes, "Updated from iPad")
|
||||
}
|
||||
|
||||
func testLeadStageMutationResponseDecodesCanonicalShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"lead_id": "lead-1",
|
||||
"person_id": "person-1",
|
||||
"status": "qualified",
|
||||
"budget_band": "AED 12M",
|
||||
"urgency": "high",
|
||||
"client_name": "Amina Rahman",
|
||||
"client_phone": "+971500000001"
|
||||
}
|
||||
"""
|
||||
|
||||
let update = try JSONDecoder().decode(
|
||||
VelocityLeadStageUpdateDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(update.leadId, "lead-1")
|
||||
XCTAssertEqual(update.personId, "person-1")
|
||||
XCTAssertEqual(update.status, "qualified")
|
||||
XCTAssertEqual(update.clientName, "Amina Rahman")
|
||||
}
|
||||
|
||||
func testImportBatchDetailDecodesCanonicalReviewShape() throws {
|
||||
let payload = """
|
||||
{
|
||||
"batch_id": "batch-1",
|
||||
"source_system": "csv_upload",
|
||||
"filename": "clients.csv",
|
||||
"row_count": 1,
|
||||
"mapping_manifest": {"mapped_count": 4},
|
||||
"lifecycle": "parsed",
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_id": "proposal-1",
|
||||
"payload": {
|
||||
"row_number": 1,
|
||||
"canonical_payload": {"full_name": "Amina Rahman", "primary_phone": "+971500000001"},
|
||||
"raw_row": {"Name": "Amina Rahman"},
|
||||
"unresolved_fields": [],
|
||||
"missing_required": [],
|
||||
"confidence": 0.92,
|
||||
"review_required": true
|
||||
},
|
||||
"confidence": 0.92,
|
||||
"status": "pending",
|
||||
"review_required": true
|
||||
}
|
||||
],
|
||||
"proposal_count": 1
|
||||
}
|
||||
"""
|
||||
|
||||
let detail = try JSONDecoder().decode(
|
||||
VelocityImportBatchDetailDTO.self,
|
||||
from: Data(payload.utf8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(detail.batchId, "batch-1")
|
||||
XCTAssertEqual(detail.proposals.count, 1)
|
||||
XCTAssertEqual(detail.proposals[0].confidencePercent, 92)
|
||||
XCTAssertEqual(detail.proposals[0].payload?.canonicalPayload?["full_name"]?.stringValue, "Amina Rahman")
|
||||
}
|
||||
|
||||
func testInventoryProductionScopeShowsDollhouseWhenAssetIsShipped() {
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: true),
|
||||
[
|
||||
.sunseeker,
|
||||
.dreamWeaver,
|
||||
.dollhouse,
|
||||
]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: true),
|
||||
"Sunseeker · Dream Weaver · Dollhouse"
|
||||
)
|
||||
}
|
||||
|
||||
func testInventoryProductionScopeSanitizesUnsupportedSelection() {
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.sanitizedProductionSelection(.dollhouse, hasDollhouseAsset: false),
|
||||
.sunseeker
|
||||
)
|
||||
XCTAssertEqual(
|
||||
InventoryModeAvailability.sanitizedProductionSelection(.dollhouse, hasDollhouseAsset: true),
|
||||
.dollhouse
|
||||
)
|
||||
}
|
||||
|
||||
func testSentinelScopeReflectsOperatorPosturePositioning() {
|
||||
XCTAssertEqual(SentinelScope.navigationTitle, "Operator Posture")
|
||||
XCTAssertEqual(SentinelScope.productFamilyName, "Sentinel")
|
||||
XCTAssertEqual(SentinelScope.availabilityBadge, "Operator posture only")
|
||||
XCTAssertEqual(
|
||||
SentinelScope.disabledAnalyticsSummary,
|
||||
"visitor counting, facial detections, sentiment scoring"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
SentinelScope.liveBackedSummary,
|
||||
"alert posture, transcription queue visibility, upcoming calendar pressure, recent operator timeline"
|
||||
)
|
||||
}
|
||||
|
||||
func testAppStoreRefreshPolicyMatchesWebOSInventorySlice() {
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.inventoryPropertyLimit, 100)
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.canonicalTaskLimit, 50)
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.leadTimelineHydrationLimit, 6)
|
||||
XCTAssertEqual(AppStoreRefreshPolicy.leadEventLimitPerLead, 4)
|
||||
}
|
||||
|
||||
func testAppStoreRefreshPolicyPrioritizesHighestScoreLeads() {
|
||||
let leads = [
|
||||
VelocityLeadDTO(
|
||||
id: "lead-1",
|
||||
personId: "person-1",
|
||||
name: "Lead One",
|
||||
phone: nil,
|
||||
source: "website",
|
||||
qualification: "POTENTIAL",
|
||||
score: 40,
|
||||
kanbanStatus: "New",
|
||||
budget: "",
|
||||
unitInterest: "",
|
||||
pendingTaskCount: 0,
|
||||
interactionCount: 0,
|
||||
createdAt: nil,
|
||||
updatedAt: nil
|
||||
),
|
||||
VelocityLeadDTO(
|
||||
id: "lead-2",
|
||||
personId: "person-2",
|
||||
name: "Lead Two",
|
||||
phone: nil,
|
||||
source: "website",
|
||||
qualification: "HOT",
|
||||
score: 95,
|
||||
kanbanStatus: "Negotiation",
|
||||
budget: "",
|
||||
unitInterest: "",
|
||||
pendingTaskCount: 1,
|
||||
interactionCount: 3,
|
||||
createdAt: nil,
|
||||
updatedAt: nil
|
||||
),
|
||||
VelocityLeadDTO(
|
||||
id: "lead-3",
|
||||
personId: "person-3",
|
||||
name: "Lead Three",
|
||||
phone: nil,
|
||||
source: "walkin",
|
||||
qualification: "WHALE",
|
||||
score: 88,
|
||||
kanbanStatus: "Qualified",
|
||||
budget: "",
|
||||
unitInterest: "",
|
||||
pendingTaskCount: 0,
|
||||
interactionCount: 1,
|
||||
createdAt: nil,
|
||||
updatedAt: nil
|
||||
),
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
AppStoreRefreshPolicy.prioritizedLeadIDs(from: leads, limit: 2),
|
||||
["lead-2", "lead-3"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VelocityApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Central app configuration.
|
||||
/// To override without touching source, add a `Config.xcconfig` (gitignored):
|
||||
/// BASE_URL = http://192.168.x.x:8080
|
||||
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
|
||||
}
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static let baseURL: String = value(for: "BASE_URL") ?? "https://api.desineuron.in"
|
||||
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")
|
||||
|
||||
static var isLiveConfigured: Bool {
|
||||
apiBearerToken != nil || (apiEmail != nil && apiPassword != nil)
|
||||
}
|
||||
|
||||
static var authModeDescription: String {
|
||||
if apiBearerToken != nil {
|
||||
return "Bearer token"
|
||||
}
|
||||
if apiEmail != nil && apiPassword != nil {
|
||||
return "Email/password"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum JSONValue: Decodable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(value)
|
||||
} else if let value = try? container.decode([JSONValue].self) {
|
||||
self = .array(value)
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value.")
|
||||
}
|
||||
}
|
||||
|
||||
var stringValue: String? {
|
||||
switch self {
|
||||
case .string(let value):
|
||||
return value
|
||||
case .number(let value):
|
||||
return String(value)
|
||||
case .bool(let value):
|
||||
return value ? "true" : "false"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 VelocityPropertyDTO: Decodable, Identifiable {
|
||||
let propertyId: String
|
||||
let projectName: String
|
||||
let developerName: String
|
||||
let propertyType: String
|
||||
let location: [String: JSONValue]?
|
||||
let priceBands: [[String: JSONValue]]
|
||||
let unitMix: [[String: JSONValue]]
|
||||
let status: String
|
||||
let ingestedAt: String?
|
||||
let createdAt: String?
|
||||
|
||||
var id: String { propertyId }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case propertyId = "property_id"
|
||||
case projectName = "project_name"
|
||||
case developerName = "developer_name"
|
||||
case propertyType = "property_type"
|
||||
case location
|
||||
case priceBands = "price_bands"
|
||||
case unitMix = "unit_mix"
|
||||
case status
|
||||
case ingestedAt = "ingested_at"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
var locationSummary: String {
|
||||
let city = location?["city"]?.stringValue
|
||||
let district = location?["district"]?.stringValue
|
||||
if let city, let district {
|
||||
return "\(district), \(city)"
|
||||
}
|
||||
return city ?? district ?? "Location pending"
|
||||
}
|
||||
}
|
||||
|
||||
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 struct PropertiesEnvelope: Decodable {
|
||||
let properties: [VelocityPropertyDTO]
|
||||
}
|
||||
|
||||
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 fetchProperties(limit: Int = 25) async throws -> [VelocityPropertyDTO] {
|
||||
let request = try await authorizedRequest(
|
||||
path: "/api/inventory/properties",
|
||||
queryItems: [URLQueryItem(name: "limit", value: String(limit))]
|
||||
)
|
||||
let response: PropertiesEnvelope = try await perform(request)
|
||||
return response.properties
|
||||
}
|
||||
|
||||
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?
|
||||
}
|
||||
|
||||
private let velocityDateFormatter = ISO8601DateFormatter()
|
||||
|
||||
extension VelocityCommunicationEventDTO {
|
||||
var timestampDate: Date? {
|
||||
velocityDateFormatter.date(from: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startDate: Date? {
|
||||
velocityDateFormatter.date(from: startAt)
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct DashboardMetrics {
|
||||
let leadCount: Int
|
||||
let whaleLeadCount: Int
|
||||
let propertyCount: Int
|
||||
let todayCalendarCount: Int
|
||||
let pendingInsights: Int
|
||||
let pendingTranscriptions: Int
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AppStore {
|
||||
static let shared = AppStore()
|
||||
|
||||
private init() {}
|
||||
|
||||
var leads: [VelocityLeadDTO] = []
|
||||
var properties: [VelocityPropertyDTO] = []
|
||||
var calendarEvents: [VelocityCalendarEventDTO] = []
|
||||
var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
var alertSnapshot: VelocityAlertSnapshotDTO?
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastRefreshAt: Date?
|
||||
|
||||
var operatorIdentity: String {
|
||||
if let email = AppConfig.apiEmail, !email.isEmpty {
|
||||
return email
|
||||
}
|
||||
if let token = AppConfig.apiBearerToken, !token.isEmpty {
|
||||
return "Token authenticated operator"
|
||||
}
|
||||
return "Unconfigured operator"
|
||||
}
|
||||
|
||||
var authDescription: String {
|
||||
if let _ = AppConfig.apiBearerToken {
|
||||
return "Bearer token"
|
||||
}
|
||||
if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil {
|
||||
return "Email/password login"
|
||||
}
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
AppConfig.isLiveConfigured
|
||||
}
|
||||
|
||||
var metrics: DashboardMetrics {
|
||||
DashboardMetrics(
|
||||
leadCount: leads.count,
|
||||
whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count,
|
||||
propertyCount: properties.count,
|
||||
todayCalendarCount: calendarEvents.filter { $0.startsToday }.count,
|
||||
pendingInsights: alertSnapshot?.pendingInsights ?? 0,
|
||||
pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
var highlightedLeads: [VelocityLeadDTO] {
|
||||
Array(leads.sorted(by: { $0.score > $1.score }).prefix(5))
|
||||
}
|
||||
|
||||
var timelineEvents: [TimelineEvent] {
|
||||
leadEvents
|
||||
.flatMap { leadId, events in
|
||||
events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) }
|
||||
}
|
||||
.sorted(by: { $0.date > $1.date })
|
||||
}
|
||||
|
||||
func refresh(silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
}
|
||||
|
||||
do {
|
||||
async let leadsTask = VelocityAPIClient.shared.fetchLeads()
|
||||
async let propertiesTask = VelocityAPIClient.shared.fetchProperties()
|
||||
async let calendarTask = VelocityAPIClient.shared.fetchCalendarEvents()
|
||||
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
|
||||
|
||||
let fetchedLeads = try await leadsTask
|
||||
let fetchedProperties = try await propertiesTask
|
||||
let fetchedCalendar = try await calendarTask
|
||||
let fetchedAlerts = try await alertsTask
|
||||
|
||||
let leadFocus = Array(fetchedLeads.sorted(by: { $0.score > $1.score }).prefix(6))
|
||||
var eventMap: [String: [VelocityCommunicationEventDTO]] = [:]
|
||||
for lead in leadFocus {
|
||||
let events = try await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 4)
|
||||
eventMap[lead.id] = events
|
||||
}
|
||||
|
||||
leads = fetchedLeads
|
||||
properties = fetchedProperties
|
||||
calendarEvents = fetchedCalendar
|
||||
alertSnapshot = fetchedAlerts
|
||||
leadEvents = eventMap
|
||||
lastRefreshAt = Date()
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if !silent {
|
||||
leads = []
|
||||
properties = []
|
||||
calendarEvents = []
|
||||
alertSnapshot = nil
|
||||
leadEvents = [:]
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
func leadName(for leadId: String) -> String {
|
||||
leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead"
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelineEvent: Identifiable {
|
||||
let leadId: String
|
||||
let event: VelocityCommunicationEventDTO
|
||||
let leadName: String
|
||||
|
||||
var id: String { event.id }
|
||||
var date: Date { event.timestampDate ?? .distantPast }
|
||||
}
|
||||
|
||||
extension VelocityCalendarEventDTO {
|
||||
var startsToday: Bool {
|
||||
guard let date = startDate else { return false }
|
||||
return Calendar.current.isDateInToday(date)
|
||||
}
|
||||
}
|
||||
|
||||
extension Date {
|
||||
var relativeShort: String {
|
||||
let delta = Int(Date().timeIntervalSince(self))
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GlassBlurView: UIViewRepresentable {
|
||||
let style: UIBlurEffect.Style
|
||||
|
||||
init(style: UIBlurEffect.Style = .systemUltraThinMaterial) {
|
||||
self.style = style
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> UIVisualEffectView {
|
||||
UIVisualEffectView(effect: UIBlurEffect(style: style))
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
|
||||
uiView.effect = UIBlurEffect(style: style)
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Design Tokens matching the WebOS dark interface
|
||||
enum VelocityTheme {
|
||||
|
||||
// ── Backgrounds ──────────────────────────────────
|
||||
/// True black app background
|
||||
static let background = Color(red: 0.00, green: 0.00, blue: 0.00)
|
||||
/// Dark surface (#131418)
|
||||
static let surface = Color(red: 0.074, green: 0.078, blue: 0.094)
|
||||
/// Slightly lighter surface (#181b20)
|
||||
static let surface2 = Color(red: 0.095, green: 0.106, blue: 0.125)
|
||||
/// Card surface (#22262e)
|
||||
static let surface3 = Color(red: 0.133, green: 0.149, blue: 0.180)
|
||||
/// Sidebar background (#0B0D10)
|
||||
static let sidebarBg = Color(red: 0.043, green: 0.051, blue: 0.063)
|
||||
|
||||
// ── Foreground ────────────────────────────────────
|
||||
static let foreground = Color(white: 0.96)
|
||||
static let mutedFg = Color(red: 0.580, green: 0.620, blue: 0.710)
|
||||
static let subtleFg = Color(red: 0.35, green: 0.38, blue: 0.44)
|
||||
|
||||
// ── Accent: Blue (#3b82f6) ────────────────────────
|
||||
static let accent = Color(red: 0.231, green: 0.510, blue: 0.965)
|
||||
static let accentDim = Color(red: 0.160, green: 0.388, blue: 0.820)
|
||||
static let accentSubtle = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.15)
|
||||
|
||||
// ── Semantic ──────────────────────────────────────
|
||||
static let success = Color(red: 0.290, green: 0.780, blue: 0.290)
|
||||
static let warning = Color(red: 0.980, green: 0.745, blue: 0.141)
|
||||
static let danger = Color(red: 0.973, green: 0.267, blue: 0.267)
|
||||
|
||||
// ── Borders ───────────────────────────────────────
|
||||
static let borderSubtle = Color.white.opacity(0.07)
|
||||
static let borderAccent = Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.18)
|
||||
}
|
||||
|
||||
// MARK: - Glass card modifier
|
||||
struct GlassCard: ViewModifier {
|
||||
var cornerRadius: CGFloat = 16
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071).opacity(0.82))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.55), radius: 16, y: 4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func glassCard(cornerRadius: CGFloat = 16) -> some View {
|
||||
self.modifier(GlassCard(cornerRadius: cornerRadius))
|
||||
}
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
enum OracleMode: String, CaseIterable {
|
||||
case pipeline = "Pipeline"
|
||||
case accountTimeline = "Account Timeline"
|
||||
case calendarTasks = "Calendar & Tasks"
|
||||
case teamPerformance = "Team Performance"
|
||||
case leadMap = "Lead Map"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .pipeline:
|
||||
return "square.grid.3x1.below.line.grid.1x2"
|
||||
case .accountTimeline:
|
||||
return "clock.arrow.circlepath"
|
||||
case .calendarTasks:
|
||||
return "calendar"
|
||||
case .teamPerformance:
|
||||
return "person.3"
|
||||
case .leadMap:
|
||||
return "map"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct OracleView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var selectedMode: OracleMode = .pipeline
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 24)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
modePicker
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 14)
|
||||
|
||||
if let error = store.errorMessage {
|
||||
errorBanner(error)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 14)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
canvas
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Oracle")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live sales intelligence assembled from leads, communication events, and calendar data.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
private var modePicker: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(OracleMode.allCases, id: \.self) { mode in
|
||||
Button {
|
||||
selectedMode = mode
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: mode.icon)
|
||||
Text(mode.rawValue)
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(selectedMode == mode ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(selectedMode == mode ? VelocityTheme.accent.opacity(0.16) : VelocityTheme.surface)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(selectedMode == mode ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var canvas: some View {
|
||||
switch selectedMode {
|
||||
case .pipeline:
|
||||
pipelineCanvas
|
||||
case .accountTimeline:
|
||||
timelineCanvas
|
||||
case .calendarTasks:
|
||||
calendarCanvas
|
||||
case .teamPerformance:
|
||||
unavailableCanvas(
|
||||
title: "Broker performance feed unavailable",
|
||||
message: "The current mobile contract does not expose broker-attributed performance rollups yet, so Oracle avoids inventing team metrics here."
|
||||
)
|
||||
case .leadMap:
|
||||
unavailableCanvas(
|
||||
title: "Lead map route unavailable",
|
||||
message: "No production geography route exists for mobile Oracle yet. This view stays disabled until a real geo-backed endpoint is added."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var pipelineCanvas: some View {
|
||||
let grouped = Dictionary(grouping: store.leads, by: { $0.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized })
|
||||
let stages = grouped.keys.sorted()
|
||||
|
||||
return VStack(alignment: .leading, spacing: 16) {
|
||||
summaryCard(
|
||||
title: "Pipeline Summary",
|
||||
body: "This view groups live CRM leads by current kanban status. Whale leads and high-score opportunities float to the top of each lane."
|
||||
)
|
||||
|
||||
if stages.isEmpty {
|
||||
emptyCard("No live pipeline rows are available yet.")
|
||||
} else {
|
||||
ForEach(stages, id: \.self) { stage in
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(stage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
ForEach((grouped[stage] ?? []).sorted(by: { $0.score > $1.score })) { lead in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(lead.name)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(lead.score)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text("\(lead.qualification.capitalized) · \(lead.unitInterest)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(lead.budget)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var timelineCanvas: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
summaryCard(
|
||||
title: "Account Timeline",
|
||||
body: "Recent communication events are pulled from the mobile-edge event stream for the highest-priority leads."
|
||||
)
|
||||
|
||||
if store.timelineEvents.isEmpty {
|
||||
emptyCard("No live communication events were returned for the current lead set.")
|
||||
} else {
|
||||
ForEach(store.timelineEvents.prefix(10)) { item in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(item.leadName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text(item.event.summary ?? "No summary available.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var calendarCanvas: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
summaryCard(
|
||||
title: "Calendar & Tasks",
|
||||
body: "Confirmed operator calendar events from the live backend appear here without any synthesized task filler."
|
||||
)
|
||||
|
||||
if store.calendarEvents.isEmpty {
|
||||
emptyCard("No live calendar events are scheduled yet for this operator.")
|
||||
} else {
|
||||
ForEach(store.calendarEvents.prefix(10)) { event in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(event.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(event.status.capitalized)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(color(for: event.status))
|
||||
}
|
||||
Text(formattedDateRange(event))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(event.location ?? "No location")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unavailableCanvas(title: String, message: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
summaryCard(title: title, body: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func summaryCard(title: String, body: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(body)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func emptyCard(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func color(for status: String) -> Color {
|
||||
switch status.lowercased() {
|
||||
case "confirmed":
|
||||
return VelocityTheme.success
|
||||
case "tentative":
|
||||
return VelocityTheme.warning
|
||||
default:
|
||||
return VelocityTheme.accent
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedDateRange(_ event: VelocityCalendarEventDTO) -> String {
|
||||
guard let start = event.startDate else { return event.startAt }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEE, MMM d · h:mm a"
|
||||
return formatter.string(from: start)
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OracleView()
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live runtime configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Connectivity") {
|
||||
SettingsRow(
|
||||
label: "Backend endpoint",
|
||||
value: AppConfig.baseURL,
|
||||
icon: "server.rack",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Auth mode",
|
||||
value: AppConfig.authModeDescription,
|
||||
icon: "lock.shield",
|
||||
accentColor: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Last refresh",
|
||||
value: store.lastRefreshAt?.relativeShort ?? "No live fetch yet",
|
||||
icon: "arrow.clockwise",
|
||||
accentColor: VelocityTheme.mutedFg
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Operator") {
|
||||
SettingsRow(
|
||||
label: "Identity",
|
||||
value: store.operatorIdentity,
|
||||
icon: "person.crop.circle",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Lead records loaded",
|
||||
value: "\(store.leads.count)",
|
||||
icon: "person.3",
|
||||
accentColor: VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Property records loaded",
|
||||
value: "\(store.properties.count)",
|
||||
icon: "building.2",
|
||||
accentColor: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Production Notes") {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("This build avoids local demo data. If credentials are missing or a route is unavailable, the surface reports that state instead of fabricating operator metrics.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sentinel visitor analytics remain disabled on iPad until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsSection<Content: View>: View {
|
||||
let title: String
|
||||
@ViewBuilder let content: Content
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
content
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user