1925 lines
86 KiB
Swift
1925 lines
86 KiB
Swift
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))"
|
||
}
|
||
}
|