Files
Project_Velocity/iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift
sayan 57144e1bd3 feat/#28 (#29)
Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #29
2026-04-20 00:48:01 +05:30

1925 lines
86 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import UIKit
// MARK: Shared Input Field (CRED / Uber Underline Style)
struct VelocityUnderlineField: View {
let label: String
let placeholder: String
@Binding var text: String
var isSecure: Bool = false
@FocusState private var focused: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(label.uppercased())
.font(.system(size: 10, weight: .heavy, design: .rounded))
.tracking(1.8)
.foregroundStyle(focused ? EdgeTheme.accent : EdgeTheme.subtleFg)
.animation(.easeOut(duration: 0.16), value: focused)
Group {
if isSecure {
SecureField("", text: $text, prompt:
Text(placeholder).foregroundStyle(EdgeTheme.subtleFg.opacity(0.50))
)
} else {
TextField("", text: $text, prompt:
Text(placeholder).foregroundStyle(EdgeTheme.subtleFg.opacity(0.50))
)
.keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
}
}
.autocorrectionDisabled()
.font(.system(size: 18, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
.focused($focused)
// Underline indicator
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.09))
.frame(height: 1)
Rectangle()
.fill(
focused ? EdgeTheme.accent :
(!text.isEmpty ? EdgeTheme.accentSecondary : .clear)
)
.frame(height: focused ? 2 : 1)
.animation(.spring(response: 0.28, dampingFraction: 0.76), value: focused)
}
}
.padding(.vertical, 4)
.contentShape(Rectangle())
.onTapGesture { focused = true }
}
}
// MARK: Root
struct EdgeRootView: View {
@Environment(VelocityAppModel.self) private var app
@State private var email = EdgeAppConfig.apiEmail ?? ""
@State private var password = EdgeAppConfig.apiPassword ?? ""
@State private var isSigningIn = false
@State private var heroAppeared = false
var body: some View {
Group {
switch app.session.rootState {
case .booting: bootView
case .signedOut: loginView
case .signedIn: authenticatedShell
}
}
.task { await app.bootstrap() }
}
// MARK: Boot
private var bootView: some View {
ZStack {
Color(red: 0.012, green: 0.014, blue: 0.030).ignoresSafeArea()
Circle()
.fill(EdgeTheme.accent.opacity(0.18))
.frame(width: 260, height: 260)
.blur(radius: 60)
VStack(spacing: 28) {
ZStack {
Circle()
.fill(EdgeTheme.accent.opacity(0.16))
.frame(width: 120, height: 120)
.blur(radius: 22)
RoundedRectangle(cornerRadius: 30, style: .continuous)
.fill(LinearGradient(
colors: [EdgeTheme.accent, EdgeTheme.accentPurple],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
.overlay(
RoundedRectangle(cornerRadius: 30, style: .continuous)
.stroke(Color.white.opacity(0.20), lineWidth: 1)
)
.shadow(color: EdgeTheme.accent.opacity(0.45), radius: 28, y: 10)
.frame(width: 88, height: 88)
Image(systemName: "bolt.fill")
.font(.system(size: 32, weight: .heavy))
.foregroundStyle(.white)
}
VStack(spacing: 8) {
Text("Velocity")
.font(.system(size: 34, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
Text("Live Control Surface")
.font(.system(size: 15, weight: .medium, design: .rounded))
.foregroundStyle(Color.white.opacity(0.40))
}
HStack(spacing: 8) {
ProgressView()
.tint(EdgeTheme.accent)
.scaleEffect(0.85)
Text("Bootstrapping…")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.subtleFg)
}
}
}
}
// MARK: Login
//
// Apple Maps / Wallet pattern:
// One ScrollView owns the full page no gesture math, no offset tricks.
// Hero sits at the top with minHeight = 44 % of physical screen.
// Auth sheet sits directly below with minHeight = full physical screen.
// Scrolling up naturally slides the hero off-screen and the sheet
// expands to fill the entire display the OS handles all physics.
// The drag handle is a visual affordance communicating this affordance.
private var loginView: some View {
// Use UIScreen for physical screen height so sizes are device-accurate.
let screenH = UIScreen.main.bounds.height
return ZStack {
// Full-bleed background
Color(red: 0.010, green: 0.012, blue: 0.026).ignoresSafeArea()
// Ambient glow orbs purely decorative
Circle()
.fill(EdgeTheme.accent.opacity(0.24))
.blur(radius: 90)
.frame(width: 380)
.offset(x: 55, y: -screenH * 0.24)
.ignoresSafeArea()
.allowsHitTesting(false)
Circle()
.fill(EdgeTheme.accentPurple.opacity(0.18))
.blur(radius: 80)
.frame(width: 280)
.offset(x: -110, y: -screenH * 0.33)
.ignoresSafeArea()
.allowsHitTesting(false)
// The single scroll axis that drives expansion
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// HERO
// Always at least 44 % of screen so it dominates at rest.
// As the user scrolls, it glides off the top.
VStack(spacing: 20) {
Spacer(minLength: 0)
// Icon tile
ZStack {
Circle()
.fill(EdgeTheme.accent.opacity(0.16))
.frame(width: 120)
.blur(radius: 22)
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(LinearGradient(
colors: [EdgeTheme.accent, EdgeTheme.accentPurple],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.overlay(
RoundedRectangle(cornerRadius: 26, style: .continuous)
.stroke(Color.white.opacity(0.22), lineWidth: 1)
)
.shadow(color: EdgeTheme.accent.opacity(0.52), radius: 26, y: 10)
.frame(width: 78, height: 78)
Image(systemName: "bolt.fill")
.font(.system(size: 28, weight: .heavy))
.foregroundStyle(.white)
}
.opacity(heroAppeared ? 1 : 0)
.scaleEffect(heroAppeared ? 1 : 0.68)
.animation(.spring(response: 0.50, dampingFraction: 0.70).delay(0.05), value: heroAppeared)
// Wordmark
VStack(spacing: 6) {
Text("Velocity")
.font(.system(size: 40, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
.tracking(-1.0)
Text("Your live command surface.")
.font(.system(size: 15, weight: .regular, design: .rounded))
.foregroundStyle(.white.opacity(0.44))
}
.opacity(heroAppeared ? 1 : 0)
.offset(y: heroAppeared ? 0 : 12)
.animation(.spring(response: 0.52, dampingFraction: 0.78).delay(0.12), value: heroAppeared)
// Live badge
HStack(spacing: 6) {
Circle()
.fill(EdgeTheme.success)
.frame(width: 7, height: 7)
.shadow(color: EdgeTheme.success.opacity(0.8), radius: 5)
Text("Backend live · v\(EdgeAppConfig.appVersion)")
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.36))
}
.opacity(heroAppeared ? 1 : 0)
.animation(.easeOut(duration: 0.38).delay(0.22), value: heroAppeared)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity)
// The hero occupies exactly 44 % of the physical screen height.
// Scroll offset of heroH = sheet has fully expanded.
.frame(height: screenH * 0.44)
// AUTH SHEET
// minHeight = full physical screen ensures the dark surface
// fills the viewport completely when fully scrolled.
// The RoundedRectangle's bottom arc is always off-screen.
VStack(spacing: 0) {
// Drag handle Apple Maps pattern
Capsule()
.fill(.white.opacity(0.22))
.frame(width: 36, height: 5)
.padding(.top, 14)
.padding(.bottom, 10)
// Form content
VStack(alignment: .leading, spacing: 30) {
// Header
VStack(alignment: .leading, spacing: 6) {
Text("Sign in")
.font(.system(size: 28, weight: .heavy, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
.tracking(-0.6)
Text("Enter your operator credentials to continue.")
.font(.system(size: 14, weight: .regular, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
.lineSpacing(2)
}
// Underline input fields (CRED / Uber style)
VStack(spacing: 26) {
VelocityUnderlineField(
label: "Email",
placeholder: "operator@desineuron.in",
text: $email
)
VelocityUnderlineField(
label: "Password",
placeholder: "Live backend password",
text: $password,
isSecure: true
)
}
// Primary CTA button
Button {
Task {
isSigningIn = true
await app.session.login(email: email, password: password)
if app.session.rootState == .signedIn {
await app.refreshCurrentModule(forceAll: true)
}
isSigningIn = false
}
} label: {
ZStack {
if isSigningIn {
HStack(spacing: 10) {
ProgressView().tint(.white).scaleEffect(0.90)
Text("Authenticating…")
.font(.system(size: 16, weight: .bold, design: .rounded))
}
} else {
Text("Continue")
.font(.system(size: 17, weight: .bold, design: .rounded))
}
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 56)
.background {
let off = email.trimmingCharacters(in: .whitespaces).isEmpty
|| password.isEmpty || isSigningIn
if off {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.fill(.white.opacity(0.07))
} else {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.fill(LinearGradient(
colors: [EdgeTheme.accent, EdgeTheme.accentPurple],
startPoint: .leading, endPoint: .trailing
))
.shadow(color: EdgeTheme.accent.opacity(0.42), radius: 20, y: 8)
}
}
}
.disabled(
email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| password.isEmpty || isSigningIn
)
.buttonStyle(_PressScaleStyle())
// Inline error
if let msg = app.session.errorMessage {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(EdgeTheme.danger)
Text(msg)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.danger.opacity(0.88))
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 13, style: .continuous)
.fill(EdgeTheme.danger.opacity(0.09))
.overlay(
RoundedRectangle(cornerRadius: 13, style: .continuous)
.stroke(EdgeTheme.danger.opacity(0.16), lineWidth: 1)
)
)
}
// Backend endpoint footer
HStack(spacing: 5) {
Circle()
.fill(EdgeTheme.subtleFg.opacity(0.4))
.frame(width: 4, height: 4)
Text(EdgeAppConfig.baseURL)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(EdgeTheme.subtleFg.opacity(0.60))
.lineLimit(1)
.truncationMode(.middle)
}
.frame(maxWidth: .infinity, alignment: .center)
}
.padding(.horizontal, 28)
.padding(.bottom, 60) // home-indicator breathing room
Spacer(minLength: 0) // keeps content top-aligned in minHeight zone
}
// KEY: minHeight full screen guarantees dark surface fills
// the viewport when fully scrolled. Content sits at the top.
.frame(maxWidth: .infinity, minHeight: screenH)
.background(
ZStack(alignment: .top) {
// Rounded top surface bottom arc is always off-screen
RoundedRectangle(cornerRadius: 34, style: .continuous)
.fill(Color(red: 0.060, green: 0.068, blue: 0.112))
// Subtle top-edge shimmer
RoundedRectangle(cornerRadius: 34, style: .continuous)
.stroke(
LinearGradient(
colors: [.white.opacity(0.14), .white.opacity(0.02)],
startPoint: .top, endPoint: .bottom
),
lineWidth: 1
)
}
// Extend background past the home indicator
.ignoresSafeArea(edges: .bottom)
)
// Entrance animation rises from below on first appear
.opacity(heroAppeared ? 1 : 0)
.offset(y: heroAppeared ? 0 : 36)
.animation(.spring(response: 0.55, dampingFraction: 0.82).delay(0.16), value: heroAppeared)
}
}
// ScrollView itself fills the full screen edge-to-edge
.ignoresSafeArea(edges: .top)
// Allow bounce to feel natural (default is true, explicit for clarity)
.scrollBounceBehavior(.basedOnSize)
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { heroAppeared = true }
}
.onDisappear { heroAppeared = false }
}
// MARK: Authenticated shell
private var authenticatedShell: some View {
ZStack {
switch app.session.selectedModule {
case .home: HomeModuleView()
case .command: CommandModuleView()
case .sentinel: SentinelModuleView()
case .inventory: InventoryModuleView()
case .catalyst: CatalystModuleView()
}
VelocityBottomNavigation(selection: Binding(
get: { app.session.selectedModule },
set: { m in
app.session.selectedModule = m
Task { await app.refreshCurrentModule() }
}
)) {
app.session.showingSettings = true
}
}
.sheet(isPresented: Binding(
get: { app.session.showingSettings },
set: { app.session.showingSettings = $0 }
)) {
SettingsModuleView()
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
}
// MARK: Press-scale button style
private struct _PressScaleStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.970 : 1)
.opacity(configuration.isPressed ? 0.88 : 1)
.animation(.spring(response: 0.19, dampingFraction: 0.68), value: configuration.isPressed)
}
}
// MARK: Section header
private struct _SectionHeader: View {
let title: String
let systemImage: String
var tint: Color = EdgeTheme.accent
var body: some View {
HStack(spacing: 8) {
Image(systemName: systemImage)
.font(.system(size: 12, weight: .bold))
.foregroundStyle(tint)
Text(title.uppercased())
.font(.system(size: 10, weight: .heavy, design: .rounded))
.tracking(1.4)
.foregroundStyle(tint)
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(
Capsule(style: .continuous)
.fill(tint.opacity(0.10))
.overlay(Capsule(style: .continuous).stroke(tint.opacity(0.18), lineWidth: 1))
)
}
}
// MARK: HOME
struct HomeModuleView: View {
@Environment(VelocityAppModel.self) private var app
private var topLead: VelocityLead? {
app.home.leads.sorted(by: { $0.score > $1.score }).first
}
var body: some View {
VelocityModuleScreen(
title: "Home",
subtitle: "Live investor command surface."
) {
if let error = app.home.errorMessage {
_ErrorBanner(message: error)
}
// Priority Lead hero card
if let lead = topLead {
_LeadHeroCard(lead: lead, alerts: app.home.alerts)
} else {
_EmptyLeadHero()
}
// 2×2 Metric grid
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
VelocityMetricTile(label: "Leads", value: "\(app.home.leads.count)", tint: EdgeTheme.accent)
VelocityMetricTile(label: "Due 24h", value: "\(app.home.alerts?.upcomingCalendarEvents24h ?? 0)", tint: EdgeTheme.warning)
VelocityMetricTile(label: "Transcripts", value: "\(app.home.alerts?.pendingTranscriptions ?? 0)", tint: EdgeTheme.accentWarm)
VelocityMetricTile(label: "System", value: app.home.adminHealth?.status.capitalized ?? "", tint: EdgeTheme.success)
}
// Quick actions
VelocityGlassCard(title: "Quick Actions") {
VStack(spacing: 8) {
_ActionRow(icon: "person.text.rectangle", label: "Lead Dossier") { app.session.selectedModule = .command; app.command.selectedSection = .oracle }
_ActionRow(icon: "square.and.pencil", label: "Operator Note") { app.session.selectedModule = .command; app.command.selectedSection = .notes }
_ActionRow(icon: "waveform.path.ecg.rectangle", label: "Review Transcript") { app.session.selectedModule = .command; app.command.selectedSection = .transcriptions }
_ActionRow(icon: "eye", label: "Sentinel Live") { app.session.selectedModule = .sentinel; app.sentinel.selectedSection = .live }
_ActionRow(icon: "wand.and.stars", label: "Dream Weaver") { app.session.selectedModule = .inventory; app.inventory.selectedSection = .dreamWeaver }
}
}
// Upcoming calendar
VelocityGlassCard(title: "Upcoming") {
if app.home.calendar.isEmpty {
_EmptyState(icon: "calendar", message: "No confirmed follow-ups scheduled.")
} else {
ForEach(app.home.calendar.prefix(4)) { event in
_TimelineRow(
icon: "calendar.circle.fill",
title: event.title,
subtitle: event.location ?? "Lead-linked follow-up",
trailing: event.startAt.edgeRelativeShort,
tint: EdgeTheme.accent
)
}
}
}
// Recent comms
VelocityGlassCard(title: "Recent Communications") {
if app.home.events.isEmpty {
_EmptyState(icon: "bubble.left.and.bubble.right", message: "No recent communication events.")
} else {
ForEach(app.home.events.prefix(4)) { event in
_TimelineRow(
icon: _channelIcon(event.channel),
title: event.channel.replacingOccurrences(of: "_", with: " ").capitalized,
subtitle: event.summary ?? "No summary available.",
trailing: event.timestamp.edgeRelativeShort,
tint: EdgeTheme.accentWarm
)
}
}
}
}
.refreshable { await app.refreshCurrentModule() }
.task {
await app.home.refresh(token: app.session.token ?? "")
await app.session.sendHeartbeat(module: .home, screen: "home_dashboard")
}
}
private func _channelIcon(_ channel: String) -> String {
switch channel.lowercased() {
case let c where c.contains("call"): return "phone.circle.fill"
case let c where c.contains("email"): return "envelope.circle.fill"
case let c where c.contains("whats"): return "message.circle.fill"
default: return "bubble.left.circle.fill"
}
}
}
private struct _LeadHeroCard: View {
let lead: VelocityLead
let alerts: VelocityAlertSnapshot?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top) {
ZStack {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.white.opacity(0.18))
Text(String(lead.name.prefix(1)))
.font(.system(size: 22, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
}
.frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 5) {
Text(lead.name)
.font(.system(size: 19, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
Text("\(lead.qualification.capitalized) intent · \(lead.unitInterest)")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.75))
}
Spacer()
// Score badge
VStack(spacing: 2) {
Text("\(lead.score)")
.font(.system(size: 22, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
Text("SCORE")
.font(.system(size: 8, weight: .heavy, design: .rounded))
.tracking(1.4)
.foregroundStyle(.white.opacity(0.60))
}
}
Divider().background(Color.white.opacity(0.15))
HStack(spacing: 8) {
_HeroPill(label: "\(alerts?.pendingInsights ?? 0) insights", tint: .white.opacity(0.80))
_HeroPill(label: lead.budget, tint: .white.opacity(0.80))
_HeroPill(label: lead.kanbanStatus.capitalized, tint: .white.opacity(0.80))
}
}
.padding(20)
.background(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(LinearGradient(
colors: [EdgeTheme.accent, EdgeTheme.accentPurple.opacity(0.88)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.overlay(
RoundedRectangle(cornerRadius: 28, style: .continuous)
.stroke(Color.white.opacity(0.16), lineWidth: 1)
)
.shadow(color: EdgeTheme.accent.opacity(0.30), radius: 28, y: 12)
)
}
}
private struct _EmptyLeadHero: View {
var body: some View {
VStack(spacing: 12) {
Image(systemName: "person.crop.circle.dashed")
.font(.system(size: 36, weight: .light))
.foregroundStyle(EdgeTheme.mutedFg)
Text("No live leads in queue")
.font(.system(size: 15, weight: .semibold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
Text("Awaiting the first live lead in this operator scope.")
.font(.system(size: 13, weight: .regular, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(28)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color.white.opacity(0.04))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(EdgeTheme.borderSubtle, lineWidth: 1)
)
)
}
}
private struct _HeroPill: View {
let label: String
let tint: Color
var body: some View {
Text(label)
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(tint)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule(style: .continuous)
.fill(Color.white.opacity(0.16))
)
}
}
// MARK: COMMAND
struct CommandModuleView: View {
@Environment(VelocityAppModel.self) private var app
var body: some View {
VelocityModuleScreen(title: "Command", subtitle: "Lead control, CRM, comms, notes, calendar, transcripts & insights.") {
// Section tabs
VelocitySectionPicker(
sections: VelocityCommandStore.Section.allCases,
selection: Binding(get: { app.command.selectedSection }, set: { app.command.selectedSection = $0 }),
title: { $0.rawValue }
)
if let error = app.command.errorMessage { _ErrorBanner(message: error) }
if let msg = app.command.actionMessage {
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(EdgeTheme.success)
Text(msg)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(EdgeTheme.success.opacity(0.10))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(EdgeTheme.success.opacity(0.20), lineWidth: 1))
)
}
switch app.command.selectedSection {
case .oracle: oracleSection
case .crm: crmSection
case .alerts: alertsSection
case .communications: commsSection
case .notes: notesSection
case .calendar: calendarSection
case .transcriptions: transcriptSection
case .insights: insightsSection
}
}
.refreshable { await app.refreshCurrentModule() }
.task {
await app.command.refresh(token: app.session.token ?? "")
await app.session.sendHeartbeat(module: .command, screen: "command_oracle")
}
.onChange(of: app.command.selectedSection) { _, s in
Task { await app.session.sendHeartbeat(module: .command, screen: "command_\(s.rawValue.lowercased())") }
}
}
// Oracle
private var oracleSection: some View {
VStack(spacing: 14) {
if let lead = app.command.primaryLead {
VelocityGlassCard(title: "Priority Lead", tint: EdgeTheme.accent) {
VStack(spacing: 0) {
_KV("Name", lead.name)
_Divider()
_KV("Stage", lead.kanbanStatus)
_Divider()
_KV("Budget", lead.budget)
_Divider()
_KV("Unit", lead.unitInterest)
_Divider()
_KV("Score", "\(lead.score)")
}
}
}
if let c360 = app.command.client360 {
VelocityGlassCard(title: "Client 360", tint: EdgeTheme.accentSecondary) {
Text(c360.summaryText)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
.lineSpacing(3)
}
}
if app.command.primaryLead == nil {
_EmptyState(icon: "person.badge.clock", message: "No live lead available for Oracle summary.")
}
}
}
// CRM
private var crmSection: some View {
VStack(spacing: 14) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
VelocityMetricTile(label: "Contacts", value: "\(app.command.contacts.count)", tint: EdgeTheme.accent)
VelocityMetricTile(label: "Deals", value: "\(app.command.opportunities.count)", tint: EdgeTheme.accentSecondary)
VelocityMetricTile(label: "Tasks", value: "\(app.command.tasks.count)", tint: EdgeTheme.warning)
VelocityMetricTile(label: "Kanban Cols", value: "\(app.command.kanban.count)", tint: EdgeTheme.accentWarm)
}
if !app.command.contacts.isEmpty {
VelocityGlassCard(title: "Top Contacts") {
ForEach(app.command.contacts.prefix(5)) { contact in
_TimelineRow(
icon: "person.circle.fill",
title: contact.fullName,
subtitle: [contact.primaryEmail, contact.primaryPhone].compactMap { $0 }.joined(separator: " · "),
trailing: contact.leadStatus ?? "Live",
tint: EdgeTheme.accent
)
}
}
}
VelocityGlassCard(title: "Create Follow-Up Task") {
_InlineEditor(
placeholder: "e.g. Call back tomorrow 11 AM",
text: Binding(get: { app.command.taskDraft }, set: { app.command.taskDraft = $0 })
)
Button("Create Task") {
Task { await app.command.createFollowUp(token: app.session.token ?? "") }
}
.buttonStyle(_ProminentButton(enabled: !app.command.taskDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
.disabled(app.command.taskDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
// Alerts
private var alertsSection: some View {
VelocityGlassCard(title: "Alert Stack", tint: EdgeTheme.danger) {
_KV("Pending Insights", "\(app.command.alerts?.pendingInsights ?? 0)")
_Divider()
_KV("Due in 24h", "\(app.command.alerts?.upcomingCalendarEvents24h ?? 0)")
_Divider()
_KV("Pending Transcripts", "\(app.command.alerts?.pendingTranscriptions ?? 0)")
}
}
// Comms
private var commsSection: some View {
VelocityGlassCard(title: "Communication Threads") {
if app.command.events.isEmpty {
_EmptyState(icon: "bubble.left.and.bubble.right", message: "No live communication events returned yet.")
} else {
ForEach(app.command.events) { event in
_TimelineRow(
icon: event.recordingRef == nil ? "bubble.left.circle.fill" : "waveform.circle.fill",
title: event.channel.replacingOccurrences(of: "_", with: " ").capitalized,
subtitle: event.summary ?? "No summary available.",
trailing: event.timestamp.edgeRelativeShort,
tint: event.recordingRef == nil ? EdgeTheme.accent : EdgeTheme.accentWarm
)
}
}
}
}
// Notes
private var notesSection: some View {
VStack(spacing: 14) {
if !app.command.memoryFacts.isEmpty {
VelocityGlassCard(title: "Memory Facts") {
ForEach(app.command.memoryFacts.prefix(6)) { fact in
_TimelineRow(
icon: "lightbulb.circle.fill",
title: fact.factType.replacingOccurrences(of: "_", with: " ").capitalized,
subtitle: fact.factText,
trailing: fact.createdAt.edgeRelativeShort,
tint: EdgeTheme.accentSecondary
)
}
}
} else {
_EmptyState(icon: "note.text", message: "No operator memory facts stored for this lead yet.")
}
VelocityGlassCard(title: "Capture Note") {
_InlineEditor(
placeholder: "e.g. Client prefers corner units on higher floors…",
text: Binding(get: { app.command.noteDraft }, set: { app.command.noteDraft = $0 }),
multiline: true
)
Button("Save Note") {
Task { await app.command.createNote(token: app.session.token ?? "") }
}
.buttonStyle(_ProminentButton(enabled: !app.command.noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
.disabled(app.command.noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
// Calendar
private var calendarSection: some View {
VStack(spacing: 14) {
VelocityGlassCard(title: "Schedule Follow-Up") {
_InlineEditor(
placeholder: "e.g. Site visit at Sobha Hartland",
text: Binding(get: { app.command.calendarTitleDraft }, set: { app.command.calendarTitleDraft = $0 })
)
Button("Create Event") {
Task { await app.command.createCalendarEvent(token: app.session.token ?? "") }
}
.buttonStyle(_ProminentButton(enabled: !app.command.calendarTitleDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty))
.disabled(app.command.calendarTitleDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if !app.command.calendar.isEmpty {
VelocityGlassCard(title: "Upcoming") {
ForEach(app.command.calendar.prefix(6)) { event in
_TimelineRow(
icon: "calendar.circle.fill",
title: event.title,
subtitle: event.location ?? event.description ?? "Live calendar event",
trailing: event.startAt.edgeRelativeShort,
tint: event.status == "cancelled" ? EdgeTheme.danger : EdgeTheme.accent
)
}
}
}
}
}
// Transcript
private var transcriptSection: some View {
VelocityGlassCard(title: "Call Transcript") {
if let t = app.command.transcript {
_KV("Status", t.job.status)
_Divider()
_KV("Provider", t.job.provider ?? "Unknown")
_Divider()
_KV("Language", t.job.language ?? "Unknown")
_Divider()
_KV("Segments", "\(t.segments.count)")
if !t.segments.isEmpty {
Divider().background(EdgeTheme.borderSubtle).padding(.vertical, 4)
ForEach(t.segments.prefix(4)) { seg in
_TimelineRow(
icon: "waveform.circle.fill",
title: seg.speakerLabel ?? "Speaker",
subtitle: seg.text,
trailing: seg.startMs.map { "\($0 / 1000)s" } ?? "",
tint: EdgeTheme.accentWarm
)
}
}
} else {
_EmptyState(icon: "waveform.path.ecg.rectangle", message: "No recording-backed transcript for the current lead.")
}
}
}
// Insights
private var insightsSection: some View {
VStack(spacing: 12) {
if app.command.insights.isEmpty {
_EmptyState(icon: "sparkles", message: "No actionable insights available for this lead.")
} else {
ForEach(app.command.insights.prefix(5)) { insight in
VelocityGlassCard(title: "Insight", tint: EdgeTheme.accentWarm) {
Text(insight.summary)
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
if let action = insight.suggestedAction {
Text(action)
.font(.system(size: 13, weight: .regular, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
.padding(.top, 2)
}
HStack(spacing: 10) {
Button("Accept") {
Task { await app.command.act(on: insight, action: "accepted", token: app.session.token ?? "") }
}
.buttonStyle(_ChipButton(tint: EdgeTheme.success))
Button("Dismiss") {
Task { await app.command.act(on: insight, action: "dismissed", token: app.session.token ?? "") }
}
.buttonStyle(_ChipButton(tint: EdgeTheme.danger))
}
.padding(.top, 6)
}
}
}
}
}
}
// MARK: SENTINEL
struct SentinelModuleView: View {
@Environment(VelocityAppModel.self) private var app
var body: some View {
VelocityModuleScreen(title: "Sentinel", subtitle: "Showroom intelligence — live visitor load, alert posture & video stack.") {
VelocitySectionPicker(
sections: VelocitySentinelStore.Section.allCases,
selection: Binding(get: { app.sentinel.selectedSection }, set: { app.sentinel.selectedSection = $0 }),
title: { $0.rawValue }
)
if let error = app.sentinel.errorMessage { _ErrorBanner(message: error) }
if app.sentinel.selectedSection == .overview {
// Status hero
_SentinelHero(
leads: app.sentinel.leads.count,
videos: app.sentinel.videos.count,
insights: app.sentinel.alerts?.pendingInsights ?? 0,
health: app.sentinel.adminHealth?.status
)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
VelocityMetricTile(label: "Active Leads", value: "\(app.sentinel.leads.count)", tint: EdgeTheme.accent)
VelocityMetricTile(label: "Video Assets", value: "\(app.sentinel.videos.count)", tint: EdgeTheme.accentWarm)
VelocityMetricTile(label: "Transcripts", value: "\(app.sentinel.alerts?.pendingTranscriptions ?? 0)", tint: EdgeTheme.warning)
VelocityMetricTile(label: "Health", value: app.sentinel.adminHealth?.status.capitalized ?? "", tint: EdgeTheme.success)
}
VelocityGlassCard(title: "High-Score Sessions") {
if app.sentinel.leads.isEmpty {
_EmptyState(icon: "eye.slash", message: "No active visitor sessions.")
} else {
ForEach(app.sentinel.leads.sorted(by: { $0.score > $1.score }).prefix(5)) { lead in
_TimelineRow(
icon: "person.crop.circle.fill",
title: lead.name,
subtitle: "\(lead.qualification.capitalized) · \(lead.unitInterest)",
trailing: "\(lead.score)",
tint: lead.score > 80 ? EdgeTheme.success : EdgeTheme.warning
)
}
}
}
} else {
VelocityGlassCard(title: "Live Session Stack") {
if app.sentinel.videos.isEmpty {
_EmptyState(icon: "play.slash.fill", message: "No live marketing videos available for review.")
} else {
ForEach(app.sentinel.videos.prefix(5)) { video in
_TimelineRow(
icon: "play.circle.fill",
title: video.title,
subtitle: "\(video.propertyName) · \(video.unitNumber) · \(video.type)",
trailing: "Live",
tint: EdgeTheme.accentSecondary
)
}
}
}
}
}
.refreshable { await app.refreshCurrentModule() }
.task {
await app.sentinel.refresh(token: app.session.token ?? "")
await app.session.sendHeartbeat(module: .sentinel, screen: "sentinel_overview")
}
.onChange(of: app.sentinel.selectedSection) { _, s in
Task { await app.session.sendHeartbeat(module: .sentinel, screen: "sentinel_\(s.rawValue.lowercased())") }
}
}
}
private struct _SentinelHero: View {
let leads: Int
let videos: Int
let insights: Int
let health: String?
var body: some View {
HStack(spacing: 0) {
_StatPill(value: "\(leads)", label: "Visitors", tint: EdgeTheme.accent)
_Separator()
_StatPill(value: "\(videos)", label: "Videos", tint: EdgeTheme.accentWarm)
_Separator()
_StatPill(value: "\(insights)", label: "Insights", tint: EdgeTheme.danger)
_Separator()
_StatPill(value: health?.capitalized ?? "", label: "System", tint: EdgeTheme.success)
}
.padding(.vertical, 18)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color.white.opacity(0.045))
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.stroke(EdgeTheme.borderGlass, lineWidth: 1)
)
)
}
private func _Separator() -> some View {
Rectangle()
.fill(EdgeTheme.borderSubtle)
.frame(width: 1, height: 32)
}
private struct _StatPill: View {
let value: String
let label: String
let tint: Color
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 22, weight: .heavy, design: .rounded))
.foregroundStyle(tint)
.lineLimit(1)
.minimumScaleFactor(0.7)
Text(label.uppercased())
.font(.system(size: 9, weight: .heavy, design: .rounded))
.tracking(1.2)
.foregroundStyle(EdgeTheme.subtleFg)
}
.frame(maxWidth: .infinity)
}
}
}
// MARK: INVENTORY
struct InventoryModuleView: View {
@Environment(VelocityAppModel.self) private var app
@State private var showingCamera = false
@State private var showingShare = false
var body: some View {
VelocityModuleScreen(title: "Inventory", subtitle: "Portfolio, unit detail, Dream Weaver & Sunseeker.") {
VelocitySectionPicker(
sections: VelocityInventoryStore.Section.allCases,
selection: Binding(get: { app.inventory.selectedSection }, set: { app.inventory.selectedSection = $0 }),
title: { $0.rawValue }
)
if let error = app.inventory.errorMessage { _ErrorBanner(message: error) }
switch app.inventory.selectedSection {
case .portfolio: portfolioSection
case .units: unitsSection
case .dreamWeaver: dreamWeaverSection
case .sunseeker: sunseekerSection
}
}
.refreshable { await app.refreshCurrentModule() }
.task {
await app.inventory.refresh(token: app.session.token ?? "")
await app.inventory.refreshDreamWeaverHealth()
await app.session.sendHeartbeat(module: .inventory, screen: "inventory_portfolio")
}
.onChange(of: app.inventory.selectedSection) { _, s in
Task { await app.session.sendHeartbeat(module: .inventory, screen: "inventory_\(s.rawValue.lowercased())") }
}
.sheet(isPresented: $showingCamera) {
VelocityImagePicker(isPresented: $showingCamera) { img in
app.inventory.sourceImage = img
app.inventory.generatedImage = nil
app.inventory.dreamWeaverMessage = nil
}
}
.sheet(isPresented: $showingShare) {
if let img = app.inventory.generatedImage { VelocityShareSheet(image: img) }
}
}
// Portfolio
private var portfolioSection: some View {
VelocityGlassCard(title: "Property Portfolio") {
if app.inventory.properties.isEmpty {
_EmptyState(icon: "building.2", message: "No properties returned for this operator scope.")
} else {
ForEach(app.inventory.properties.prefix(8)) { prop in
Button {
app.inventory.selectedPropertyID = prop.propertyId
Task { await app.inventory.refresh(token: app.session.token ?? "") }
app.inventory.selectedSection = .units
} label: {
HStack {
_TimelineRow(
icon: "building.2.fill",
title: prop.projectName,
subtitle: "\(prop.developerName) · \(prop.propertyType)",
trailing: prop.status.capitalized,
tint: EdgeTheme.accent
)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(EdgeTheme.subtleFg)
}
}
.buttonStyle(.plain)
}
}
}
}
// Units / Detail
private var unitsSection: some View {
VStack(spacing: 14) {
if let prop = app.inventory.selectedProperty {
VelocityGlassCard(title: "Property Detail") {
_KV("Project", prop.projectName)
_Divider()
_KV("Developer", prop.developerName)
_Divider()
_KV("Type", prop.propertyType)
_Divider()
_KV("Status", prop.status.capitalized)
_Divider()
_KV("Location", JSONValue.object(prop.location).summaryText)
}
} else {
_EmptyState(icon: "building.2.crop.circle", message: "Select a property from the Portfolio tab.")
}
VelocityGlassCard(title: "Media & Floor Plans") {
if app.inventory.media.isEmpty {
_EmptyState(icon: "photo.on.rectangle.angled", message: "No media assets for this property.")
} else {
ForEach(app.inventory.media.prefix(6)) { m in
_TimelineRow(
icon: "photo.circle.fill",
title: m.mediaType.replacingOccurrences(of: "_", with: " ").capitalized,
subtitle: m.url,
trailing: "Asset",
tint: EdgeTheme.accentWarm
)
}
}
}
}
}
// Dream Weaver
private var dreamWeaverSection: some View {
VStack(spacing: 14) {
// Status banner
HStack(spacing: 10) {
Circle()
.fill(app.inventory.dreamWeaverOnline == true ? EdgeTheme.success : EdgeTheme.danger)
.frame(width: 8, height: 8)
.shadow(color: app.inventory.dreamWeaverOnline == true ? EdgeTheme.success : EdgeTheme.danger, radius: 4)
Text(app.inventory.dreamWeaverOnline == true ? "Gateway online" : app.inventory.dreamWeaverOnline == false ? "Gateway offline" : "Checking gateway…")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
Spacer()
Text(app.inventory.roomType.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(EdgeTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule(style: .continuous).fill(EdgeTheme.accent.opacity(0.12)))
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color.white.opacity(0.04))
.overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(EdgeTheme.borderSubtle, lineWidth: 1))
)
// Image viewport
ZStack {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color.black.opacity(0.90))
.frame(minHeight: 320)
if let image = app.inventory.generatedImage ?? app.inventory.sourceImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(14)
} else {
VStack(spacing: 10) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 44, weight: .ultraLight))
.foregroundStyle(EdgeTheme.subtleFg)
Text("Capture a room to reimagine it")
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
}
}
if app.inventory.dreamWeaverBusy {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(Color.black.opacity(0.55))
VStack(spacing: 12) {
ProgressView().tint(.white).scaleEffect(1.2)
Text("Running Dream Weaver pipeline…")
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.80))
}
}
}
// Room selector
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(["bedroom","living_room","bathroom","kitchen","dining_room","home_office","hallway","balcony"], id: \.self) { room in
Button(room.replacingOccurrences(of: "_", with: " ").capitalized) {
app.inventory.roomType = room
}
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(app.inventory.roomType == room ? EdgeTheme.foreground : EdgeTheme.mutedFg)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule(style: .continuous)
.fill(app.inventory.roomType == room ? EdgeTheme.accent.opacity(0.20) : Color.white.opacity(0.06))
.overlay(Capsule(style: .continuous).stroke(app.inventory.roomType == room ? EdgeTheme.accent.opacity(0.40) : EdgeTheme.borderSubtle, lineWidth: 1))
)
.buttonStyle(.plain)
}
}
.padding(.horizontal, 2)
}
// Keywords
_InlineEditor(
placeholder: "Optional style keywords (e.g. minimalist, warm tones…)",
text: Binding(get: { app.inventory.keywords }, set: { app.inventory.keywords = $0 })
)
// Actions
HStack(spacing: 10) {
Button("Capture") { showingCamera = true }
.buttonStyle(_ChipButton(tint: EdgeTheme.accentSecondary))
.frame(maxWidth: .infinity)
Button("Reimagine") { Task { await app.inventory.generateDreamWeaver() } }
.buttonStyle(_ProminentButton(
enabled: app.inventory.sourceImage != nil && !app.inventory.dreamWeaverBusy
))
.disabled(app.inventory.sourceImage == nil || app.inventory.dreamWeaverBusy)
.frame(maxWidth: .infinity)
}
if app.inventory.generatedImage != nil {
Button("Share Result") { showingShare = true }
.buttonStyle(_ChipButton(tint: EdgeTheme.accentWarm))
.frame(maxWidth: .infinity)
}
if let msg = app.inventory.dreamWeaverMessage {
HStack(spacing: 8) {
Image(systemName: msg.localizedCaseInsensitiveContains("render") ? "checkmark.circle.fill" : "exclamationmark.circle.fill")
.foregroundStyle(msg.localizedCaseInsensitiveContains("render") ? EdgeTheme.success : EdgeTheme.warning)
Text(msg)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
}
.padding(12)
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.04)))
}
}
}
// Sunseeker
private var sunseekerSection: some View {
VStack(spacing: 14) {
VStack(spacing: 16) {
Image(systemName: "sun.max.trianglebadge.exclamationmark")
.font(.system(size: 44, weight: .light))
.foregroundStyle(EdgeTheme.warning)
Text("Real Device Required")
.font(.system(size: 17, weight: .heavy, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
Text("Sunseeker uses ARKit, CoreLocation, and CoreMotion. This surface is reserved for real-device deployment where full sensor access is available.")
.font(.system(size: 13, weight: .regular, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
.multilineTextAlignment(.center)
.lineSpacing(3)
}
.padding(28)
.frame(maxWidth: .infinity)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(EdgeTheme.warning.opacity(0.06))
.overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(EdgeTheme.warning.opacity(0.18), lineWidth: 1))
)
}
}
}
// MARK: CATALYST
struct CatalystModuleView: View {
@Environment(VelocityAppModel.self) private var app
var body: some View {
VelocityModuleScreen(title: "Catalyst", subtitle: "Campaign intelligence — spend, reach, ROI & creative posture.") {
VelocitySectionPicker(
sections: VelocityCatalystStore.Section.allCases,
selection: Binding(get: { app.catalyst.selectedSection }, set: { app.catalyst.selectedSection = $0 }),
title: { $0.rawValue }
)
if let error = app.catalyst.errorMessage { _ErrorBanner(message: error) }
// Summary hero tiles
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
VelocityMetricTile(label: "Campaigns", value: "\(app.catalyst.campaigns.count)", tint: EdgeTheme.accent)
VelocityMetricTile(label: "Insights", value: "\(app.catalyst.insights.count)", tint: EdgeTheme.accentWarm)
VelocityMetricTile(label: "Total Spend", value: app.catalyst.campaigns.reduce(0.0) { $0 + $1.spent }.asCurrency, tint: EdgeTheme.warning)
VelocityMetricTile(label: "Conversions", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.conversions })", tint: EdgeTheme.success)
}
switch app.catalyst.selectedSection {
case .studio, .marketing: creativeSection
case .command: commandSection
case .roi: roiSection
case .warRoom: warRoomSection
}
}
.refreshable { await app.refreshCurrentModule() }
.task {
await app.catalyst.refresh(token: app.session.token ?? "")
await app.session.sendHeartbeat(module: .catalyst, screen: "catalyst_studio")
}
.onChange(of: app.catalyst.selectedSection) { _, s in
Task { await app.session.sendHeartbeat(module: .catalyst, screen: "catalyst_\(s.rawValue.lowercased())") }
}
}
private var creativeSection: some View {
VelocityGlassCard(title: "Campaign Studio") {
if app.catalyst.campaigns.isEmpty {
_EmptyState(icon: "megaphone.fill", message: "No active campaigns in this marketing scope.")
} else {
ForEach(app.catalyst.campaigns.prefix(6)) { c in
_TimelineRow(
icon: _platformIcon(c.platform),
title: c.name,
subtitle: "\(c.platform.capitalized) · \(c.objective ?? "Campaign")",
trailing: c.status.capitalized,
tint: EdgeTheme.accent
)
}
}
}
}
private var commandSection: some View {
VelocityGlassCard(title: "Campaign Command") {
if app.catalyst.campaigns.isEmpty {
_EmptyState(icon: "chart.bar.xaxis", message: "No campaigns to display.")
} else {
ForEach(app.catalyst.campaigns.prefix(6)) { c in
VStack(alignment: .leading, spacing: 4) {
Text(c.name)
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
HStack {
Text("Budget \(c.budget.asCurrency)")
Text("·")
Text("Spent \(c.spent.asCurrency)")
}
.font(.system(size: 12, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
}
.padding(.vertical, 4)
if c.id != app.catalyst.campaigns.prefix(6).last?.id {
Divider().background(EdgeTheme.borderSubtle)
}
}
}
}
}
private var roiSection: some View {
VStack(spacing: 14) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
VelocityMetricTile(label: "Spend", value: app.catalyst.campaigns.reduce(0.0) { $0 + $1.spent }.asCurrency, tint: EdgeTheme.warning)
VelocityMetricTile(label: "Impressions", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.impressions })", tint: EdgeTheme.accent)
VelocityMetricTile(label: "Clicks", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.clicks })", tint: EdgeTheme.accentSecondary)
VelocityMetricTile(label: "Conversions", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.conversions })", tint: EdgeTheme.success)
}
}
}
private var warRoomSection: some View {
VelocityGlassCard(title: "War Room", tint: EdgeTheme.accentWarm) {
if app.catalyst.insights.isEmpty {
_EmptyState(icon: "bolt.shield", message: "No live marketing intelligence events.")
} else {
ForEach(app.catalyst.insights.prefix(6)) { i in
_TimelineRow(
icon: "chart.line.uptrend.xyaxis.circle.fill",
title: i.campaignId ?? "Campaign",
subtitle: "\(i.platform?.capitalized ?? "Platform") · Spend \((i.spend ?? 0).asCurrency)",
trailing: "\(i.clicks ?? 0) clicks",
tint: EdgeTheme.accentWarm
)
}
}
}
}
private func _platformIcon(_ p: String) -> String {
switch p.lowercased() {
case "meta", "facebook": return "f.circle.fill"
case "google": return "g.circle.fill"
case "tiktok": return "play.circle.fill"
default: return "megaphone.circle.fill"
}
}
}
// MARK: SETTINGS
struct SettingsModuleView: View {
@Environment(VelocityAppModel.self) private var app
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ZStack {
EdgeAmbientBackground().ignoresSafeArea()
ScrollView(showsIndicators: false) {
VStack(spacing: 20) {
// Profile card
_ProfileCard(
name: app.session.displayName,
role: app.session.roleLabel,
email: app.session.profile?.email ?? ""
)
VelocityGlassCard(title: "Runtime") {
_KV("Backend", EdgeAppConfig.baseURL)
_Divider()
_KV("Version", EdgeAppConfig.appVersion)
_Divider()
_KV("Heartbeat", app.session.lastHeartbeatAt?.edgeRelativeShort ?? "None yet")
_Divider()
_KV("Last Screen", app.session.lastScreenName)
_Divider()
_KV("Auth Mode", EdgeAppConfig.authModeDescription)
}
if let health = app.settings.adminHealth {
VelocityGlassCard(title: "System Health", tint: health.status == "ok" ? EdgeTheme.success : EdgeTheme.danger) {
_KV("Status", health.status.capitalized)
_Divider()
_KV("Transcriptions", "\(health.queues.pendingTranscriptions)")
_Divider()
_KV("Synthetic Jobs", "\(health.queues.pendingSyntheticJobs)")
_Divider()
_KV("Admin Actions", "\(health.queues.pendingAdminActions)")
_Divider()
_KV("Inventory Batches","\(health.queues.pendingInventoryBatches)")
}
}
if let error = app.settings.errorMessage { _ErrorBanner(message: error) }
// Sign out
Button {
app.session.logout()
dismiss()
} label: {
HStack(spacing: 8) {
Image(systemName: "rectangle.portrait.and.arrow.right")
Text("Sign Out")
}
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(EdgeTheme.danger)
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(EdgeTheme.danger.opacity(0.10))
.overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(EdgeTheme.danger.opacity(0.22), lineWidth: 1))
)
}
.buttonStyle(_PressScaleStyle())
}
.padding(.horizontal, 16) // Apple's canonical content margin
.padding(.top, 16)
.padding(.bottom, 40)
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbarColorScheme(.dark, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
.foregroundStyle(EdgeTheme.accent)
.font(.system(size: 15, weight: .semibold, design: .rounded))
}
}
.task {
if let token = app.session.token {
await app.settings.refresh(token: token)
await app.session.sendHeartbeat(module: app.session.selectedModule, screen: "settings_sheet")
}
}
}
}
}
private struct _ProfileCard: View {
let name: String
let role: String
let email: String
var body: some View {
HStack(spacing: 16) {
ZStack {
Circle()
.fill(LinearGradient(colors: [EdgeTheme.accent, EdgeTheme.accentPurple], startPoint: .topLeading, endPoint: .bottomTrailing))
Text(String(name.prefix(1)).uppercased())
.font(.system(size: 22, weight: .heavy, design: .rounded))
.foregroundStyle(.white)
}
.frame(width: 56, height: 56)
VStack(alignment: .leading, spacing: 4) {
Text(name)
.font(.system(size: 17, weight: .bold, design: .rounded))
.foregroundStyle(EdgeTheme.foreground)
Text(role)
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(EdgeTheme.accent)
if !email.isEmpty {
Text(email)
.font(.system(size: 12, weight: .regular, design: .rounded))
.foregroundStyle(EdgeTheme.mutedFg)
}
}
Spacer()
}
.padding(18)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(EdgeTheme.cardGradient)
.overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(EdgeTheme.borderGlass, lineWidth: 1))
.shadow(color: .black.opacity(0.25), radius: 18, y: 8)
)
}
}
// MARK: Shared sub-components
private struct _ActionRow: View {
let icon: String
let label: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
// Tinted icon in a fixed 32×32 touch target Apple Settings icon grid
ZStack {
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(EdgeTheme.accent.opacity(0.14))
.frame(width: 32, height: 32)
Image(systemName: icon)
.font(.system(size: 14, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(EdgeTheme.accent)
}
Text(label)
.font(.system(.subheadline, design: .rounded, weight: .medium))
.foregroundStyle(EdgeTheme.foreground)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
}
// 11pt V Apple's standard list row height minus content height
.padding(.vertical, 11)
}
.buttonStyle(_PressScaleStyle())
}
}
private struct _TimelineRow: View {
let icon: String
let title: String
let subtitle: String
let trailing: String
let tint: Color
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Filled SF Symbol icon tinted, clear visual anchor
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(tint)
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(.subheadline, design: .rounded, weight: .semibold))
.foregroundStyle(EdgeTheme.foreground)
.lineLimit(1)
Text(subtitle)
.font(.system(.caption, design: .rounded, weight: .regular))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 8)
Text(trailing)
.font(.system(.caption2, design: .rounded, weight: .medium))
.foregroundStyle(Color(uiColor: .tertiaryLabel))
.lineLimit(1)
}
// 10pt V Apple's standard list row rhythm (Settings rows use 11pt)
.padding(.vertical, 10)
}
}
private struct _EmptyState: View {
let icon: String
let message: String
var body: some View {
VStack(spacing: 10) {
Image(systemName: icon)
.font(.system(size: 32, weight: .ultraLight))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Color(uiColor: .tertiaryLabel))
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineSpacing(2)
}
.frame(maxWidth: .infinity)
// 24pt V generous breathing room in an otherwise-dense list context
.padding(.vertical, 24)
}
}
private struct _ErrorBanner: View {
let message: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(EdgeTheme.danger)
Text(message)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(EdgeTheme.danger.opacity(0.85))
.fixedSize(horizontal: false, vertical: true)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(EdgeTheme.danger.opacity(0.09))
.overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(EdgeTheme.danger.opacity(0.16), lineWidth: 1))
)
}
}
private struct _InlineEditor: View {
let placeholder: String
@Binding var text: String
var multiline: Bool = false
var body: some View {
TextField(placeholder, text: $text, axis: multiline ? .vertical : .horizontal)
.font(.system(.subheadline, design: .rounded, weight: .regular))
.foregroundStyle(EdgeTheme.foreground)
.lineLimit(multiline ? 6 : 1)
// 14pt H / 11pt V matches Apple's inline search field proportions
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(text.isEmpty ? EdgeTheme.borderSubtle : EdgeTheme.borderAccent, lineWidth: 0.75)
)
)
}
}
private struct _KV: View {
let label: String
let value: String
init(_ label: String, _ value: String) {
self.label = label
self.value = value
}
var body: some View {
HStack(alignment: .firstTextBaseline) {
Text(label)
// Caption in secondary matches Settings label column
.font(.system(.caption, design: .rounded, weight: .regular))
.foregroundStyle(.secondary)
.frame(minWidth: 90, alignment: .leading)
Spacer(minLength: 12)
Text(value)
.font(.system(.callout, design: .rounded, weight: .medium))
.foregroundStyle(EdgeTheme.foreground)
.multilineTextAlignment(.trailing)
}
// 9pt V tight but not cramped; mirrors Xcode's inspector row height
.padding(.vertical, 9)
}
}
private func _Divider() -> some View {
// 0.5pt hairline Apple's physical pixel-precise separator (UITableView default)
// 16pt leading inset matches the card's 16pt horizontal content margin
Rectangle()
.fill(EdgeTheme.borderSubtle)
.frame(height: 0.5)
.padding(.leading, 16)
}
// MARK: Button Styles
private struct _ProminentButton: ButtonStyle {
let enabled: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(
enabled
? AnyShapeStyle(LinearGradient(colors: [EdgeTheme.accent, EdgeTheme.accentPurple], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.white.opacity(0.07))
)
.shadow(color: enabled ? EdgeTheme.accent.opacity(0.35) : .clear, radius: 12, y: 5)
)
.scaleEffect(configuration.isPressed ? 0.972 : 1)
.opacity(configuration.isPressed ? 0.88 : 1)
.animation(.spring(response: 0.20, dampingFraction: 0.70), value: configuration.isPressed)
}
}
private struct _ChipButton: ButtonStyle {
let tint: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(tint)
.frame(maxWidth: .infinity)
.frame(height: 42)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(tint.opacity(0.10))
.overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(tint.opacity(0.22), lineWidth: 1))
)
.scaleEffect(configuration.isPressed ? 0.972 : 1)
.animation(.spring(response: 0.18, dampingFraction: 0.70), value: configuration.isPressed)
}
}
// MARK: UIKit bridges
private struct VelocityImagePicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let onImage: (UIImage) -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.allowsEditing = false
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
private let parent: VelocityImagePicker
init(_ parent: VelocityImagePicker) { self.parent = parent }
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
parent.isPresented = false
}
}
}
private struct VelocityShareSheet: UIViewControllerRepresentable {
let image: UIImage
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [image], applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
// MARK: Extensions
private extension String {
var edgeRelativeShort: String {
guard let date = ISO8601DateFormatter().date(from: self) else { return self }
return date.edgeRelativeShort
}
}
private extension JSONValue {
var summaryText: String {
switch self {
case .string(let v): return v
case .number(let v): return String(v)
case .bool(let v): return v ? "Yes" : "No"
case .object(let d): return d.map { "\($0.key): \($0.value.summaryText)" }.prefix(4).joined(separator: " · ")
case .array(let l): return l.map(\.summaryText).prefix(4).joined(separator: ", ")
case .null: return ""
}
}
}
private extension Double {
var asCurrency: String {
let f = NumberFormatter()
f.numberStyle = .currency
f.currencyCode = "AED"
f.maximumFractionDigits = 0
return f.string(from: NSNumber(value: self)) ?? "AED \(Int(self))"
}
}