feat: Ipad app features and Dream Weaver for Velocity WebOS
Some checks failed
Production Readiness / backend-contracts (pull_request) Has been cancelled
Production Readiness / webos-typecheck (pull_request) Has been cancelled
Production Readiness / ipad-parse (pull_request) Has been cancelled

This commit is contained in:
Sayan Datta
2026-04-28 10:59:07 +05:30
parent 184bfa77f8
commit fefe8373ec
117 changed files with 19510 additions and 6383 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,490 @@
import Combine
import SwiftUI
struct ClientsView: View {
@State private var store = AppStore.shared
@State private var searchText = ""
@State private var selectedClient360: VelocityClient360DTO?
@State private var selectedPersonID: String?
@State private var isClient360Loading = false
@State private var client360Error: String?
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
.padding(.horizontal, 24)
.padding(.top, 24)
.padding(.bottom, 16)
if let error = store.errorMessage {
errorBanner(error)
.padding(.horizontal, 24)
.padding(.bottom, 14)
}
ScrollView {
VStack(alignment: .leading, spacing: 16) {
summaryPanel
searchPanel
contactsPanel
}
.padding(.horizontal, 24)
.padding(.bottom, 24)
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
.sheet(isPresented: client360PresentationBinding) {
client360Sheet
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Clients")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Canonical CRM contact workspace backed by `/api/crm/client-data` and client detail APIs.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
private var summaryPanel: some View {
HStack(spacing: 12) {
metricCard("Contacts", value: "\(store.contacts.count)", color: VelocityTheme.accent)
metricCard("Active Leads", value: "\(store.leads.count)", color: VelocityTheme.success)
metricCard("Open Tasks", value: "\(store.metrics.pendingTaskCount)", color: VelocityTheme.warning)
metricCard("High Intent", value: "\(highIntentCount)", color: VelocityTheme.danger)
}
}
private var searchPanel: some View {
HStack(spacing: 12) {
Image(systemName: "magnifyingglass")
.foregroundStyle(VelocityTheme.mutedFg)
TextField("Search by name, phone, interest, budget, or status", text: $searchText)
.textInputAutocapitalization(.words)
.foregroundStyle(VelocityTheme.foreground)
if !searchText.isEmpty {
Button("Clear") {
searchText = ""
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(16)
.glassCard(cornerRadius: 16)
}
private var contactsPanel: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Canonical Contacts")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(filteredContacts.count) shown")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingCard
} else if store.contacts.isEmpty {
emptyCard("No canonical contacts were returned for this operator scope yet.")
} else if filteredContacts.isEmpty {
emptyCard("No canonical contacts match this search.")
} else {
LazyVStack(spacing: 10) {
ForEach(filteredContacts) { contact in
contactCard(contact)
}
}
}
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func contactCard(_ contact: VelocityCanonicalContactListItemDTO) -> some View {
Button {
openClient360(for: contact.personId)
} label: {
HStack(alignment: .top, spacing: 14) {
ZStack {
Circle()
.fill(VelocityTheme.accent.opacity(0.14))
.frame(width: 42, height: 42)
Text(initials(for: contact.fullName))
.font(.system(size: 13, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 7) {
HStack {
Text(contact.fullName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(contact.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
Text("\(contact.buyerTypeLabel) · \(contact.leadStatusLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
Text("\(contact.budgetSummary) · \(contact.interestSummary)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text("\(contact.contactLine) · \(contact.pendingTasks) pending tasks · \(contact.interactionCount) interactions")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
private var loadingCard: some View {
HStack(spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading canonical contacts...")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func metricCard(_ label: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 21, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
RoundedRectangle(cornerRadius: 3)
.fill(color)
.frame(width: 42, height: 4)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
private var filteredContacts: [VelocityCanonicalContactListItemDTO] {
let normalized = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else {
return store.contacts
}
return store.contacts.filter { contact in
[
contact.fullName,
contact.primaryPhone ?? "",
contact.buyerType ?? "",
contact.leadStatus ?? "",
contact.budgetBand ?? "",
contact.primaryInterest ?? "",
contact.urgency ?? "",
]
.joined(separator: " ")
.lowercased()
.contains(normalized)
}
}
private var highIntentCount: Int {
store.contacts.filter { $0.displayIntentScore >= 80 }.count
}
private func initials(for name: String) -> String {
let initials = name
.split(separator: " ")
.prefix(2)
.compactMap(\.first)
return initials.isEmpty ? "C" : String(initials)
}
private var client360PresentationBinding: Binding<Bool> {
Binding(
get: { selectedPersonID != nil },
set: { isPresented in
if !isPresented {
selectedPersonID = nil
selectedClient360 = nil
client360Error = nil
isClient360Loading = false
}
}
)
}
@ViewBuilder
private var client360Sheet: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
if isClient360Loading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 40)
} else if let client360Error {
errorBanner(client360Error)
} else if let snapshot = selectedClient360 {
client360Snapshot(snapshot)
}
}
.padding(20)
}
.background(VelocityTheme.background)
.navigationTitle("Client 360")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
selectedPersonID = nil
selectedClient360 = nil
client360Error = nil
isClient360Loading = false
}
}
}
}
}
private func client360Snapshot(_ snapshot: VelocityClient360DTO) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 8) {
Text(snapshot.identity.fullName)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(snapshot.identity.primaryPhone ?? "No phone") · \(snapshot.identity.buyerType?.replacingOccurrences(of: "_", with: " ").capitalized ?? "CRM contact")")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
if let email = snapshot.identity.primaryEmail {
Text(email)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.identity.personaLabels.isEmpty {
Text(snapshot.identity.personaLabels.joined(separator: " · "))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
VStack(alignment: .leading, spacing: 10) {
if let lead = snapshot.currentLead {
sectionLine("Lead", value: "\(lead.status.replacingOccurrences(of: "_", with: " ").capitalized) · \(lead.budgetBand ?? "Budget pending")")
sectionLine("Urgency", value: lead.urgency?.replacingOccurrences(of: "_", with: " ").capitalized ?? "Normal")
if !lead.motivations.isEmpty {
Text("Motivations: \(lead.motivations.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !lead.objections.isEmpty {
Text("Objections: \(lead.objections.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
} else {
Text("No active canonical lead context was returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
sectionLine("Opportunities", value: "\(snapshot.activeOpportunities.count)")
sectionLine("Tasks", value: "\(snapshot.tasks.count)")
sectionLine("Interactions", value: "\(snapshot.recentInteractions.count)")
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
if !snapshot.activeOpportunities.isEmpty {
client360ListCard(title: "Active Opportunities") {
ForEach(snapshot.activeOpportunities) { opportunity in
VStack(alignment: .leading, spacing: 5) {
Text(opportunity.stage.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(opportunity.formattedValue) · \(opportunity.probabilityLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(opportunity.nextAction ?? "Next action pending")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
if !snapshot.propertyInterests.isEmpty {
client360ListCard(title: "Property Interests") {
ForEach(snapshot.propertyInterests) { interest in
VStack(alignment: .leading, spacing: 5) {
Text(interest.projectName)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text([interest.configuration, interest.unitPreference].compactMap { nonEmpty($0) }.joined(separator: " · "))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
client360ListCard(title: "Recent Interactions") {
if snapshot.recentInteractions.isEmpty {
Text("No recent canonical interactions were returned for this client.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(snapshot.recentInteractions) { interaction in
VStack(alignment: .leading, spacing: 5) {
Text("\(interaction.channel.capitalized) · \(interaction.interactionType.replacingOccurrences(of: "_", with: " ").capitalized)")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(interaction.summary ?? "No summary captured")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 4)
}
}
}
if !snapshot.tasks.isEmpty || !snapshot.recommendedNextActions.isEmpty || !snapshot.riskFlags.isEmpty {
client360ListCard(title: "Operator Actions") {
ForEach(snapshot.tasks) { task in
sectionLine(task.title, value: "\(task.priorityLabel) · \(task.dueLabel)")
}
ForEach(snapshot.recommendedNextActions, id: \.self) { action in
Text(action)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
ForEach(snapshot.riskFlags, id: \.self) { flag in
Text(flag.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.warning)
}
}
}
}
}
private func client360ListCard<Content: View>(
title: String,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
content()
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func sectionLine(_ title: String, value: String) -> some View {
HStack {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Text(value)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
}
}
private func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
private func openClient360(for personId: String) {
selectedPersonID = personId
selectedClient360 = nil
client360Error = nil
isClient360Loading = true
Task {
do {
let snapshot = try await VelocityAPIClient.shared.fetchClient360(personId: personId)
await MainActor.run {
selectedClient360 = snapshot
isClient360Loading = false
}
} catch {
await MainActor.run {
selectedClient360 = nil
client360Error = error.localizedDescription
isClient360Loading = false
}
}
}
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
}
#Preview {
ClientsView()
}

View File

@@ -0,0 +1,455 @@
import Combine
import SwiftUI
private struct CommunicationThread: Identifiable {
let id: String
let leadName: String
let channel: String
let status: String
let summary: String
let nextAction: String
let updatedAt: String
let accent: Color
}
private struct CommunicationAlert: Identifiable {
let id: String
let title: String
let detail: String
let severity: String
let color: Color
}
struct CommunicationsView: View {
@State private var selectedThread: String?
@State private var threads: [CommunicationThread] = []
@State private var alerts: [CommunicationAlert] = []
@State private var isLoading = true
@State private var errorMessage: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
private var activeThread: CommunicationThread? {
threads.first(where: { $0.id == selectedThread })
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let errorMessage {
errorBanner(errorMessage)
}
if isLoading {
loadingPanel
} else {
alertsStrip
HStack(alignment: .top, spacing: 18) {
threadRail
detailPanel
}
}
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await loadLiveData() }
.refreshable { await loadLiveData() }
.onReceive(refreshTimer) { _ in
Task { await loadLiveData(silent: true) }
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Communications")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Phone, WhatsApp, transcript, and memory edge across canonical CRM contacts with active lead context.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
statusBadge(label: "\(threads.count) live threads", color: VelocityTheme.success)
if let queueAlert = alerts.first(where: { $0.id == "pending_transcriptions" }) {
statusBadge(label: queueAlert.detail, color: queueAlert.color)
}
}
}
}
private var alertsStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(alerts) { alert in
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(alert.severity)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(alert.color)
Spacer()
Circle()
.fill(alert.color)
.frame(width: 8, height: 8)
}
Text(alert.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(alert.detail)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(3)
}
.padding(16)
.frame(width: 250, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
}
}
private var threadRail: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Active Threads")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if threads.isEmpty {
detailRow(title: "Live data", value: "No communication events have been captured for the current canonical CRM lead set yet.")
}
ForEach(threads) { thread in
Button {
selectedThread = thread.id
} label: {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(thread.leadName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(thread.channel)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(thread.updatedAt)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
Text(thread.summary)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(3)
HStack {
Text(thread.status.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(1)
.foregroundStyle(thread.accent)
Spacer()
Text(thread.nextAction)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedThread == thread.id ? thread.accent.opacity(0.12) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedThread == thread.id ? thread.accent.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
}
.padding(18)
.frame(maxWidth: 360, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private var detailPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(activeThread?.leadName ?? "Select a thread")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(activeThread?.channel ?? "Communication detail")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if let thread = activeThread {
statusBadge(label: thread.status, color: thread.accent)
}
}
VStack(alignment: .leading, spacing: 12) {
detailRow(title: "Latest summary", value: activeThread?.summary ?? "No thread selected")
detailRow(title: "Next operator action", value: activeThread?.nextAction ?? "None")
detailRow(title: "Memory extraction", value: activeThread != nil ? "Backed by persisted mobile-edge communication events and live backend alerts." : "No communication memory available.")
detailRow(title: "Suggested response", value: activeThread != nil ? "Use the current thread state, transcript queue, and calendar urgency to choose the next operator action." : "Select a thread to view live context.")
}
VStack(alignment: .leading, spacing: 12) {
Text("Recent activity")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(alerts.prefix(3)) { alert in
activityCard(icon: alertIcon(for: alert.id), title: alert.title, detail: alert.detail)
}
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .topLeading)
.glassCard(cornerRadius: 20)
}
private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
}
}
private func activityCard(icon: String, title: String, detail: String) -> some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(VelocityTheme.accent.opacity(0.14))
.frame(width: 38, height: 38)
Image(systemName: icon)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(detail)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
private func statusBadge(label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(color.opacity(0.12))
.overlay(
Capsule()
.stroke(color.opacity(0.22), lineWidth: 1)
)
)
}
private var loadingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading live communications...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Velocity is fetching canonical CRM contact summaries, communication events, and alert state from the backend.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 20)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
private func loadLiveData(silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
async let leadsTask = VelocityAPIClient.shared.fetchLeads()
async let alertsTask = VelocityAPIClient.shared.fetchAlerts()
let leads = try await leadsTask
let alertSnapshot = try await alertsTask
let topLeads = Array(leads.sorted(by: { $0.score > $1.score }).prefix(8))
var fetchedThreads: [CommunicationThread] = []
for lead in topLeads {
let events = (try? await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 1)) ?? []
let latest = events.first
fetchedThreads.append(
CommunicationThread(
id: lead.id,
leadName: lead.name,
channel: latest.map { channelLabel($0.channel) } ?? sourceLabel(lead.source),
status: statusLabel(for: lead, event: latest),
summary: latest?.summary ?? "No communication events captured yet for this lead.",
nextAction: nextActionLabel(for: lead, event: latest),
updatedAt: latest.map { relativeShort($0.timestamp) } ?? "No events",
accent: accentColor(for: lead, event: latest)
)
)
}
let fetchedAlerts = buildAlerts(from: alertSnapshot)
await MainActor.run {
threads = fetchedThreads
alerts = fetchedAlerts
if selectedThread == nil || !fetchedThreads.contains(where: { $0.id == selectedThread }) {
selectedThread = fetchedThreads.first?.id
}
errorMessage = nil
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
threads = []
alerts = []
isLoading = false
}
}
}
private func buildAlerts(from snapshot: VelocityAlertSnapshotDTO) -> [CommunicationAlert] {
[
CommunicationAlert(
id: "pending_insights",
title: "Pending insights",
detail: "\(snapshot.pendingInsights) insight recommendations need operator review.",
severity: "Priority",
color: VelocityTheme.danger
),
CommunicationAlert(
id: "pending_transcriptions",
title: "Transcription queue",
detail: "\(snapshot.pendingTranscriptions) transcript jobs waiting.",
severity: "Queue",
color: VelocityTheme.warning
),
CommunicationAlert(
id: "calendar_due",
title: "Calendar due soon",
detail: "\(snapshot.upcomingCalendarEvents24h) calendar events are due in the next 24 hours.",
severity: "Calendar",
color: VelocityTheme.success
),
]
}
private func statusLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
if event == nil {
if lead.pendingTaskCount > 0 {
return "Task pending"
}
return "No events yet"
}
if lead.score >= 90 {
return "Whale priority"
}
return lead.kanbanStatus
}
private func nextActionLabel(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> String {
if event?.recordingRef != nil {
return "Review transcript"
}
if lead.pendingTaskCount > 0 {
return lead.pendingTaskCount == 1 ? "Review pending task" : "Review \(lead.pendingTaskCount) tasks"
}
if lead.score >= 90 {
return "Schedule follow-up"
}
return "Update operator note"
}
private func accentColor(for lead: VelocityLeadDTO, event: VelocityCommunicationEventDTO?) -> Color {
if event?.recordingRef != nil {
return VelocityTheme.accent
}
if lead.score >= 90 {
return VelocityTheme.success
}
return VelocityTheme.warning
}
private func alertIcon(for id: String) -> String {
switch id {
case "pending_transcriptions":
return "waveform.badge.mic"
case "calendar_due":
return "calendar.badge.plus"
default:
return "brain.head.profile"
}
}
private func channelLabel(_ value: String) -> String {
value.replacingOccurrences(of: "_", with: " ").capitalized
}
private func sourceLabel(_ value: String) -> String {
value.replacingOccurrences(of: "_", with: " ").capitalized
}
private func relativeShort(_ iso: String) -> String {
let formatter = ISO8601DateFormatter()
guard let date = formatter.date(from: iso) else {
return iso
}
let delta = Int(Date().timeIntervalSince(date))
if delta < 60 { return "now" }
if delta < 3600 { return "\(delta / 60)m ago" }
if delta < 86400 { return "\(delta / 3600)h ago" }
return "\(delta / 86400)d ago"
}
}
#Preview {
CommunicationsView()
}

View File

@@ -0,0 +1,333 @@
import Combine
import SwiftUI
struct DashboardView: View {
@State private var store = AppStore.shared
@State private var session = SessionStore.shared
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let error = store.errorMessage {
errorBanner(error)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingPanel
} else {
metricsGrid
liveStatusPanel
followUpLoadPanel
leadFocusPanel
inventoryPanel
}
}
.padding(20)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Dashboard")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live mobile operator posture for canonical CRM pipeline, inventory, and follow-up load.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
statusBadge(
label: session.isConfigured ? "Live backend" : "Config required",
color: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
)
if let lastRefresh = store.lastRefreshAt {
Text("Updated \(lastRefresh.relativeShort)")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
}
}
private var metricsGrid: some View {
LazyVGrid(columns: columns, spacing: 14) {
MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent)
MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success)
MetricCard(title: "Pending Tasks", value: "\(store.metrics.pendingTaskCount)", subtitle: "Canonical CRM reminders", color: VelocityTheme.warning)
MetricCard(title: "Urgent Tasks", value: "\(store.metrics.urgentTaskCount)", subtitle: "High and urgent follow-ups", color: VelocityTheme.danger)
MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning)
MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99))
}
}
private var liveStatusPanel: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Live Status")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
statusBadge(label: session.authModeDescription, color: VelocityTheme.accent)
}
detailRow(title: "Endpoint", value: session.endpointDisplay)
detailRow(title: "Operator", value: session.operatorIdentity)
detailRow(title: "Pending CRM tasks", value: "\(store.metrics.pendingTaskCount)")
detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)")
detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)")
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var followUpLoadPanel: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Follow-Up Load")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if store.prioritizedTasks.isEmpty {
emptyMessage("No canonical CRM reminder tasks are pending for this operator right now.")
} else {
ForEach(store.prioritizedTasks.prefix(4)) { task in
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(task.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(task.ownerLabel) · \(task.dueLabel)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
}
Spacer()
Text(task.priorityLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(priorityColor(for: task.priority))
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var leadFocusPanel: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Client Focus")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if store.highlightedLeads.isEmpty {
emptyMessage("No canonical CRM contacts with active lead context have been returned by the backend yet.")
} else {
ForEach(store.highlightedLeads) { lead in
HStack(alignment: .top, spacing: 14) {
VStack(alignment: .leading, spacing: 5) {
Text(lead.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(lead.unitInterest) · \(lead.budget)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text("\(lead.score)")
.font(.system(size: 20, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(lead.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var inventoryPanel: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Inventory Coverage")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if store.properties.isEmpty {
emptyMessage("No live inventory properties are available yet for this operator scope.")
} else {
ForEach(store.properties.prefix(4)) { property in
VStack(alignment: .leading, spacing: 6) {
Text(property.projectName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(property.developerName) · \(property.propertyType.capitalized)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(property.locationSummary)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private func detailRow(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
}
}
private func emptyMessage(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func statusBadge(label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(color.opacity(0.12))
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
)
}
private var loadingPanel: some View {
VStack(alignment: .leading, spacing: 12) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text("Loading live dashboard data...")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Velocity is reading canonical CRM contacts, reminders, alerts, calendar events, and inventory summaries from the backend.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
private func priorityColor(for priority: String) -> Color {
switch priority.lowercased() {
case "urgent":
return VelocityTheme.danger
case "high":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
private func taskNote(_ task: VelocityTaskDTO) -> String {
let note = task.notes?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return note.isEmpty ? "No operator note yet." : note
}
}
private struct MetricCard: View {
let title: String
let value: String
let subtitle: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
RoundedRectangle(cornerRadius: 4)
.fill(color)
.frame(width: 52, height: 4)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
#Preview {
DashboardView()
}

View File

@@ -0,0 +1,467 @@
import Combine
import SwiftUI
struct ImportsView: View {
@State private var batches: [VelocityImportBatchSummaryDTO] = []
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
@State private var detail: VelocityImportBatchDetailDTO?
@State private var isLoading = false
@State private var isCommitting = false
@State private var activeProposalID: String?
@State private var errorMessage: String?
@State private var successMessage: String?
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
var body: some View {
HStack(spacing: 0) {
batchRail
.frame(width: 350)
.background(VelocityTheme.sidebarBg)
Divider()
.background(VelocityTheme.borderSubtle)
detailPane
}
.background(VelocityTheme.background)
.task { await loadBatches(selectFirst: true) }
.refreshable { await loadBatches(selectFirst: false) }
.onReceive(refreshTimer) { _ in
Task { await loadBatches(selectFirst: false, silent: true) }
}
}
private var batchRail: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Imports")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Canonical CRM import review and commit queue.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 18)
.padding(.top, 22)
if let errorMessage {
errorBanner(errorMessage)
.padding(.horizontal, 18)
}
if let successMessage {
successBanner(successMessage)
.padding(.horizontal, 18)
}
ScrollView {
LazyVStack(spacing: 10) {
if isLoading && batches.isEmpty {
loadingCard("Loading import batches...")
} else if batches.isEmpty {
emptyCard("No canonical import batches were returned yet.")
} else {
ForEach(batches) { batch in
batchCard(batch)
}
}
}
.padding(.horizontal, 18)
.padding(.bottom, 18)
}
}
}
private var detailPane: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
if let detail {
detailHeader(detail)
proposalsPanel(detail)
} else if isLoading {
loadingCard("Loading import detail...")
} else {
emptyCard("Select an import batch to review canonical proposals.")
}
}
.padding(24)
}
.background(VelocityTheme.background)
}
private func batchCard(_ batch: VelocityImportBatchSummaryDTO) -> some View {
Button {
Task { await selectBatch(batch) }
} label: {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(batch.displayName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(1)
Spacer()
Text(batch.lifecycleLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(lifecycleColor(batch.lifecycle))
}
Text("\(batch.rowCount) rows · \(batch.mappedCount ?? 0) mapped · \(batch.unresolvedCount ?? 0) unresolved")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
Text(batch.sourceSystem ?? "Unknown source")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(selectedBatch?.batchId == batch.batchId ? VelocityTheme.accent.opacity(0.14) : VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(selectedBatch?.batchId == batch.batchId ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
}
private func detailHeader(_ detail: VelocityImportBatchDetailDTO) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text(detail.filename ?? "CRM import")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(detail.rowCount) rows · \(detail.proposalCount) proposals · \(detail.sourceSystem ?? "Unknown source")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text(detail.lifecycle.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(lifecycleColor(detail.lifecycle))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(lifecycleColor(detail.lifecycle).opacity(0.12)))
}
HStack(spacing: 12) {
metricCard("Pending", value: "\(detail.proposals.filter { $0.status == "pending" }.count)", color: VelocityTheme.warning)
metricCard("Approved", value: "\(detail.proposals.filter { $0.status == "approved" }.count)", color: VelocityTheme.success)
metricCard("Rejected", value: "\(detail.proposals.filter { $0.status == "rejected" }.count)", color: VelocityTheme.danger)
}
Button {
Task { await commitSelectedBatch() }
} label: {
HStack {
if isCommitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isCommitting ? "Committing..." : "Commit Approved Proposals")
.font(.system(size: 13, weight: .semibold))
}
.foregroundStyle(.white)
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule()
.fill(approvedCount(detail) > 0 && !isCommitting ? VelocityTheme.success : VelocityTheme.subtleFg)
)
}
.buttonStyle(.plain)
.disabled(approvedCount(detail) == 0 || isCommitting)
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func proposalsPanel(_ detail: VelocityImportBatchDetailDTO) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Review Proposals")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if detail.proposals.isEmpty {
emptyCard("No proposals were returned for this import batch.")
} else {
ForEach(detail.proposals) { proposal in
proposalCard(proposal, batchId: detail.batchId)
}
}
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(proposal.rowLabel)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(proposal.confidencePercent)% confidence · \(proposal.status.capitalized)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if activeProposalID == proposal.proposalId {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
} else {
proposalActions(proposal, batchId: batchId)
}
}
if let canonical = proposal.payload?.canonicalPayload, !canonical.isEmpty {
Text(canonicalPreview(canonical))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(3)
}
if let missing = proposal.payload?.missingRequired, !missing.isEmpty {
Text("Missing: \(missing.joined(separator: ", "))")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.danger)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
HStack(spacing: 8) {
Button("Approve") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "approved")
Button("Reject") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.danger)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "rejected")
}
}
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
if !silent {
isLoading = true
}
do {
let fetched = try await VelocityAPIClient.shared.fetchImportBatches()
await MainActor.run {
batches = fetched
errorMessage = nil
isLoading = false
}
if selectFirst, selectedBatch == nil, let first = fetched.first {
await selectBatch(first)
} else if let selectedBatch {
await refreshDetail(batchId: selectedBatch.batchId, silent: true)
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func selectBatch(_ batch: VelocityImportBatchSummaryDTO) async {
await MainActor.run {
selectedBatch = batch
detail = nil
errorMessage = nil
successMessage = nil
isLoading = true
}
await refreshDetail(batchId: batch.batchId)
}
private func refreshDetail(batchId: String, silent: Bool = false) async {
if !silent {
await MainActor.run { isLoading = true }
}
do {
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
await MainActor.run {
detail = fetched
errorMessage = nil
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
private func reviewProposal(
batchId: String,
proposal: VelocityImportProposalDTO,
decision: String
) async {
await MainActor.run {
activeProposalID = proposal.proposalId
errorMessage = nil
successMessage = nil
}
do {
_ = try await VelocityAPIClient.shared.reviewImportProposal(
batchId: batchId,
proposalId: proposal.proposalId,
decision: decision,
notes: "Reviewed from iPad Imports workspace."
)
await refreshDetail(batchId: batchId, silent: true)
await MainActor.run {
activeProposalID = nil
successMessage = "Proposal \(decision)."
}
} catch {
await MainActor.run {
activeProposalID = nil
errorMessage = error.localizedDescription
}
}
}
private func commitSelectedBatch() async {
guard let batchId = detail?.batchId else {
return
}
await MainActor.run {
isCommitting = true
errorMessage = nil
successMessage = nil
}
do {
let result = try await VelocityAPIClient.shared.commitImportBatch(batchId: batchId)
await loadBatches(selectFirst: false, silent: true)
await refreshDetail(batchId: batchId, silent: true)
await MainActor.run {
isCommitting = false
successMessage = "Committed \(result.committed), skipped \(result.skipped)."
if !result.errors.isEmpty {
errorMessage = result.errors.joined(separator: " · ")
}
}
} catch {
await MainActor.run {
isCommitting = false
errorMessage = error.localizedDescription
}
}
}
private func approvedCount(_ detail: VelocityImportBatchDetailDTO) -> Int {
detail.proposals.filter { $0.status == "approved" }.count
}
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
payload
.sorted(by: { $0.key < $1.key })
.prefix(5)
.map { "\($0.key): \($0.value.stringValue ?? "-")" }
.joined(separator: " · ")
}
private func metricCard(_ label: String, value: String, color: Color) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(label.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 18, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
RoundedRectangle(cornerRadius: 3)
.fill(color)
.frame(width: 34, height: 3)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
}
private func loadingCard(_ message: String) -> some View {
HStack(spacing: 10) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 16).fill(VelocityTheme.surface))
}
private func errorBanner(_ message: String) -> some View {
banner(message, color: VelocityTheme.danger)
}
private func successBanner(_ message: String) -> some View {
banner(message, color: VelocityTheme.success)
}
private func banner(_ message: String, color: Color) -> some View {
Text(message)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(color)
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(color.opacity(0.10))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(color.opacity(0.22), lineWidth: 1))
)
}
private func lifecycleColor(_ lifecycle: String) -> Color {
switch lifecycle.lowercased() {
case "committed":
return VelocityTheme.success
case "failed":
return VelocityTheme.danger
case "approved", "proposed", "parsed":
return VelocityTheme.warning
default:
return VelocityTheme.accent
}
}
}
#Preview {
ImportsView()
}

View File

@@ -0,0 +1,276 @@
import ARKit
import CoreLocation
import CoreMotion
import SceneKit
import SwiftUI
// MARK: - ARSunOverlayView
struct ARSunOverlayView: UIViewRepresentable {
@Binding var sunNodesReady: Bool
let vm: SunseekerViewModel
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady, vm: vm)
}
func makeUIView(context: Context) -> ARSCNView {
let view = ARSCNView(frame: .zero)
view.delegate = context.coordinator
view.scene = SCNScene()
view.automaticallyUpdatesLighting = true
let config = ARWorldTrackingConfiguration()
config.worldAlignment = .gravityAndHeading // north = -Z axis
view.session.run(config)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: ARSCNView, context: Context) {}
static func dismantleUIView(_ uiView: ARSCNView, coordinator: Coordinator) {
uiView.session.pause()
coordinator.stop()
}
// MARK: - Coordinator
final class Coordinator: NSObject, ARSCNViewDelegate, CLLocationManagerDelegate {
private weak var sceneView: ARSCNView?
private let vm: SunseekerViewModel
@Binding private var sunNodesReady: Bool
// Scene node containers (replaced on each rebuild)
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var isSceneBuilt = false
// Fallback timer for CoreMotion-only mode
private var fallbackTimer: Timer?
private var limitedTrackingStart: Date?
init(sunNodesReady: Binding<Bool>, vm: SunseekerViewModel) {
_sunNodesReady = sunNodesReady
self.vm = vm
}
func attach(to sceneView: ARSCNView) {
self.sceneView = sceneView
sceneView.scene.rootNode.addChildNode(arcRootNode)
sceneView.scene.rootNode.addChildNode(currentSunNode)
}
func stop() {
vm.stop()
fallbackTimer?.invalidate()
}
// MARK: - ARSCNViewDelegate per-frame update
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
guard vm.isReady else { return }
// Build arc once
if !isSceneBuilt {
DispatchQueue.main.async { self.buildScene() }
}
// Update current sun orb every frame
if let cur = vm.currentPosition {
let pos = vm.worldPosition(for: cur, radius: 1.8)
currentSunNode.position = pos
}
}
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .limited(let reason):
print("[Sunseeker] Tracking limited: \(reason)")
if limitedTrackingStart == nil {
limitedTrackingStart = Date()
// After 5s of limited tracking, switch to CoreMotion attitude fallback
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
self?.activateCoreMotionFallback()
}
}
case .normal:
limitedTrackingStart = nil
fallbackTimer?.invalidate()
fallbackTimer = nil
case .notAvailable:
break
@unknown default:
break
}
}
// MARK: - Scene Building
private func buildScene() {
guard sceneView != nil else { return }
// Remove old nodes
arcRootNode.childNodes.forEach { $0.removeFromParentNode() }
currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
let arc = vm.arc
let radius: Float = 1.8
var positions: [SCNVector3] = []
// Hourly marker spheres + time labels
for (date, pos) in arc {
guard pos.elevation > -5 else { continue }
let worldPos = vm.worldPosition(for: pos, radius: radius)
positions.append(worldPos)
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = worldPos
arcRootNode.addChildNode(markerNode)
// Time label (only on even hours to avoid clutter)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
if hour % 2 == 0 {
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
arcRootNode.addChildNode(labelNode)
}
}
// Continuous arc line
if positions.count >= 2 {
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
arcRootNode.addChildNode(lineNode)
}
// Sunrise marker
if let riseDate = vm.riseSet.rise {
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: vm.coordinate!)
let wPos = vm.worldPosition(for: risePos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
}
// Sunset marker
if let setDate = vm.riseSet.set {
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: vm.coordinate!)
let wPos = vm.worldPosition(for: setPos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
}
// Current sun orb (large, animated glow)
if let cur = vm.currentPosition {
let orb = SCNSphere(radius: 0.055)
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
orb.firstMaterial?.emission.contents = UIColor.systemYellow
orb.firstMaterial?.lightingModel = .constant
let orbNode = SCNNode(geometry: orb)
orbNode.position = vm.worldPosition(for: cur, radius: radius)
// Pulse animation
let pulse = CABasicAnimation(keyPath: "scale")
pulse.fromValue = SCNVector3(1, 1, 1)
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
pulse.duration = 1.2
pulse.autoreverses = true
pulse.repeatCount = .infinity
orbNode.addAnimation(pulse, forKey: "pulse")
let label = makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
label.position = SCNVector3(0, 0.09, 0)
orbNode.addChildNode(label)
currentSunNode.addChildNode(orbNode)
}
isSceneBuilt = true
sunNodesReady = true
}
// MARK: - Node Factories
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
let root = SCNNode()
let sphere = SCNSphere(radius: 0.035)
sphere.firstMaterial?.diffuse.contents = color
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = pos
root.addChildNode(markerNode)
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
root.addChildNode(labelNode)
return root
}
/// Creates a billboard SCNText node that always faces the camera.
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
let scnText = SCNText(string: text, extrusionDepth: 0)
scnText.font = UIFont.systemFont(ofSize: fontSize * 100, weight: .medium)
scnText.firstMaterial?.diffuse.contents = color
scnText.firstMaterial?.lightingModel = .constant
scnText.isWrapped = false
let textNode = SCNNode(geometry: scnText)
textNode.scale = SCNVector3(fontSize / 100, fontSize / 100, fontSize / 100)
// Billboard constraint always face camera
let constraint = SCNBillboardConstraint()
constraint.freeAxes = .Y
textNode.constraints = [constraint]
// Centre text
let (min, max) = textNode.boundingBox
textNode.pivot = SCNMatrix4MakeTranslation((max.x - min.x) / 2, 0, 0)
return textNode
}
/// Builds a line strip SCNNode connecting all positions.
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))
indices.append(Int32(i + 1))
}
let vertexSource = SCNGeometrySource(vertices: vertices)
let element = SCNGeometryElement(
indices: indices,
primitiveType: .line
)
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
geometry.firstMaterial?.diffuse.contents = color
geometry.firstMaterial?.lightingModel = .constant
return SCNNode(geometry: geometry)
}
private func hourLabel(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "ha"
fmt.amSymbol = "am"
fmt.pmSymbol = "pm"
return fmt.string(from: date)
}
// MARK: - CoreMotion Fallback
private func activateCoreMotionFallback() {
// In fallback mode we rely on CMMotionManager attitude (already running in SunseekerViewModel)
// and just keep the scene nodes updated via the 1s tick in the VM.
print("[Sunseeker] Switched to CoreMotion fallback — ARKit tracking unavailable.")
}
}
}
// MARK: - Degree helpers
private extension Double {
var radians: Double { self * .pi / 180.0 }
}

View File

@@ -0,0 +1,35 @@
import Foundation
enum InventoryModeAvailability {
static let dollhouseAssetCandidates: [(name: String, ext: String)] = [
("Building", "usdz"),
("Building", "scn"),
]
static func hasShippedDollhouseAsset(in bundle: Bundle = .main) -> Bool {
dollhouseAssetCandidates.contains { candidate in
bundle.url(forResource: candidate.name, withExtension: candidate.ext) != nil
}
}
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
if hasDollhouseAsset {
modes.append(.dollhouse)
}
return modes
}
static func sanitizedProductionSelection(
_ candidate: InventoryStore.Mode,
hasDollhouseAsset: Bool
) -> InventoryStore.Mode {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).contains(candidate) ? candidate : .sunseeker
}
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
.map(\.rawValue)
.joined(separator: " · ")
}
}

View File

@@ -0,0 +1,852 @@
import AVFoundation
import Observation
import SceneKit
import SwiftUI
import UIKit
@Observable
final class InventoryStore {
enum Mode: String, CaseIterable, Identifiable {
case sunseeker = "Sunseeker"
case dreamWeaver = "Dream Weaver"
case dollhouse = "Dollhouse"
var id: String { rawValue }
}
var mode: Mode = .sunseeker
var sourceImage: UIImage?
var generatedImage: UIImage?
var isProcessing: Bool = false
var sunNodesReady: Bool = false
var dollhouseHour: Double = 12
// Error message shown in the DreamWeaver panel
var errorMessage: String?
}
struct InventoryView: View {
@State private var store = InventoryStore()
@State private var showCamera = false
@State private var sliderTickHour = 12
@State private var showShareSheet = false
@State private var shareImage: UIImage? = nil
private let hasDollhouseAsset = InventoryModeAvailability.hasShippedDollhouseAsset()
private let haptics = UIImpactFeedbackGenerator(style: .light)
private var visibleModes: [InventoryStore.Mode] {
InventoryModeAvailability.productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
}
private var selectedMode: InventoryStore.Mode {
InventoryModeAvailability.sanitizedProductionSelection(store.mode, hasDollhouseAsset: hasDollhouseAsset)
}
private var modeSelection: Binding<InventoryStore.Mode> {
Binding(
get: { selectedMode },
set: { newValue in
store.mode = InventoryModeAvailability.sanitizedProductionSelection(
newValue,
hasDollhouseAsset: hasDollhouseAsset
)
}
)
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
// Page header share button sits on the same baseline as the title
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 4) {
Text("Inventory")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(InventoryModeAvailability.modeSummaryText(hasDollhouseAsset: hasDollhouseAsset))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if let img = store.generatedImage {
Button {
shareImage = img
showShareSheet = true
} label: {
Image(systemName: "square.and.arrow.up")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.transition(.opacity.combined(with: .scale))
}
}
.animation(.easeInOut(duration: 0.2), value: store.generatedImage != nil)
.padding(.horizontal, 20)
.padding(.top, 20)
Picker("Mode", selection: modeSelection) {
ForEach(visibleModes) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
.padding(.horizontal, 20)
.padding(.top, 12)
if !hasDollhouseAsset {
ProductionScopeCard(
icon: "cube.transparent",
title: "Dollhouse hidden in this production build",
message: "Dollhouse stays out of the production iPad scope until a verified Building.usdz or Building.scn asset is shipped in the app bundle."
)
.padding(.horizontal, 20)
}
Group {
switch selectedMode {
case .sunseeker:
#if targetEnvironment(simulator)
SimulatorUnavailableCard(
icon: "arkit",
title: "Sunseeker requires a real device",
message: "The production build no longer renders a simulated AR sun path with fake location or heading data. Use a physical iPad to inspect the live camera-based overlay."
)
#else
SunseekerPanel(sunNodesReady: $store.sunNodesReady)
#endif
case .dreamWeaver:
// No simulator guard here CameraPicker automatically falls back
// to the photo library when no camera is available (e.g. Simulator),
// so the full Capture Reimagine API flow is testable without a device.
DreamWeaverPanel(
sourceImage: $store.sourceImage,
generatedImage: $store.generatedImage,
isProcessing: $store.isProcessing,
errorMessage: $store.errorMessage,
showCamera: $showCamera
)
case .dollhouse:
DollhousePanel(hour: $store.dollhouseHour, tickHour: $sliderTickHour, haptics: haptics)
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
.animation(.easeInOut(duration: 0.25), value: store.mode)
}
.background(VelocityTheme.background)
.simultaneousGesture(
TapGesture().onEnded {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
)
.onAppear {
store.mode = selectedMode
UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(
red: 0.231, green: 0.510, blue: 0.965, alpha: 0.85)
UISegmentedControl.appearance().setTitleTextAttributes(
[.foregroundColor: UIColor.white], for: .selected)
UISegmentedControl.appearance().setTitleTextAttributes(
[.foregroundColor: UIColor(white: 0.62, alpha: 1)], for: .normal)
UISegmentedControl.appearance().backgroundColor = UIColor(
red: 0.031, green: 0.039, blue: 0.071, alpha: 1)
}
.sheet(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { captured in
// Normalise orientation immediately on capture
store.sourceImage = captured.fixedOrientation()
// Clear previous result and error when a new photo is taken
store.generatedImage = nil
store.errorMessage = nil
}
}
.sheet(item: $shareImage) { img in
ShareSheet(image: img)
.ignoresSafeArea()
}
}
}
// MARK: - Shared simulator placeholder
private struct SimulatorUnavailableCard: View {
let icon: String
let title: String
let message: String
var body: some View {
ZStack {
VStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 40))
.foregroundStyle(VelocityTheme.mutedFg)
Text(title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.multilineTextAlignment(.center)
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
}
private struct ProductionScopeCard: View {
let icon: String
let title: String
let message: String
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.top, 2)
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(message)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderAccent, lineWidth: 1)
)
)
}
}
// MARK: - Sunseeker
private struct SunseekerPanel: View {
@Binding var sunNodesReady: Bool
@State private var vm = SunseekerViewModel()
var body: some View {
ZStack(alignment: .topLeading) {
ARSunOverlayView(sunNodesReady: $sunNodesReady, vm: vm)
.clipShape(RoundedRectangle(cornerRadius: 20))
// Retained as a stylistic design element framing the AR view
DashedSunLine()
.stroke(Color.yellow.opacity(0.9), style: StrokeStyle(lineWidth: 3, dash: [10, 8]))
.padding(.horizontal, 24)
.padding(.vertical, 80)
VStack(alignment: .leading, spacing: 12) {
// Info block
VStack(alignment: .leading, spacing: 8) {
Text("Sunseeker")
.font(.headline)
Text("Point the iPad toward windows to inspect yearly sun-entry path.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(14)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
if !vm.isReady && vm.locationError == nil {
// Loading state
HStack(spacing: 8) {
ProgressView().tint(.white)
Text("Looking for the Sun...")
.font(.footnote)
.foregroundStyle(.white)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color.black.opacity(0.6).clipShape(Capsule()))
}
// Error banner (e.g. Location Denied)
if let error = vm.locationError {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(error)
.font(.subheadline)
.foregroundStyle(.white)
Spacer()
Button("Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.borderedProminent)
.tint(.white.opacity(0.2))
}
.padding(14)
.background {
RoundedRectangle(cornerRadius: 14)
.fill(Color.red.opacity(0.8))
}
}
}
.padding(20)
}
}
}
// MARK: - Dream Weaver
/// Available room types from integration guide §2
private struct RoomType: Identifiable {
let id: String // sent as the `room_type` form field
let displayName: String
let icon: String // SF Symbol
}
private let roomTypes: [RoomType] = [
RoomType(id: "bedroom", displayName: "Bedroom", icon: "bed.double"),
RoomType(id: "living_room", displayName: "Living Rm", icon: "sofa"),
RoomType(id: "bathroom", displayName: "Bathroom", icon: "drop"),
RoomType(id: "kitchen", displayName: "Kitchen", icon: "refrigerator"),
RoomType(id: "dining_room", displayName: "Dining Rm", icon: "fork.knife"),
RoomType(id: "home_office", displayName: "Office", icon: "desktopcomputer"),
RoomType(id: "hallway", displayName: "Hallway", icon: "door.left.hand.open"),
RoomType(id: "balcony", displayName: "Balcony", icon: "sun.max"),
]
private struct DreamWeaverPanel: View {
@Binding var sourceImage: UIImage?
@Binding var generatedImage: UIImage?
@Binding var isProcessing: Bool
@Binding var errorMessage: String?
@Binding var showCamera: Bool
/// Selected room type ID sent as `room_type` field. nil = none chosen yet.
@State private var selectedRoomType: String? = nil
/// Optional extra keywords sent as `keywords` field (§3.2)
@State private var keywords: String = ""
/// Server health: nil = checking, true = online, false = offline
@State private var serverOnline: Bool? = nil
var body: some View {
VStack(spacing: 14) {
// Preview card
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(Color.black.opacity(0.9))
if let sourceImage {
Image(uiImage: sourceImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
} else {
ContentUnavailableView(
"No Capture",
systemImage: "camera.viewfinder",
description: Text("Tap Capture to snap a room.")
)
.foregroundStyle(.white)
}
if let generatedImage {
Image(uiImage: generatedImage)
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(12)
.transition(.opacity)
}
if isProcessing { ProcessingOverlay() }
// Server health badge top-right corner
VStack {
HStack {
Spacer()
HStack(spacing: 5) {
Circle()
.fill(serverOnline == true ? Color.green : serverOnline == false ? Color.red : Color.gray)
.frame(width: 7, height: 7)
Text(serverOnline == true ? "Server Online" : serverOnline == false ? "Server Offline" : "Checking...")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white.opacity(0.8))
}
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(.black.opacity(0.45))
.clipShape(Capsule())
.padding(14)
}
Spacer()
}
}
.frame(maxWidth: .infinity, minHeight: 420)
.animation(.easeInOut(duration: 0.35), value: generatedImage)
// Error banner
if let errorMessage {
HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
Text(errorMessage)
.font(.footnote)
.foregroundStyle(VelocityTheme.foreground)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.red.opacity(0.15))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.red.opacity(0.35), lineWidth: 1)
)
)
.transition(.move(edge: .top).combined(with: .opacity))
}
// Room Type picker
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(roomTypes) { room in
Button {
withAnimation(.spring(response: 0.3)) {
// Tap again to deselect
selectedRoomType = selectedRoomType == room.id ? nil : room.id
}
} label: {
HStack(spacing: 6) {
Image(systemName: room.icon)
.font(.system(size: 11, weight: .medium))
Text(room.displayName)
.font(.system(size: 13, weight: .medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(
Capsule()
.fill(selectedRoomType == room.id
? Color(red: 0.231, green: 0.510, blue: 0.965)
: Color.white.opacity(0.08))
)
.foregroundStyle(selectedRoomType == room.id ? .white : .white.opacity(0.6))
.overlay(
Capsule()
.stroke(selectedRoomType == room.id
? Color.clear
: Color.white.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 2)
}
// Keywords input
PromptInputBar(
text: $keywords,
isDisabled: sourceImage == nil || isProcessing || serverOnline == false
) {
Task { await generate() }
}
// Capture / Retake
Button(sourceImage == nil ? "Capture" : "Retake") {
showCamera = true
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.disabled(isProcessing)
}
.padding(16)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 18))
.contentShape(RoundedRectangle(cornerRadius: 18))
.onTapGesture {
UIApplication.shared.sendAction(
#selector(UIResponder.resignFirstResponder),
to: nil, from: nil, for: nil
)
}
}
.animation(.easeInOut(duration: 0.3), value: errorMessage)
.task { serverOnline = await ComfyClient.shared.checkHealth() }
}
@MainActor
private func generate() async {
guard let sourceImage, !isProcessing else { return }
if serverOnline == false {
errorMessage = "Server is currently offline. Please try again later."
return
}
isProcessing = true
errorMessage = nil
do {
let result = try await ComfyClient.shared.generateImage(
source: sourceImage,
roomType: selectedRoomType ?? roomTypes[0].id, // default: bedroom
keywords: keywords.trimmingCharacters(in: .whitespaces)
)
withAnimation(.easeInOut(duration: 0.4)) {
generatedImage = result
}
} catch {
errorMessage = error.localizedDescription
}
isProcessing = false
}
}
// MARK: - Prompt Input Bar
private struct PromptInputBar: View {
@Binding var text: String
let isDisabled: Bool
let onSubmit: () -> Void
@FocusState private var isFocused: Bool
@State private var shimmer = false
private let placeholder = "gold, marble, luxury, etc."
var body: some View {
HStack(spacing: 10) {
ZStack(alignment: .leading) {
if text.isEmpty {
Text(placeholder)
.font(.system(size: 14))
.foregroundStyle(Color.white.opacity(0.35))
.padding(.leading, 4)
.allowsHitTesting(false) // let taps pass through to the gesture below
}
TextField("", text: $text, axis: .vertical)
.font(.system(size: 14))
.foregroundStyle(Color.white)
.lineLimit(1...3)
.focused($isFocused)
.submitLabel(.send)
.onSubmit {
guard !isDisabled, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return }
onSubmit()
}
.tint(Color(red: 0.231, green: 0.510, blue: 0.965))
}
.contentShape(Rectangle()) // expand hit area to full ZStack bounds
.onTapGesture { isFocused = true } // focus immediately on any tap
// Send arrow button
Button {
isFocused = false
onSubmit()
} label: {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.231, green: 0.510, blue: 0.965),
Color(red: 0.40, green: 0.25, blue: 0.95)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 36, height: 36)
Image(systemName: "arrow.up")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
}
.disabled(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty)
.opacity(isDisabled || text.trimmingCharacters(in: .whitespaces).isEmpty ? 0.4 : 1.0)
.animation(.easeInOut(duration: 0.2), value: text.isEmpty)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color.white.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(
isFocused
? Color(red: 0.231, green: 0.510, blue: 0.965).opacity(0.8)
: Color.white.opacity(0.12),
lineWidth: 1
)
)
)
.animation(.easeInOut(duration: 0.2), value: isFocused)
}
}
// MARK: - Dollhouse
private struct DollhousePanel: View {
@Binding var hour: Double
@Binding var tickHour: Int
let haptics: UIImpactFeedbackGenerator
var body: some View {
VStack(spacing: 12) {
SceneKitDollhouseView(hour: $hour)
.clipShape(RoundedRectangle(cornerRadius: 20))
.frame(maxWidth: .infinity, minHeight: 460)
VStack(alignment: .leading, spacing: 8) {
Text(String(format: "Time: %02d:00", Int(hour.rounded())))
.font(.headline)
Slider(value: $hour, in: 0...24, step: 0.25)
.onChange(of: hour) { _, newValue in
let rounded = Int(newValue.rounded())
if rounded != tickHour {
tickHour = rounded
haptics.impactOccurred(intensity: 0.7)
}
}
}
.padding(14)
.background {
GlassBlurView(style: .systemThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
}
}
// MARK: - SceneKit Dollhouse
private struct SceneKitDollhouseView: UIViewRepresentable {
@Binding var hour: Double
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> SCNView {
let view = SCNView()
view.scene = context.coordinator.scene
view.autoenablesDefaultLighting = false
view.allowsCameraControl = true
view.backgroundColor = UIColor.systemBackground
context.coordinator.setupScene()
context.coordinator.updateSunLight(hour: hour)
return view
}
func updateUIView(_ uiView: SCNView, context: Context) {
context.coordinator.updateSunLight(hour: hour)
}
final class Coordinator {
let scene = SCNScene()
private let sunNode = SCNNode()
func setupScene() {
let modelScene = InventoryModeAvailability.dollhouseAssetCandidates
.compactMap { candidate in
SCNScene(named: "\(candidate.name).\(candidate.ext)")
}
.first
if let modelScene {
let container = SCNNode()
for child in modelScene.rootNode.childNodes {
container.addChildNode(child.clone())
}
scene.rootNode.addChildNode(container)
} else {
let fallback = SCNFloor()
fallback.firstMaterial?.diffuse.contents = UIColor.secondarySystemBackground
scene.rootNode.addChildNode(SCNNode(geometry: fallback))
}
let camera = SCNCamera()
let cameraNode = SCNNode()
cameraNode.camera = camera
cameraNode.position = SCNVector3(0, 4, 10)
scene.rootNode.addChildNode(cameraNode)
let light = SCNLight()
light.type = .directional
light.intensity = 1_200
light.castsShadow = true
sunNode.light = light
scene.rootNode.addChildNode(sunNode)
let ambient = SCNLight()
ambient.type = .ambient
ambient.intensity = 200
let ambientNode = SCNNode()
ambientNode.light = ambient
scene.rootNode.addChildNode(ambientNode)
}
func updateSunLight(hour: Double) {
let normalized = (hour / 24.0) * (2 * Double.pi)
let x = Float(cos(normalized) * 8.0)
let y = Float(max(sin(normalized) * 8.0, 1.0))
let z = Float(sin(normalized + .pi / 3) * 6.0)
sunNode.position = SCNVector3(x, y, z)
sunNode.look(at: SCNVector3(0, 0, 0))
}
}
}
// MARK: - ProcessingOverlay
private struct ProcessingOverlay: View {
@State private var animate = false
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 16)
.fill(.black.opacity(0.45))
Text("AI Processing...")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background {
GlassBlurView(style: .systemUltraThinMaterialDark)
.clipShape(Capsule())
}
.overlay(
Rectangle()
.fill(
LinearGradient(
colors: [.clear, .white.opacity(0.6), .clear],
startPoint: .leading,
endPoint: .trailing
)
)
.rotationEffect(.degrees(18))
.offset(x: animate ? 160 : -160)
.animation(.linear(duration: 1.2).repeatForever(autoreverses: false), value: animate)
.blendMode(.screen)
.mask(Capsule().frame(height: 44))
)
}
.padding(12)
.onAppear { animate = true }
}
}
// MARK: - DashedSunLine
private struct DashedSunLine: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.minX, y: rect.maxY * 0.75))
path.addQuadCurve(
to: CGPoint(x: rect.maxX, y: rect.maxY * 0.25),
control: CGPoint(x: rect.midX, y: rect.minY + 30)
)
return path
}
}
// MARK: - CameraPicker
/// UIImagePickerController wrapper that delivers the captured image via a callback,
/// triggering orientation fix and clearing stale state immediately on capture.
private struct CameraPicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
let onCapture: (UIImage) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
#if targetEnvironment(simulator)
// Newer Simulators report camera as available but the shutter never
// delivers an image. Force photo library so testing actually works.
picker.sourceType = .photoLibrary
#else
if UIImagePickerController.isSourceTypeAvailable(.camera) {
picker.sourceType = .camera
picker.cameraCaptureMode = .photo
} else {
picker.sourceType = .photoLibrary
}
#endif
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
private let parent: CameraPicker
init(_ parent: CameraPicker) {
self.parent = parent
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let captured = info[.originalImage] as? UIImage {
parent.onCapture(captured)
}
parent.isPresented = false
}
}
}
// MARK: - Share Sheet
/// Wraps UIActivityViewController to match the native iOS Photos share experience.
/// Natively includes: Save Image, AirDrop, Messages, Mail, Copy, and all installed share extensions.
private struct ShareSheet: UIViewControllerRepresentable {
let image: UIImage
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [image], applicationActivities: nil)
}
func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {}
}
// MARK: - UIImage + Identifiable
// Required to use UIImage as the `item` in .sheet(item:)
extension UIImage: @retroactive Identifiable {
public var id: ObjectIdentifier { ObjectIdentifier(self) }
}

View File

@@ -0,0 +1,254 @@
import CoreLocation
import SceneKit
import SwiftUI
#if targetEnvironment(simulator)
/// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator.
/// Uses a synthetic camera, fake location, and mock heading instead of ARKit.
struct SimulatorSunOverlayView: UIViewRepresentable {
@Binding var sunNodesReady: Bool
// Fake location (e.g. San Francisco)
private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
private let mockHeading: Double = 0 // North
func makeCoordinator() -> Coordinator {
Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading)
}
func makeUIView(context: Context) -> SCNView {
let view = SCNView(frame: .zero)
view.scene = SCNScene()
view.allowsCameraControl = true // Swipe around the 3D space
view.autoenablesDefaultLighting = true
view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
view.isPlaying = true // Force render loop
view.showsStatistics = true // Prove it's rendering
// Setup synthetic camera
let cameraNode = SCNNode()
cameraNode.camera = SCNCamera()
cameraNode.camera?.zFar = 100
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered
view.scene?.rootNode.addChildNode(cameraNode)
context.coordinator.attach(to: view)
return view
}
func updateUIView(_ uiView: SCNView, context: Context) {}
final class Coordinator: NSObject {
@Binding private var sunNodesReady: Bool
private let mockLocation: CLLocationCoordinate2D
private let mockHeading: Double
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var updateTimer: Timer?
init(sunNodesReady: Binding<Bool>, mockLocation: CLLocationCoordinate2D, mockHeading: Double) {
_sunNodesReady = sunNodesReady
self.mockLocation = mockLocation
self.mockHeading = mockHeading
super.init()
}
func attach(to view: SCNView) {
view.scene?.rootNode.addChildNode(arcRootNode)
view.scene?.rootNode.addChildNode(currentSunNode)
buildScene()
startRealTimeTick()
}
deinit {
updateTimer?.invalidate()
}
private func startRealTimeTick() {
// Update current sun position every second
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self = self else { return }
let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation)
// Need to remove previous child as we are completely replacing it
self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
let radius: Float = 1.8
let orb = SCNSphere(radius: 0.055)
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
orb.firstMaterial?.emission.contents = UIColor.systemYellow
orb.firstMaterial?.lightingModel = .constant
let orbNode = SCNNode(geometry: orb)
orbNode.position = self.worldPosition(for: cur, radius: radius)
let pulse = CABasicAnimation(keyPath: "scale")
pulse.fromValue = SCNVector3(1, 1, 1)
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
pulse.duration = 1.2
pulse.autoreverses = true
pulse.repeatCount = .infinity
orbNode.addAnimation(pulse, forKey: "pulse")
let label = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
label.position = SCNVector3(0, 0.09, 0)
orbNode.addChildNode(label)
self.currentSunNode.addChildNode(orbNode)
}
}
private func buildScene() {
let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation)
let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation)
let radius: Float = 1.8
var positions: [SCNVector3] = []
// Hourly blocks
for (date, pos) in arc {
guard pos.elevation > -5 else { continue }
let worldPos = worldPosition(for: pos, radius: radius)
positions.append(worldPos)
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = worldPos
arcRootNode.addChildNode(markerNode)
let calendar = Calendar.current
let hour = calendar.component(.hour, from: date)
if hour % 2 == 0 {
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
arcRootNode.addChildNode(labelNode)
}
}
if positions.count >= 2 {
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
arcRootNode.addChildNode(lineNode)
}
if let riseDate = riseSet.rise {
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation)
let wPos = worldPosition(for: risePos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
}
if let setDate = riseSet.set {
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation)
let wPos = worldPosition(for: setPos, radius: radius)
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
}
// Generate current sun node synchronously for first frame
updateTimer?.fire()
DispatchQueue.main.async {
self.sunNodesReady = true
}
}
// MARK: Math equivalent from SunseekerViewModel
private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
let elev = Float(sun.elevation * .pi / 180.0)
let az = Float(sun.azimuth * .pi / 180.0)
let x = radius * cos(elev) * sin(az)
let y = radius * sin(elev)
let z = -radius * cos(elev) * cos(az)
return SCNVector3(x, y, z)
}
// MARK: SceneKit Factories
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
let root = SCNNode()
let sphere = SCNSphere(radius: 0.035)
sphere.firstMaterial?.diffuse.contents = color
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
sphere.firstMaterial?.lightingModel = .constant
let markerNode = SCNNode(geometry: sphere)
markerNode.position = pos
root.addChildNode(markerNode)
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
root.addChildNode(labelNode)
return root
}
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
// SCNText is buggy in Simulator. Render text to a UIImage instead.
let font = UIFont.systemFont(ofSize: 40, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: color
]
let size = (text as NSString).size(withAttributes: attributes)
// Add some padding
let paddedSize = CGSize(width: size.width + 10, height: size.height + 10)
let renderer = UIGraphicsImageRenderer(size: paddedSize)
let image = renderer.image { context in
(text as NSString).draw(
in: CGRect(x: 5, y: 5, width: size.width, height: size.height),
withAttributes: attributes
)
}
// Map the image onto an SCNPlane
// A 100x50 image becomes a 0.1 x 0.05 meter plane
let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0)
plane.firstMaterial?.diffuse.contents = image
plane.firstMaterial?.isDoubleSided = true
plane.firstMaterial?.lightingModel = .constant
let textNode = SCNNode(geometry: plane)
// Statically scale the plane up so it is readable next to the spheres
textNode.scale = SCNVector3(1.5, 1.5, 1.5)
let constraint = SCNBillboardConstraint()
constraint.freeAxes = .all
textNode.constraints = [constraint]
return textNode
}
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
guard positions.count >= 2 else { return SCNNode() }
let vertices: [SCNVector3] = positions
var indices: [Int32] = []
for i in 0..<(vertices.count - 1) {
indices.append(Int32(i))
indices.append(Int32(i + 1))
}
let vertexSource = SCNGeometrySource(vertices: vertices)
let element = SCNGeometryElement(
indices: indices,
primitiveType: .line
)
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
geometry.firstMaterial?.diffuse.contents = color
geometry.firstMaterial?.lightingModel = .constant
return SCNNode(geometry: geometry)
}
private func hourLabel(from date: Date) -> String {
let fmt = DateFormatter()
fmt.dateFormat = "ha"
fmt.amSymbol = "am"
fmt.pmSymbol = "pm"
return fmt.string(from: date)
}
}
}
#endif

View File

@@ -0,0 +1,140 @@
import CoreLocation
import CoreMotion
import Foundation
import Observation
import SceneKit
// MARK: - SunseekerViewModel
/// Owns all sensor state for the Sunseeker AR overlay.
/// Separates CoreLocation / CoreMotion concerns from the ARKit view layer.
@Observable
final class SunseekerViewModel: NSObject, CLLocationManagerDelegate {
// MARK: - Published State
/// True once we have both a GPS fix and a valid heading.
private(set) var isReady = false
/// Latest GPS coordinate. nil until first fix.
private(set) var coordinate: CLLocationCoordinate2D?
/// Latest true heading (0 = North, clockwise).
private(set) var heading: Double = 0
/// Dense hourly arc for today.
private(set) var arc: [(date: Date, position: SunPosition)] = []
/// Current real-time sun position (updated every second).
private(set) var currentPosition: SunPosition?
/// Sunrise and sunset for today.
private(set) var riseSet: (rise: Date?, set: Date?) = (nil, nil)
/// Diagnostic string for the UI when location access is denied.
private(set) var locationError: String?
// MARK: - Private
private let locationManager = CLLocationManager()
private let motionManager = CMMotionManager()
private var updateTimer: Timer?
// MARK: - Init / Deinit
override init() {
super.init()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyBest
locationManager.headingFilter = 1.0
locationManager.requestWhenInUseAuthorization()
locationManager.startUpdatingLocation()
locationManager.startUpdatingHeading()
startMotionUpdates()
startRealTimeTick()
}
deinit {
stop()
}
// MARK: - Control
func stop() {
motionManager.stopDeviceMotionUpdates()
locationManager.stopUpdatingLocation()
locationManager.stopUpdatingHeading()
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: - World-Space Transform
/// Converts a solar `SunPosition` into a SceneKit world-space position on a sphere of given `radius`.
/// Orientation is relative to ARWorldTrackingConfiguration(.gravityAndHeading), so north = -Z axis.
func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
let elev = Float(sun.elevation.radians)
let az = Float(sun.azimuth.radians) // clockwise from north
let x = radius * cos(elev) * sin(az)
let y = radius * sin(elev)
let z = -radius * cos(elev) * cos(az) // -Z = north in ARKit gravity+heading
return SCNVector3(x, y, z)
}
// MARK: - Private helpers
private func startMotionUpdates() {
guard motionManager.isDeviceMotionAvailable else { return }
motionManager.deviceMotionUpdateInterval = 0.05
motionManager.startDeviceMotionUpdates()
}
private func startRealTimeTick() {
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
guard let self, let coord = self.coordinate else { return }
self.currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
}
}
private func refreshArc() {
guard let coord = coordinate else { return }
arc = SunMath.sunPathArc(for: Date(), coordinate: coord)
riseSet = SunMath.sunRiseSet(for: Date(), coordinate: coord)
currentPosition = SunMath.calculateSunPosition(date: Date(), coordinate: coord)
isReady = true
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard coordinate == nil, let loc = locations.last else { return }
coordinate = loc.coordinate
refreshArc()
}
func locationManager(_ manager: CLLocationManager, didUpdateHeading newHeading: CLHeading) {
heading = newHeading.trueHeading > 0 ? newHeading.trueHeading : newHeading.magneticHeading
if coordinate != nil { isReady = true }
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("[Sunseeker] Location error: \(error.localizedDescription)")
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .denied, .restricted:
locationError = "Location access needed to calculate the sun path. Please enable it in Settings."
case .notDetermined:
manager.requestWhenInUseAuthorization()
default:
locationError = nil
}
}
}
// MARK: - Degree helpers (internal to this file)
private extension Double {
var radians: Double { self * .pi / 180.0 }
}

View File

@@ -0,0 +1,19 @@
import Foundation
enum OracleModeAvailability {
static let productionVisibleModes: [OracleMode] = [
.pipeline,
.deals,
.accountTimeline,
.calendarTasks,
]
static let hiddenModesUntilBackendSupport: [OracleMode] = [
.teamPerformance,
.leadMap,
]
static func sanitizedProductionSelection(_ candidate: OracleMode) -> OracleMode {
productionVisibleModes.contains(candidate) ? candidate : .pipeline
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
import Foundation
enum SentinelScope {
static let navigationTitle = "Operator Posture"
static let productFamilyName = "Sentinel"
static let availabilityBadge = "Operator posture only"
static let disabledAnalyticsCapabilities: [String] = [
"visitor counting",
"facial detections",
"sentiment scoring",
]
static let liveBackedCapabilities: [String] = [
"alert posture",
"transcription queue visibility",
"upcoming calendar pressure",
"recent operator timeline",
]
static var disabledAnalyticsSummary: String {
disabledAnalyticsCapabilities.joined(separator: ", ")
}
static var liveBackedSummary: String {
liveBackedCapabilities.joined(separator: ", ")
}
}

View File

@@ -0,0 +1,209 @@
import Combine
import SwiftUI
struct SentinelView: View {
@State private var store = AppStore.shared
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
header
if let error = store.errorMessage {
errorBanner(error)
}
availabilityCard
postureCards
timelineCard
}
.padding(24)
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
Text(SentinelScope.productFamilyName.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
Text(SentinelScope.navigationTitle)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Truthful live posture for alerts and comms load. \(SentinelScope.productFamilyName) analytics stay disabled on iPad until a real production stream is exposed.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
private var availabilityCard: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Production Scope")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
statusBadge(
label: SentinelScope.availabilityBadge,
color: VelocityTheme.warning
)
}
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var postureCards: some View {
HStack(spacing: 14) {
SentinelCard(
title: "Pending insights",
value: "\(store.metrics.pendingInsights)",
subtitle: "Recommendations waiting on operator review",
color: VelocityTheme.danger
)
SentinelCard(
title: "Transcript queue",
value: "\(store.metrics.pendingTranscriptions)",
subtitle: "Imported recordings still processing",
color: VelocityTheme.warning
)
SentinelCard(
title: "Upcoming 24h",
value: "\(store.alertSnapshot?.upcomingCalendarEvents24h ?? 0)",
subtitle: "Calendar events due soon",
color: VelocityTheme.success
)
}
}
private var timelineCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Recent Operator Timeline")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if let lastRefresh = store.lastRefreshAt {
Text("Updated \(lastRefresh.relativeShort)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
if store.timelineEvents.isEmpty {
Text("No live communication events have been loaded for the current high-priority leads yet.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(store.timelineEvents.prefix(6)) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.leadName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
Text(item.event.summary ?? "No summary available for this event.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private func statusBadge(label: String, color: Color) -> some View {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(color)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
Capsule()
.fill(color.opacity(0.12))
.overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1))
)
}
private func errorBanner(_ message: String) -> some View {
Text(message)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.danger.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1)
)
)
}
}
private struct SentinelCard: View {
let title: String
let value: String
let subtitle: String
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 26, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
RoundedRectangle(cornerRadius: 4)
.fill(color)
.frame(width: 48, height: 4)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
}
#Preview {
SentinelView()
}

View File

@@ -0,0 +1,267 @@
import SwiftUI
struct SessionConfigurationPanel: View {
@State private var session = SessionStore.shared
let title: String
let subtitle: String
let primaryActionTitle: String
let allowsClearingStoredConfiguration: Bool
var body: some View {
@Bindable var session = session
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(subtitle)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
VStack(spacing: 14) {
SessionInputField(
label: "Backend endpoint",
placeholder: "https://velocity.desineuron.in/api"
) {
TextField("", text: $session.draftBaseURL, prompt: Text("https://velocity.desineuron.in/api"))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
SessionInputField(
label: "Dream Weaver endpoint",
placeholder: "Leave blank to use the backend endpoint"
) {
TextField("", text: $session.draftDreamWeaverBaseURL, prompt: Text("https://dreamweaver.desineuron.in"))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
}
SessionInputField(
label: "Dream Weaver gateway API key",
placeholder: session.currentConfiguration.hasDreamWeaverAPIKey ? "Leave blank to keep the current gateway key" : "Optional unless the gateway enforces it"
) {
SecureField(
"",
text: $session.draftDreamWeaverAPIKey,
prompt: Text(session.currentConfiguration.hasDreamWeaverAPIKey ? "Leave blank to keep the current gateway key" : "Optional unless the gateway enforces it")
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
VStack(alignment: .leading, spacing: 8) {
Text("Authentication mode")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
Picker("Authentication mode", selection: $session.draftAuthMode) {
ForEach(SessionAuthMode.allCases) { mode in
Text(mode.rawValue).tag(mode)
}
}
.pickerStyle(.segmented)
}
if session.draftAuthMode == .emailPassword {
SessionInputField(
label: "Operator email",
placeholder: "operator@desineuron.in"
) {
TextField("", text: $session.draftEmail, prompt: Text("operator@desineuron.in"))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.emailAddress)
}
SessionInputField(
label: "Password",
placeholder: session.currentConfiguration.hasPassword ? "Leave blank to keep the current secret" : "Required"
) {
SecureField(
"",
text: $session.draftPassword,
prompt: Text(session.currentConfiguration.hasPassword ? "Leave blank to keep the current secret" : "Required")
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
} else {
SessionInputField(
label: "Bearer token",
placeholder: session.currentConfiguration.hasBearerToken ? "Leave blank to keep the current token" : "Required"
) {
SecureField(
"",
text: $session.draftBearerToken,
prompt: Text(session.currentConfiguration.hasBearerToken ? "Leave blank to keep the current token" : "Required")
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
}
}
VStack(alignment: .leading, spacing: 4) {
Text("Current source: \(session.configurationSourceDescription)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
Text("Runtime overrides are saved on-device. Secrets are stored in Keychain; the backend endpoint, optional Dream Weaver endpoint, and operator email are stored in local app preferences.")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let message = session.statusMessage {
SessionStatusBanner(message: message, accentColor: VelocityTheme.success)
}
if let error = session.errorMessage {
SessionStatusBanner(message: error, accentColor: VelocityTheme.danger)
}
HStack(spacing: 12) {
Button {
Task { await session.saveDraft() }
} label: {
HStack(spacing: 8) {
if session.isSaving {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.tint(.white)
}
Text(primaryActionTitle)
.font(.system(size: 14, weight: .semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(SessionActionButtonStyle(background: VelocityTheme.accent))
.disabled(session.isSaving)
Button("Reset form") {
session.discardDraftChanges()
}
.buttonStyle(SessionSecondaryButtonStyle())
.disabled(session.isSaving || !session.hasUnsavedChanges)
}
if allowsClearingStoredConfiguration {
Button("Clear stored session override") {
Task { await session.clearStoredConfiguration() }
}
.buttonStyle(SessionDangerButtonStyle())
.disabled(session.isSaving || !session.isUsingStoredRuntimeConfiguration)
}
}
.padding(20)
.glassCard(cornerRadius: 20)
}
}
private struct SessionInputField<Field: View>: View {
let label: String
let placeholder: String
@ViewBuilder let field: Field
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(label)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
field
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
Text(placeholder)
.font(.system(size: 10))
.foregroundStyle(VelocityTheme.mutedFg.opacity(0.9))
}
}
}
private struct SessionStatusBanner: View {
let message: String
let accentColor: Color
var body: some View {
Text(message)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(accentColor)
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(accentColor.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(accentColor.opacity(0.18), lineWidth: 1)
)
)
}
}
private struct SessionActionButtonStyle: ButtonStyle {
let background: Color
func makeBody(configuration: Configuration) -> some View {
configuration.label
.foregroundStyle(.white)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(background.opacity(configuration.isPressed ? 0.82 : 1))
)
}
}
private struct SessionSecondaryButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 14, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(VelocityTheme.surface.opacity(configuration.isPressed ? 0.78 : 1))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
private struct SessionDangerButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
.padding(.horizontal, 14)
.padding(.vertical, 11)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(VelocityTheme.danger.opacity(configuration.isPressed ? 0.14 : 0.10))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(VelocityTheme.danger.opacity(0.2), lineWidth: 1)
)
)
}
}

View File

@@ -0,0 +1,224 @@
import SwiftUI
struct SettingsView: View {
@State private var store = AppStore.shared
@State private var session = SessionStore.shared
var body: some View {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live runtime configuration")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
SettingsSection(title: "Connectivity") {
SettingsRow(
label: "Backend endpoint",
value: session.endpointDisplay,
icon: "server.rack",
accentColor: VelocityTheme.accent
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Dream Weaver endpoint",
value: session.dreamWeaverEndpointDisplay,
icon: "wand.and.stars",
accentColor: session.dreamWeaverEndpointModeDescription == "Dedicated gateway" ? VelocityTheme.warning : VelocityTheme.mutedFg
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Dream Weaver route mode",
value: session.dreamWeaverEndpointModeDescription,
icon: "point.3.connected.trianglepath.dotted",
accentColor: session.dreamWeaverEndpointModeDescription == "Dedicated gateway" ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Dream Weaver auth",
value: session.dreamWeaverAuthenticationDescription,
icon: "key.horizontal",
accentColor: session.dreamWeaverAuthenticationDescription == "API key configured" ? VelocityTheme.success : VelocityTheme.mutedFg
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Auth mode",
value: session.authModeDescription,
icon: "lock.shield",
accentColor: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Config source",
value: session.configurationSourceDescription,
icon: "externaldrive.badge.icloud",
accentColor: session.isUsingStoredRuntimeConfiguration ? VelocityTheme.success : VelocityTheme.mutedFg
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Last refresh",
value: store.lastRefreshAt?.relativeShort ?? "No live fetch yet",
icon: "arrow.clockwise",
accentColor: VelocityTheme.mutedFg
)
}
SettingsSection(title: "Operator") {
SettingsRow(
label: "Identity",
value: session.operatorIdentity,
icon: "person.crop.circle",
accentColor: VelocityTheme.accent
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "CRM contacts loaded",
value: "\(store.contacts.count)",
icon: "person.3",
accentColor: VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Pending CRM tasks loaded",
value: "\(store.tasks.count)",
icon: "checklist",
accentColor: VelocityTheme.warning
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Property records loaded",
value: "\(store.properties.count)",
icon: "building.2",
accentColor: VelocityTheme.warning
)
}
SettingsSection(title: "Production Readiness") {
SettingsRow(
label: "Canonical contacts",
value: "\(store.contacts.count) loaded",
icon: "person.text.rectangle",
accentColor: store.contacts.isEmpty ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Pipeline lanes",
value: "\(store.kanbanColumns.reduce(0) { $0 + $1.count }) leads",
icon: "square.grid.3x1.below.line.grid.1x2",
accentColor: store.kanbanColumns.isEmpty ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Deals",
value: "\(store.opportunities.count) opportunities",
icon: "target",
accentColor: store.opportunities.isEmpty ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Timeline events",
value: "\(store.timelineEvents.count) hydrated",
icon: "clock.arrow.circlepath",
accentColor: store.timelineEvents.isEmpty ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Last app error",
value: store.errorMessage ?? "None",
icon: "exclamationmark.triangle",
accentColor: store.errorMessage == nil ? VelocityTheme.success : VelocityTheme.danger
)
}
SessionConfigurationPanel(
title: "Session Configuration",
subtitle: "Update the production endpoint, point Dream Weaver at a dedicated gateway when needed, or rotate operator credentials without rebuilding the app. Saving clears the cached token, re-runs a live refresh, and probes the Dream Weaver routes.",
primaryActionTitle: "Save and refresh",
allowsClearingStoredConfiguration: true
)
SettingsSection(title: "Production Notes") {
VStack(alignment: .leading, spacing: 8) {
Text("This build avoids local demo data. Runtime session overrides are stored on-device so investor or operator installs no longer depend on committed build-time credentials.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
Text("\(SentinelScope.navigationTitle) remains the truthful iPad label for the current \(SentinelScope.productFamilyName) surface because visitor analytics stay disabled until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed. Dream Weaver can now use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are still enforced and reported truthfully.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
Spacer()
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(VelocityTheme.background)
}
}
private struct SettingsSection<Content: View>: View {
let title: String
@ViewBuilder let content: Content
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
.padding(.bottom, 8)
.padding(.horizontal, 4)
VStack(spacing: 0) {
content
}
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(red: 0.031, green: 0.039, blue: 0.071))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
}
private struct SettingsRow: View {
let label: String
let value: String
let icon: String
let accentColor: Color
var body: some View {
HStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 7)
.fill(accentColor.opacity(0.12))
.frame(width: 30, height: 30)
Image(systemName: icon)
.font(.system(size: 13, weight: .medium))
.foregroundStyle(accentColor)
}
Text(label)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(value)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.multilineTextAlignment(.trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}