feat: Built the native SwiftUI app shell mirroring the WebOS interface (Dashboard, Inventory, Oracle tabs) (#2)
I have attached the screenshots of the native SwiftUI app. <img width="1705" alt="image.png" src="attachments/59fec2f3-0ae2-4b58-9349-457618ea0678"> <img width="1699" alt="image.png" src="attachments/0bf7c4f9-c883-4929-be36-774685b82fc4"> <img width="1698" alt="image.png" src="attachments/e3407e84-aaf2-45c0-9325-247d4020bace"> <img width="1694" alt="image.png" src="attachments/ee2cd47d-800d-4a40-855c-d54856680e79"> <img width="1694" alt="image.png" src="attachments/a2c902f1-9bc9-4427-8cae-b5801527c1ff"> Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #2 Co-authored-by: sayan <sayan@desineuron.in> Co-committed-by: sayan <sayan@desineuron.in>
This commit was merged in pull request #2.
This commit is contained in:
173
iOS/velocity/velocity/App/ContentView.swift
Normal file
173
iOS/velocity/velocity/App/ContentView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
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 .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 .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()
|
||||
}
|
||||
11
iOS/velocity/velocity/App/VelocityApp.swift
Normal file
11
iOS/velocity/velocity/App/VelocityApp.swift
Normal file
@@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VelocityApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
iOS/velocity/velocity/Assets.xcassets/Contents.json
Normal file
6
iOS/velocity/velocity/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
92
iOS/velocity/velocity/Core/Math/SunMath.swift
Normal file
92
iOS/velocity/velocity/Core/Math/SunMath.swift
Normal file
@@ -0,0 +1,92 @@
|
||||
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 }
|
||||
}
|
||||
100
iOS/velocity/velocity/Core/Networking/ComfyClient.swift
Normal file
100
iOS/velocity/velocity/Core/Networking/ComfyClient.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
256
iOS/velocity/velocity/Core/State/AppStore.swift
Normal file
256
iOS/velocity/velocity/Core/State/AppStore.swift
Normal file
@@ -0,0 +1,256 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
17
iOS/velocity/velocity/Core/UI/GlassBlurView.swift
Normal file
17
iOS/velocity/velocity/Core/UI/GlassBlurView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
60
iOS/velocity/velocity/Core/UI/VelocityTheme.swift
Normal file
60
iOS/velocity/velocity/Core/UI/VelocityTheme.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
442
iOS/velocity/velocity/Features/Dashboard/DashboardView.swift
Normal file
442
iOS/velocity/velocity/Features/Dashboard/DashboardView.swift
Normal file
@@ -0,0 +1,442 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
iOS/velocity/velocity/Features/Inventory/ARSunOverlayView.swift
Normal file
118
iOS/velocity/velocity/Features/Inventory/ARSunOverlayView.swift
Normal file
@@ -0,0 +1,118 @@
|
||||
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 }
|
||||
}
|
||||
439
iOS/velocity/velocity/Features/Inventory/InventoryView.swift
Normal file
439
iOS/velocity/velocity/Features/Inventory/InventoryView.swift
Normal file
@@ -0,0 +1,439 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
960
iOS/velocity/velocity/Features/Oracle/OracleView.swift
Normal file
960
iOS/velocity/velocity/Features/Oracle/OracleView.swift
Normal file
@@ -0,0 +1,960 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
413
iOS/velocity/velocity/Features/Sentinel/SentinelView.swift
Normal file
413
iOS/velocity/velocity/Features/Sentinel/SentinelView.swift
Normal file
@@ -0,0 +1,413 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
141
iOS/velocity/velocity/Features/Settings/SettingsView.swift
Normal file
141
iOS/velocity/velocity/Features/Settings/SettingsView.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user