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:
@@ -1,19 +1,30 @@
|
||||
import SwiftUI
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable {
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
case settings = "Settings"
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .oracle: return "message"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,29 +33,141 @@ struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List(AppSection.allCases, selection: $selectedSection) { section in
|
||||
Label(section.rawValue, systemImage: section.systemImage)
|
||||
.tag(section)
|
||||
}
|
||||
.navigationTitle("Velocity")
|
||||
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 .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ struct VelocityApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import Alamofire
|
||||
@preconcurrency import Alamofire
|
||||
|
||||
final class ComfyClient {
|
||||
static let shared = ComfyClient()
|
||||
@@ -45,12 +45,12 @@ final class ComfyClient {
|
||||
}
|
||||
}
|
||||
|
||||
private struct DreamWeaverRequest: Encodable {
|
||||
private struct DreamWeaverRequest: Encodable, Sendable {
|
||||
let imageBase64: String
|
||||
let prompt: String
|
||||
}
|
||||
|
||||
private struct DreamWeaverResponse: Decodable {
|
||||
private struct DreamWeaverResponse: Decodable, Sendable {
|
||||
let outputBase64: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
||||
256
iOS/Core/State/AppStore.swift
Normal file
256
iOS/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"
|
||||
}
|
||||
}
|
||||
60
iOS/Core/UI/VelocityTheme.swift
Normal file
60
iOS/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))
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,442 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DashboardView: View {
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 16)]
|
||||
private var store: AppStore { AppStore.shared }
|
||||
@State private var chatInput = ""
|
||||
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
WidgetCard(title: "Listings", value: "128", subtitle: "Active units")
|
||||
WidgetCard(title: "Revenue", value: "$3.2M", subtitle: "30-day forecast")
|
||||
WidgetCard(title: "AI Jobs", value: "24", subtitle: "Queue depth")
|
||||
WidgetCard(title: "Visitors", value: "17", subtitle: "Today")
|
||||
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)
|
||||
}
|
||||
.navigationTitle("Dashboard")
|
||||
.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?"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WidgetCard: View {
|
||||
// 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: 8) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
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(.largeTitle.bold())
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.secondary)
|
||||
.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
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 140, alignment: .leading)
|
||||
.padding()
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,19 @@ struct InventoryView: View {
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .light)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
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)
|
||||
@@ -45,7 +57,34 @@ struct InventoryView: View {
|
||||
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,
|
||||
@@ -63,8 +102,18 @@ struct InventoryView: View {
|
||||
.padding(.bottom, 20)
|
||||
.animation(.easeInOut(duration: 0.25), value: store.mode)
|
||||
}
|
||||
.navigationTitle("Inventory")
|
||||
.background(Color(uiColor: .systemGroupedBackground))
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,960 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OracleView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "message.and.waveform")
|
||||
.font(.system(size: 48))
|
||||
Text("Oracle Chat")
|
||||
.font(.title2.bold())
|
||||
Text("Connect this view to your backend assistant pipeline.")
|
||||
.foregroundStyle(.secondary)
|
||||
// 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"
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.navigationTitle("Oracle")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline mock data (extended with detail fields)
|
||||
struct OracleLeadCard: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String
|
||||
let name: String
|
||||
let company: String
|
||||
let value: String
|
||||
let status: LeadStatus
|
||||
let phone: String
|
||||
let interest: String
|
||||
let qualification: String
|
||||
}
|
||||
|
||||
private let pipelineData: [(stage: String, cards: [OracleLeadCard])] = [
|
||||
("New", [
|
||||
OracleLeadCard(initials: "JW", name: "James Wilson", company: "Website", value: "AED 3.5M", status: .new,
|
||||
phone: "+971 52 456 7890", interest: "1BR Investment", qualification: "Potential"),
|
||||
]),
|
||||
("Qualified", [
|
||||
OracleLeadCard(initials: "FH", name: "Fatima Hassan", company: "WhatsApp", value: "AED 12M", status: .qualified,
|
||||
phone: "+971 54 321 0987", interest: "3BR + Maid", qualification: "Whale"),
|
||||
OracleLeadCard(initials: "SC", name: "Sarah Chen", company: "Walk-in", value: "AED 6.5M", status: .engaged,
|
||||
phone: "+971 50 987 6543", interest: "2BR Sea View", qualification: "Potential"),
|
||||
]),
|
||||
("Proposal", [
|
||||
OracleLeadCard(initials: "MA", name: "Mohammed Al-Rashid", company: "WhatsApp", value: "AED 15M+", status: .hot,
|
||||
phone: "+971 55 123 4567", interest: "Penthouse Suite", qualification: "Whale"),
|
||||
]),
|
||||
("Closed", [
|
||||
OracleLeadCard(initials: "DK", name: "David Kumar", company: "Walk-in", value: "AED 20M", status: .closed,
|
||||
phone: "+971 56 789 0123", interest: "Full Floor", qualification: "Whale"),
|
||||
]),
|
||||
]
|
||||
|
||||
struct TeamMemberData: Identifiable {
|
||||
let id = UUID()
|
||||
let initials: String; let name: String; let deals: Int; let revenue: String; let trend: String
|
||||
}
|
||||
private let teamData: [TeamMemberData] = [
|
||||
.init(initials: "RA", name: "Rania Al-Farsi", deals: 42, revenue: "$2.1M", trend: "↑ 18%"),
|
||||
.init(initials: "KM", name: "Khaled Mensah", deals: 31, revenue: "$1.6M", trend: "↑ 12%"),
|
||||
.init(initials: "LT", name: "Lina Torres", deals: 28, revenue: "$1.3M", trend: "→ 2%"),
|
||||
.init(initials: "AH", name: "Ahmed Hassan", deals: 19, revenue: "$0.9M", trend: "↓ 5%"),
|
||||
]
|
||||
|
||||
struct OracleTimelineEvent: Identifiable {
|
||||
let id = UUID()
|
||||
let badge: String; let summary: String; let when: String; let detail: String
|
||||
}
|
||||
private let timelineEvents: [OracleTimelineEvent] = [
|
||||
.init(badge: "MEETING", summary: "VR Amenity Tour – Apex Innovations", when: "2h ago",
|
||||
detail: "CFO and Legal Director attended. Strong reaction to the panoramic sea view suite. Follow-up proposal requested by Tuesday."),
|
||||
.init(badge: "EMAIL", summary: "Proposal deck sent to legal team", when: "Yesterday",
|
||||
detail: "58-page proposal + payment plan schedule sent via DocuSign. Legal team has 5 business days to review."),
|
||||
.init(badge: "CALL", summary: "Budget discussion – CFO confirmed", when: "Mon",
|
||||
detail: "Budget ceiling confirmed at AED 15M+. CFO expressed strong preference for a penthouse unit with private terrace."),
|
||||
.init(badge: "VISIT", summary: "Site walkthrough – Penthouse Suite", when: "Last week",
|
||||
detail: "First in-person visit. 45-minute walkthrough of Penthouse A & B. Visitor dwell time analysis showed 'excited' sentiment for 90% of the visit."),
|
||||
]
|
||||
|
||||
struct RegionPin: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String; let country: String; let count: Int; let temp: String; let topLead: String
|
||||
}
|
||||
private let mapPins: [RegionPin] = [
|
||||
.init(label: "UAE", country: "🇦🇪", count: 8, temp: "hot", topLead: "Mohammed Al-Rashid"),
|
||||
.init(label: "Saudi Arabia", country: "🇸🇦", count: 5, temp: "warm", topLead: "Al-Mansour Group"),
|
||||
.init(label: "UK", country: "🇬🇧", count: 3, temp: "cold", topLead: "Rexford Capital"),
|
||||
.init(label: "USA", country: "🇺🇸", count: 4, temp: "warm", topLead: "Apex Innovations"),
|
||||
.init(label: "India", country: "🇮🇳", count: 6, temp: "hot", topLead: "Starlight Systems"),
|
||||
.init(label: "Germany", country: "🇩🇪", count: 2, temp: "cold", topLead: "TechWave GmbH"),
|
||||
]
|
||||
|
||||
struct CalTask: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String; let subtitle: String; let due: String
|
||||
}
|
||||
private let calTasks: [CalTask] = [
|
||||
.init(title: "Follow up with Mohammed", subtitle: "High-value penthouse lead – 2 unread messages", due: "Today 3 PM"),
|
||||
.init(title: "Send contract to Fatima", subtitle: "3BR unit finalised – payment plan to confirm", due: "Tomorrow 10 AM"),
|
||||
.init(title: "Schedule VR tour – James", subtitle: "Website lead, potential 1BR investor", due: "Thu 2 PM"),
|
||||
]
|
||||
|
||||
// MARK: – OracleView (main)
|
||||
struct OracleView: View {
|
||||
@State private var selectedMode: OracleMode = .pipeline
|
||||
@State private var prompt = "Show me a pipeline view by stage for Q4."
|
||||
@State private var insightText = "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
@State private var isSubmitting = false
|
||||
|
||||
// Sheet states
|
||||
@State private var selectedLead: OracleLeadCard? = nil
|
||||
@State private var selectedMember: TeamMemberData? = nil
|
||||
@State private var selectedRegion: RegionPin? = nil
|
||||
@State private var scheduledTask: CalTask? = nil
|
||||
@State private var showScheduleConfirm = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
pageHeader
|
||||
.padding(.horizontal, 24).padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
insightCard
|
||||
.padding(.horizontal, 24).padding(.bottom, 14)
|
||||
|
||||
ScrollView {
|
||||
canvasView
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
}
|
||||
|
||||
promptBar
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
|
||||
// Lead detail sheet
|
||||
.sheet(item: $selectedLead) { card in
|
||||
LeadDetailSheet(card: card)
|
||||
}
|
||||
// Team member sheet
|
||||
.sheet(item: $selectedMember) { member in
|
||||
MemberDetailSheet(member: member)
|
||||
}
|
||||
// Region callout sheet
|
||||
.sheet(item: $selectedRegion) { pin in
|
||||
RegionDetailSheet(pin: pin)
|
||||
}
|
||||
// Schedule confirmation alert
|
||||
.alert("Confirm Schedule",
|
||||
isPresented: $showScheduleConfirm,
|
||||
presenting: scheduledTask) { task in
|
||||
Button("Schedule") {
|
||||
// In a real app this would create a calendar event
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { task in
|
||||
Text("Add \"\(task.title)\" to your calendar for \(task.due)?")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sub-views
|
||||
private var pageHeader: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Oracle").font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("AI intelligence pipeline").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if isSubmitting {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var insightCard: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(LinearGradient(colors: [Color(red: 0.58, green: 0.77, blue: 0.99), VelocityTheme.accent],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 3)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("AI INSIGHT").font(.system(size: 9, weight: .semibold)).tracking(1.5)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text(insightText).font(.system(size: 13)).foregroundStyle(VelocityTheme.foreground).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.55))
|
||||
.overlay(RoundedRectangle(cornerRadius: 12).stroke(VelocityTheme.borderAccent, lineWidth: 1))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var canvasView: some View {
|
||||
switch selectedMode {
|
||||
case .pipeline:
|
||||
PipelineCanvas(onSelectLead: { selectedLead = $0 })
|
||||
case .teamPerformance:
|
||||
TeamPerformanceCanvas(onSelectMember: { selectedMember = $0 })
|
||||
case .accountTimeline:
|
||||
AccountTimelineCanvas()
|
||||
case .leadMap:
|
||||
LeadMapCanvas(onSelectRegion: { selectedRegion = $0 })
|
||||
case .calendarTasks:
|
||||
CalendarCanvas(onSchedule: { task in
|
||||
scheduledTask = task
|
||||
showScheduleConfirm = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Prompt Bar
|
||||
private var promptBar: some View {
|
||||
VStack(spacing: 0) {
|
||||
TextField("Ask Oracle anything…", text: $prompt)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.tint(VelocityTheme.accent)
|
||||
.onSubmit { submitPrompt() }
|
||||
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Menu {
|
||||
ForEach(OracleMode.allCases, id: \.self) { mode in
|
||||
Button {
|
||||
selectedMode = mode
|
||||
prompt = modeSamplePrompt(mode)
|
||||
insightText = oracleInsight(for: mode)
|
||||
} label: {
|
||||
Label(mode.rawValue, systemImage: mode.icon)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: selectedMode.icon).font(.system(size: 10))
|
||||
Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium))
|
||||
Image(systemName: "chevron.down").font(.system(size: 8))
|
||||
}
|
||||
.foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99))
|
||||
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.14))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.3), lineWidth: 1)))
|
||||
}
|
||||
Spacer()
|
||||
Button(action: submitPrompt) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(isSubmitting ? VelocityTheme.mutedFg : VelocityTheme.accent)
|
||||
.shadow(color: VelocityTheme.accent.opacity(0.5), radius: 8)
|
||||
if isSubmitting {
|
||||
ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(0.6)
|
||||
} else {
|
||||
Image(systemName: "paperplane.fill").font(.system(size: 12)).foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(width: 34, height: 34)
|
||||
}
|
||||
.disabled(isSubmitting || prompt.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12).padding(.bottom, 12)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color(red: 0.039, green: 0.043, blue: 0.063).opacity(0.95))
|
||||
.overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.white.opacity(0.11), lineWidth: 1))
|
||||
.shadow(color: .black.opacity(0.6), radius: 20, y: -4)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: – Prompt logic
|
||||
private func submitPrompt() {
|
||||
let clean = prompt.trimmingCharacters(in: .whitespaces)
|
||||
guard !clean.isEmpty && !isSubmitting else { return }
|
||||
isSubmitting = true
|
||||
let lower = clean.lowercased()
|
||||
if lower.contains("team") || lower.contains("performance") || lower.contains("sales") {
|
||||
selectedMode = .teamPerformance
|
||||
} else if lower.contains("account") || lower.contains("apex") || lower.contains("timeline") {
|
||||
selectedMode = .accountTimeline
|
||||
} else if lower.contains("map") || lower.contains("geographic") || lower.contains("location") {
|
||||
selectedMode = .leadMap
|
||||
} else if lower.contains("calendar") || lower.contains("schedule") || lower.contains("task") {
|
||||
selectedMode = .calendarTasks
|
||||
} else {
|
||||
selectedMode = .pipeline
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
insightText = oracleInsight(for: selectedMode)
|
||||
isSubmitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func modeSamplePrompt(_ mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Show me a pipeline view by stage for Q4."
|
||||
case .teamPerformance: return "What's the performance of the sales team this month?"
|
||||
case .accountTimeline: return "Find all contacts at Apex Innovations and their recent activity."
|
||||
case .leadMap: return "Give me a geographic map of all leads."
|
||||
case .calendarTasks: return "Schedule follow-ups with the top 3 high-value leads."
|
||||
}
|
||||
}
|
||||
|
||||
private func oracleInsight(for mode: OracleMode) -> String {
|
||||
switch mode {
|
||||
case .pipeline: return "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours."
|
||||
case .teamPerformance: return "Rania Al-Farsi leads the team at $2.1M closed. Overall quota attainment is at 87% — ahead of last month."
|
||||
case .accountTimeline: return "Apex Innovations has 4 active stakeholders. Confusion detected in legal stage — recommend expediting contract review."
|
||||
case .leadMap: return "UAE and India show the hottest lead concentration. 8 high-value prospects in UAE require immediate outreach."
|
||||
case .calendarTasks: return "3 high-priority follow-ups scheduled. Mohammed Al-Rashid has 2 unread messages — respond within 24h."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Pipeline Canvas
|
||||
private struct PipelineCanvas: View {
|
||||
let onSelectLead: (OracleLeadCard) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 200), spacing: 12)]
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: cols, alignment: .leading, spacing: 12) {
|
||||
ForEach(pipelineData, id: \.stage) { col in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(col.stage.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Text("\(col.cards.count)")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(Capsule().fill(VelocityTheme.accent.opacity(0.12))
|
||||
.overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1)))
|
||||
}
|
||||
ForEach(col.cards) { card in
|
||||
TappableLeadCard(card: card, onTap: { onSelectLead(card) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderSubtle, lineWidth: 1))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableLeadCard: View {
|
||||
let card: OracleLeadCard
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(card.status.color.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(card.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(card.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text(card.company).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(card.value).font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Image(systemName: "chevron.right").font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.10) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1))
|
||||
)
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Detail Sheet
|
||||
private struct LeadDetailSheet: View {
|
||||
let card: OracleLeadCard
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
// Avatar + name
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(card.status.color.opacity(0.20)).frame(width: 60, height: 60)
|
||||
Text(card.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(card.status.color)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(card.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Text(card.status.rawValue)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(card.status.color)
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(Capsule().fill(card.status.color.opacity(0.14)))
|
||||
Text(card.qualification).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Deal Value", value: card.value)
|
||||
DetailField(label: "Source", value: card.company)
|
||||
DetailField(label: "Interest", value: card.interest)
|
||||
DetailField(label: "Phone", value: card.phone)
|
||||
}
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
// Action buttons
|
||||
HStack(spacing: 12) {
|
||||
ActionChip(icon: "phone.fill", label: "Call", color: VelocityTheme.success)
|
||||
ActionChip(icon: "message.fill", label: "Message", color: VelocityTheme.accent)
|
||||
ActionChip(icon: "calendar.badge.plus", label: "Schedule", color: Color(red: 0.60, green: 0.57, blue: 0.99))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Lead Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DetailField: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(label.uppercased()).font(.system(size: 9, weight: .semibold)).tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.07), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct ActionChip: View {
|
||||
let icon: String; let label: String; let color: Color
|
||||
@State private var pressed = false
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon).font(.system(size: 12))
|
||||
Text(label).font(.system(size: 12, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(RoundedRectangle(cornerRadius: 10).fill(color.opacity(pressed ? 0.22 : 0.13))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.30), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.96 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture { pressed = true; DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { pressed = false } }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Performance Canvas
|
||||
private struct TeamPerformanceCanvas: View {
|
||||
let onSelectMember: (TeamMemberData) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
quotaPanel
|
||||
teamListPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var quotaPanel: some View {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle().stroke(Color.white.opacity(0.06), lineWidth: 10).frame(width: 110, height: 110)
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.87)
|
||||
.stroke(AngularGradient(colors: [VelocityTheme.accent, Color(red: 0.13, green: 0.83, blue: 0.93)],
|
||||
center: .center),
|
||||
style: StrokeStyle(lineWidth: 10, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.frame(width: 110, height: 110)
|
||||
VStack(spacing: 2) {
|
||||
Text("87%").font(.system(size: 26, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("QUOTA").font(.system(size: 8, weight: .semibold)).tracking(1.2).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Quota Attainment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Monthly target exceeded").font(.system(size: 11)).foregroundStyle(VelocityTheme.success)
|
||||
Text("Q4 FY2025–26").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var teamListPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("TEAM PERFORMANCE").font(.system(size: 10, weight: .semibold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 8)
|
||||
ForEach(teamData) { member in
|
||||
TappableTeamRow(member: member, onTap: { onSelectMember(member) })
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableTeamRow: View {
|
||||
let member: TeamMemberData
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 36, height: 36)
|
||||
Text(member.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(member.name).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(member.deals) deals closed").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(member.revenue).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(member.trend)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(member.trend.hasPrefix("↑") ? VelocityTheme.success :
|
||||
member.trend.hasPrefix("↓") ? VelocityTheme.danger : VelocityTheme.mutedFg)
|
||||
}
|
||||
Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? VelocityTheme.accent.opacity(0.08) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(pressed ? VelocityTheme.accent.opacity(0.25) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.98 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Team Member Detail Sheet
|
||||
private struct MemberDetailSheet: View {
|
||||
let member: TeamMemberData
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 60, height: 60)
|
||||
Text(member.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(member.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Sales Executive").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Revenue Closed", value: member.revenue)
|
||||
DetailField(label: "Deals Closed", value: "\(member.deals)")
|
||||
DetailField(label: "Trend", value: member.trend)
|
||||
DetailField(label: "Period", value: "Q4 FY2025–26")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Team Member")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Account Timeline Canvas
|
||||
private struct AccountTimelineCanvas: View {
|
||||
@State private var expandedId: UUID? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
// Account overview
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("ACCOUNT OVERVIEW").font(.system(size: 9, weight: .semibold)).tracking(1.5).foregroundStyle(VelocityTheme.accent)
|
||||
Text("Apex Innovations").font(.system(size: 24, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 14) {
|
||||
InfoMini(label: "Deal Value", value: "AED 15M+")
|
||||
InfoMini(label: "Primary Contact", value: "CEO – James T.")
|
||||
InfoMini(label: "Industry", value: "Technology")
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
|
||||
// Expandable timeline
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Activity Timeline").font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground).padding(.bottom, 16)
|
||||
ForEach(Array(timelineEvents.enumerated()), id: \.offset) { i, event in
|
||||
TimelineEventRow(event: event, isLast: i == timelineEvents.count - 1,
|
||||
isExpanded: expandedId == event.id) {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
expandedId = expandedId == event.id ? nil : event.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TimelineEventRow: View {
|
||||
let event: OracleTimelineEvent
|
||||
let isLast: Bool
|
||||
let isExpanded: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(spacing: 0) {
|
||||
Circle().fill(VelocityTheme.accent).frame(width: 10, height: 10)
|
||||
.overlay(Circle().stroke(VelocityTheme.background, lineWidth: 2))
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(LinearGradient(colors: [VelocityTheme.accent.opacity(0.5), .clear],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
.frame(width: 2)
|
||||
.frame(height: isExpanded ? 100 : 50)
|
||||
.animation(.easeInOut(duration: 0.25), value: isExpanded)
|
||||
}
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(event.badge).font(.system(size: 9, weight: .bold)).tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.15))
|
||||
.overlay(RoundedRectangle(cornerRadius: 4).stroke(VelocityTheme.accent.opacity(0.25), lineWidth: 1)))
|
||||
Spacer()
|
||||
Text(event.when).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Text(event.summary).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground)
|
||||
if isExpanded {
|
||||
Text(event.detail).font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(.top, 4)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isExpanded ? VelocityTheme.accent.opacity(0.06) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isExpanded ? VelocityTheme.accent.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
.onTapGesture { onTap() }
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct InfoMini: View {
|
||||
let label: String; let value: String
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.system(size: 9)).tracking(0.8).foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Lead Map Canvas
|
||||
private struct LeadMapCanvas: View {
|
||||
let onSelectRegion: (RegionPin) -> Void
|
||||
private let cols = [GridItem(.adaptive(minimum: 140), spacing: 10)]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 16) {
|
||||
LegendDot(color: VelocityTheme.danger, label: "Hot Lead")
|
||||
LegendDot(color: Color(red: 0.13, green: 0.83, blue: 0.93), label: "Warm Lead")
|
||||
LegendDot(color: VelocityTheme.mutedFg, label: "Cold Lead")
|
||||
Spacer()
|
||||
}
|
||||
LazyVGrid(columns: cols, spacing: 10) {
|
||||
ForEach(mapPins) { pin in
|
||||
TappableRegionPin(pin: pin, onTap: { onSelectRegion(pin) })
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct TappableRegionPin: View {
|
||||
let pin: RegionPin
|
||||
let onTap: () -> Void
|
||||
@State private var pressed = false
|
||||
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(pin.country).font(.system(size: 24))
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(pin.label).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(pinColor).frame(width: 6, height: 6).shadow(color: pinColor.opacity(0.8), radius: 3)
|
||||
Text("\(pin.count) leads").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.circle")
|
||||
.font(.system(size: 13)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(pressed ? pinColor.opacity(0.12) : Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(pinColor.opacity(pressed ? 0.5 : 0.25), lineWidth: 1)))
|
||||
.scaleEffect(pressed ? 0.97 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.12), value: pressed)
|
||||
.onTapGesture {
|
||||
pressed = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false }
|
||||
onTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct LegendDot: View {
|
||||
let color: Color; let label: String
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(color).frame(width: 8, height: 8).shadow(color: color.opacity(0.8), radius: 3)
|
||||
Text(label).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Region Detail Sheet
|
||||
private struct RegionDetailSheet: View {
|
||||
let pin: RegionPin
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
private var pinColor: Color {
|
||||
pin.temp == "hot" ? VelocityTheme.danger :
|
||||
pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg
|
||||
}
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack(spacing: 16) {
|
||||
Text(pin.country).font(.system(size: 52))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(pin.label).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(pinColor).frame(width: 7, height: 7)
|
||||
Text(pin.temp.capitalized + " Market")
|
||||
.font(.system(size: 12, weight: .medium)).foregroundStyle(pinColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) {
|
||||
DetailField(label: "Active Leads", value: "\(pin.count)")
|
||||
DetailField(label: "Top Lead", value: pin.topLead)
|
||||
DetailField(label: "Temperature", value: pin.temp.capitalized)
|
||||
DetailField(label: "Priority", value: pin.temp == "hot" ? "High 🔴" : pin.temp == "warm" ? "Medium 🟡" : "Low ⚪")
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(24)
|
||||
.background(VelocityTheme.background.ignoresSafeArea())
|
||||
.navigationTitle("Region Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Calendar Canvas
|
||||
private struct CalendarCanvas: View {
|
||||
let onSchedule: (CalTask) -> Void
|
||||
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
weekPanel
|
||||
tasksPanel
|
||||
}
|
||||
}
|
||||
|
||||
private var weekPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Weekly Schedule").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 6) {
|
||||
ForEach(days, id: \.self) { day in
|
||||
Text(day).font(.system(size: 10, weight: .medium)).tracking(0.5)
|
||||
.foregroundStyle(VelocityTheme.mutedFg).frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { i, _ in
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(i == 2 ? VelocityTheme.accent.opacity(0.15) : Color.white.opacity(0.03))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(i == 2 ? VelocityTheme.accent.opacity(0.3) : Color.white.opacity(0.05), lineWidth: 1))
|
||||
.frame(height: 60)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
|
||||
private var tasksPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 5) {
|
||||
Circle().fill(Color(red: 0.13, green: 0.83, blue: 0.93)).frame(width: 6, height: 6)
|
||||
Text("Tasks & Actions").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
ForEach(calTasks) { task in
|
||||
CalTaskRow(task: task, onSchedule: { onSchedule(task) })
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071))
|
||||
.overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalTaskRow: View {
|
||||
let task: CalTask
|
||||
let onSchedule: () -> Void
|
||||
@State private var scheduled = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(task.title).font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(scheduled ? "Scheduled ✓" : "Action")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(scheduled ? VelocityTheme.success : VelocityTheme.mutedFg)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 4)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.12) : Color.white.opacity(0.06)))
|
||||
}
|
||||
Text(task.subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg).lineLimit(2)
|
||||
HStack {
|
||||
Image(systemName: "clock").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent)
|
||||
Text(task.due).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent)
|
||||
Spacer()
|
||||
Button {
|
||||
onSchedule()
|
||||
withAnimation(.easeInOut(duration: 0.3)) { scheduled = true }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: scheduled ? "checkmark" : "calendar.badge.plus")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
Text(scheduled ? "Scheduled" : "Schedule")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12).padding(.vertical, 5)
|
||||
.background(RoundedRectangle(cornerRadius: 7)
|
||||
.fill(scheduled ? VelocityTheme.success : VelocityTheme.accent)
|
||||
.shadow(color: (scheduled ? VelocityTheme.success : VelocityTheme.accent).opacity(0.4), radius: 6))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 10)
|
||||
.fill(scheduled ? VelocityTheme.success.opacity(0.05) : Color.white.opacity(0.04))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(scheduled ? VelocityTheme.success.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +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 {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 48))
|
||||
Text("Sentinel")
|
||||
.font(.title2.bold())
|
||||
Text("FaceID and visitor event logs surface here.")
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
pageHeader
|
||||
kpiGrid
|
||||
analyticsRow
|
||||
bottomRow
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.navigationTitle("Sentinel")
|
||||
.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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,140 @@ import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Backend") {
|
||||
LabeledContent("ComfyUI Endpoint", value: "http://192.168.x.x:8000")
|
||||
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)
|
||||
}
|
||||
Section("Display") {
|
||||
LabeledContent("Orientation", value: "Landscape Only")
|
||||
|
||||
// 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()
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
369
iOS/velocity/velocity.xcodeproj/project.pbxproj
Normal file
369
iOS/velocity/velocity.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,369 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = A27B23452F58DAF100A74A49 /* Alamofire */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = velocity.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
A27B23172F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = velocity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
A27B23122F58D9C300A74A49 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A27B23462F58DAF100A74A49 /* Alamofire in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
A27B230C2F58D9C300A74A49 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
A27B23162F58D9C300A74A49 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
A27B23162F58D9C300A74A49 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A27B23152F58D9C300A74A49 /* velocity.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
A27B23142F58D9C300A74A49 /* velocity */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A27B23202F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity" */;
|
||||
buildPhases = (
|
||||
A27B23112F58D9C300A74A49 /* Sources */,
|
||||
A27B23122F58D9C300A74A49 /* Frameworks */,
|
||||
A27B23132F58D9C300A74A49 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
A27B23172F58D9C300A74A49 /* velocity */,
|
||||
);
|
||||
name = velocity;
|
||||
packageProductDependencies = (
|
||||
A27B23452F58DAF100A74A49 /* Alamofire */,
|
||||
);
|
||||
productName = velocity;
|
||||
productReference = A27B23152F58D9C300A74A49 /* velocity.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
A27B230D2F58D9C300A74A49 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
A27B23142F58D9C300A74A49 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = A27B230C2F58D9C300A74A49;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = A27B23162F58D9C300A74A49 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
A27B23142F58D9C300A74A49 /* velocity */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
A27B23132F58D9C300A74A49 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
A27B23112F58D9C300A74A49 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A27B231E2F58D9C400A74A49 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A27B231F2F58D9C400A74A49 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
A27B23212F58D9C400A74A49 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
A27B23222F58D9C400A74A49 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L29922NHD9;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "\"Used to capture rooms for the DreamWeaver AI redesign feature.\"";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n\"Used to calculate the sun's path for the Sunseeker AR overlay.\"\n";
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.desineouron.velocity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A27B23102F58D9C300A74A49 /* Build configuration list for PBXProject "velocity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A27B231E2F58D9C400A74A49 /* Debug */,
|
||||
A27B231F2F58D9C400A74A49 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
A27B23202F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A27B23212F58D9C400A74A49 /* Debug */,
|
||||
A27B23222F58D9C400A74A49 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Alamofire/Alamofire";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 5.0.0;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A27B23452F58DAF100A74A49 /* Alamofire */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A27B23442F58DAF100A74A49 /* XCRemoteSwiftPackageReference "Alamofire" */;
|
||||
productName = Alamofire;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = A27B230D2F58D9C300A74A49 /* Project object */;
|
||||
}
|
||||
7
iOS/velocity/velocity.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
iOS/velocity/velocity.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"originHash" : "11b78eba97192d19796cff581fdf69b3e65b441188b1448a1b67e5d7b825a354",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "alamofire",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Alamofire/Alamofire",
|
||||
"state" : {
|
||||
"revision" : "3f99050e75bbc6fe71fc323adabb039756680016",
|
||||
"version" : "5.11.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
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