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))" } }