feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s

#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

@@ -1,4 +1,3 @@
import Combine
import SwiftUI
private struct CalendarAgendaItem: Identifiable {
@@ -9,6 +8,7 @@ private struct CalendarAgendaItem: Identifiable {
let location: String
let type: String
let color: Color
let pendingSync: Bool
let sortDate: Date?
let event: VelocityCalendarEventDTO?
let task: VelocityTaskDTO?
@@ -58,15 +58,19 @@ struct CalendarView: View {
@State private var actionError: String?
@State private var actionMessage: String?
@State private var actionMessageDismissTask: Task<Void, Never>?
@State private var activeDashboardFocus: VelocityDashboardCalendarFocus?
@State private var activeTaskMutationID: String?
@State private var activeEventMutationID: String?
@State private var undoCancelledTask: VelocityTaskDTO?
@State private var undoCancelledEvent: VelocityCalendarEventDTO?
@State private var isCreateEventPresented = false
@State private var editingEvent: VelocityCalendarEventDTO?
@State private var eventDraft = CalendarEventDraft()
@State private var isCreatingEvent = false
@State private var isSavingEvent = false
@State private var createEventError: String?
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
@State private var schedulingClientPersonID: String?
@State private var targetedDropDay: String?
private let visibleWeekdays = Calendar.current.weekdaySymbols
var body: some View {
@@ -82,10 +86,14 @@ struct CalendarView: View {
if let actionMessage {
successBanner(actionMessage)
}
if let activeDashboardFocus {
dashboardFocusBanner(activeDashboardFocus)
}
if store.isLoading && store.lastRefreshAt == nil {
loadingPanel
} else {
metricsRow
clientSchedulingStrip
HStack(alignment: .top, spacing: 18) {
scheduleRail
agendaPanel
@@ -96,11 +104,14 @@ struct CalendarView: View {
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.task {
await store.refresh()
consumeRequestedCalendarFocus()
}
.onChange(of: store.requestedCalendarFocus) { _, _ in
consumeRequestedCalendarFocus()
}
.refreshable { await store.refresh() }
.onDisappear {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
@@ -108,6 +119,9 @@ struct CalendarView: View {
.sheet(isPresented: $isCreateEventPresented) {
createEventSheet
}
.sheet(item: $editingEvent) { event in
editEventSheet(event)
}
}
private var header: some View {
@@ -141,6 +155,9 @@ struct CalendarView: View {
Button {
selectedDay = Self.currentWeekdayName()
actionError = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = nil
}
} label: {
Text("This week")
.font(.system(size: 11, weight: .semibold))
@@ -207,14 +224,25 @@ struct CalendarView: View {
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
.fill(targetedDropDay == day ? VelocityTheme.accent.opacity(0.22) : (selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
.stroke(targetedDropDay == day || selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
.buttonStyle(.plain)
.dropDestination(for: String.self) { personIDs, _ in
guard let personID = personIDs.first?.trimmedNonEmpty else {
return false
}
Task { await scheduleSiteVisit(forPersonID: personID, on: day) }
return true
} isTargeted: { isTargeted in
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) {
targetedDropDay = isTargeted ? day : nil
}
}
}
}
.padding(18)
@@ -222,6 +250,46 @@ struct CalendarView: View {
.glassCard(cornerRadius: 20)
}
private var clientSchedulingStrip: some View {
Group {
if !store.contacts.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(store.contacts.prefix(12)) { contact in
HStack(spacing: 8) {
ZStack {
Circle()
.fill(VelocityTheme.accent.opacity(0.16))
.frame(width: 32, height: 32)
Text(initials(for: contact.fullName))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
Text(contact.fullName)
.font(.system(size: 12, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(VelocityTheme.foreground)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
Capsule()
.fill(VelocityTheme.surface)
.overlay(Capsule().stroke(VelocityTheme.borderSubtle, lineWidth: 1))
)
.opacity(schedulingClientPersonID == contact.personId ? 0.55 : 1)
.scaleEffect(schedulingClientPersonID == contact.personId ? 0.97 : 1)
.draggable(contact.personId)
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.86), value: schedulingClientPersonID)
}
}
.padding(.vertical, 2)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
}
private var agendaPanel: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
@@ -263,6 +331,12 @@ struct CalendarView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if item.pendingSync {
Circle()
.fill(VelocityTheme.warning)
.frame(width: 8, height: 8)
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
}
Text(item.type)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(item.color)
@@ -499,7 +573,157 @@ struct CalendarView: View {
.presentationDragIndicator(.visible)
}
private func editEventSheet(_ event: VelocityCalendarEventDTO) -> some View {
NavigationStack {
ZStack {
VelocityTheme.background.ignoresSafeArea()
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 4) {
Text("Edit Event")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Update the backend-owned calendar slot details.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let createEventError {
errorBanner(createEventError)
}
ScrollView {
VStack(alignment: .leading, spacing: 14) {
formLabel("Title")
eventTextField("Site visit with client", text: $eventDraft.title)
HStack(spacing: 12) {
Picker("Status", selection: $eventDraft.status) {
Text("Normal Task").tag("tentative")
Text("Confirmed Task").tag("confirmed")
Text("Done").tag("done")
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
Picker("Reminder", selection: $eventDraft.reminderMinutes) {
Text("None").tag(0)
Text("5 min").tag(5)
Text("15 min").tag(15)
Text("30 min").tag(30)
Text("1 hour").tag(60)
Text("1 day").tag(1_440)
}
.pickerStyle(.menu)
.tint(VelocityTheme.accent)
}
.padding(12)
.background(fieldBackground)
DatePicker(
"Starts",
selection: eventStartBinding,
displayedComponents: eventDatePickerComponents
)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
if !eventDraft.allDay {
DatePicker(
"Ends",
selection: $eventDraft.endDate,
in: eventDraft.startDate.addingTimeInterval(60)...,
displayedComponents: [.date, .hourAndMinute]
)
.tint(VelocityTheme.accent)
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(fieldBackground)
}
formLabel("Location")
eventTextField("Project site, sales lounge, video call", text: $eventDraft.location)
formLabel("Description")
TextEditor(text: $eventDraft.description)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.frame(minHeight: 110)
.padding(10)
.background(fieldBackground)
}
.padding(18)
.glassCard(cornerRadius: 18)
}
.frame(height: 436)
HStack(spacing: 12) {
Button {
editingEvent = nil
} label: {
Text("Cancel")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(fieldBackground)
}
.buttonStyle(.plain)
Button {
saveEventEdits(event)
} label: {
HStack(spacing: 8) {
if isSavingEvent {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(isSavingEvent ? "Saving..." : "Save Event")
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 13)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(eventDraft.isValid && !isSavingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
)
}
.buttonStyle(.plain)
.disabled(!eventDraft.isValid || isSavingEvent)
}
}
.padding(24)
.frame(maxWidth: 620)
.frame(maxWidth: .infinity)
}
}
.presentationDetents([.height(690)])
.presentationDragIndicator(.visible)
}
private var filteredAgendaItems: [CalendarAgendaItem] {
if let activeDashboardFocus {
switch activeDashboardFocus {
case .today:
let weekday = Self.currentWeekdayName().lowercased()
return agendaItems.filter {
$0.slot.lowercased().contains(weekday) && !isInactiveAgendaItem($0)
}
case .pendingTasks:
return agendaItems.filter { item in
guard let task = item.task else { return false }
return ["pending", "snoozed", "confirmed"].contains(task.status.lowercased())
}
case .urgentTasks:
return agendaItems.filter { item in
guard let task = item.task else { return false }
return ["urgent", "high"].contains(task.priority.lowercased()) && !isInactiveAgendaItem(item)
}
}
}
let weekday = selectedDay.lowercased()
return agendaItems.filter { $0.slot.lowercased().contains(weekday) }
}
@@ -514,6 +738,7 @@ struct CalendarView: View {
location: event.location ?? "No location",
type: eventStatusLabel(event.status),
color: color(for: event.status),
pendingSync: store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-"),
sortDate: event.startDate,
event: event,
task: nil
@@ -528,6 +753,7 @@ struct CalendarView: View {
location: task.clientPhone ?? "Canonical CRM task",
type: taskStatusLabel(task),
color: taskColor(for: task),
pendingSync: store.pendingSyncTaskIDs.contains(task.reminderId),
sortDate: task.dueDate,
event: nil,
task: task
@@ -673,6 +899,34 @@ struct CalendarView: View {
)
}
private func dashboardFocusBanner(_ focus: VelocityDashboardCalendarFocus) -> some View {
HStack(spacing: 10) {
Label(calendarFocusLabel(focus), systemImage: "line.3.horizontal.decrease.circle")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
Text(calendarFocusDescription(focus))
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
Spacer()
Button("Clear") {
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = nil
}
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.warning.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.warning.opacity(0.22), lineWidth: 1)
)
)
}
private func buildMetrics(
events: [VelocityCalendarEventDTO],
tasks: [VelocityTaskDTO],
@@ -884,6 +1138,14 @@ struct CalendarView: View {
private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View {
Menu {
let status = event.status.lowercased()
if status != "cancelled" {
Button {
presentEditEvent(event)
} label: {
Label("Edit Event", systemImage: "square.and.pencil")
}
}
if status == "done" {
Button(role: .destructive) {
cancelEvent(event, message: "Task removed.", supportsUndo: false)
@@ -946,6 +1208,79 @@ struct CalendarView: View {
isCreateEventPresented = true
}
private func presentEditEvent(_ event: VelocityCalendarEventDTO) {
let startDate = event.startDate ?? CalendarEventDraft.defaultStartDate()
var draft = CalendarEventDraft(startDate: startDate)
draft.title = event.title
draft.description = event.description ?? ""
draft.location = event.location ?? ""
draft.endDate = event.endDate ?? startDate.addingTimeInterval(60 * 60)
draft.allDay = event.allDay
draft.status = event.status.lowercased()
draft.reminderMinutes = event.reminderMinutes.first ?? 0
eventDraft = draft
createEventError = nil
actionError = nil
clearActionMessage()
editingEvent = event
}
@MainActor
private func scheduleSiteVisit(forPersonID personID: String, on weekday: String) async {
guard schedulingClientPersonID == nil else {
return
}
guard let contact = store.contacts.first(where: { $0.personId == personID }) else {
actionError = "Unable to schedule: this client is not present in the canonical CRM payload."
return
}
guard let leadId = contact.leadId?.trimmedNonEmpty else {
actionError = "Unable to schedule \(contact.fullName): no canonical lead is attached."
return
}
let startDate = defaultEventStartDate(for: weekday)
let endDate = startDate.addingTimeInterval(60 * 60)
var metadata = [
"created_from": "ipad_calendar_drag_drop",
"surface": "velocity_ipad",
"person_id": contact.personId,
"client_name": contact.fullName,
]
if let phone = contact.primaryPhone?.trimmedNonEmpty {
metadata["client_phone"] = phone
}
schedulingClientPersonID = personID
actionError = nil
clearActionMessage()
do {
_ = try await store.createCalendarEvent(
leadId: leadId,
title: "Site visit with \(contact.fullName)",
description: contact.primaryInterest.flatMap { "Property focus: \($0)".trimmedNonEmpty },
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
allDay: false,
status: "confirmed",
reminderMinutes: [60, 15],
location: contact.primaryInterest?.trimmedNonEmpty ?? "Project site",
metadata: metadata
)
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.86)) {
selectedDay = weekday
schedulingClientPersonID = nil
targetedDropDay = nil
}
showActionMessage("Site visit scheduled for \(contact.fullName).")
} catch {
schedulingClientPersonID = nil
targetedDropDay = nil
actionError = calendarActionErrorMessage(error)
}
}
private func createEvent() {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
@@ -1004,6 +1339,50 @@ struct CalendarView: View {
}
}
private func saveEventEdits(_ event: VelocityCalendarEventDTO) {
guard eventDraft.isValid else {
createEventError = "Add an event title and make sure the end time is after the start time."
return
}
let calendar = Calendar.current
let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate
let endDate = eventDraft.allDay
? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60))
: eventDraft.endDate
let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : []
createEventError = nil
isSavingEvent = true
Task {
do {
_ = try await store.updateCalendarEvent(
event,
title: eventDraft.title.trimmedNonEmpty ?? event.title,
description: eventDraft.description,
status: eventDraft.status,
startAt: iso8601Timestamp(startDate),
endAt: iso8601Timestamp(endDate),
reminderMinutes: reminderMinutes,
location: eventDraft.location
)
await MainActor.run {
selectedDay = weekdayName(for: startDate)
isSavingEvent = false
editingEvent = nil
showActionMessage("Event updated.")
actionError = nil
}
} catch {
await MainActor.run {
isSavingEvent = false
createEventError = calendarActionErrorMessage(error)
}
}
}
}
private func mutateEvent(
_ event: VelocityCalendarEventDTO,
status: String,
@@ -1170,6 +1549,41 @@ struct CalendarView: View {
}
}
private func consumeRequestedCalendarFocus() {
guard let focus = store.requestedCalendarFocus else {
return
}
store.requestedCalendarFocus = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
activeDashboardFocus = focus
if focus == .today {
selectedDay = Self.currentWeekdayName()
}
}
}
private func calendarFocusLabel(_ focus: VelocityDashboardCalendarFocus) -> String {
switch focus {
case .today:
return "Today"
case .pendingTasks:
return "Pending tasks"
case .urgentTasks:
return "Urgent tasks"
}
}
private func calendarFocusDescription(_ focus: VelocityDashboardCalendarFocus) -> String {
switch focus {
case .today:
return "Showing todays confirmed events and CRM reminders."
case .pendingTasks:
return "Showing actionable CRM reminders across the week."
case .urgentTasks:
return "Showing high-priority and urgent CRM reminders."
}
}
private func clearActionMessage(clearUndo: Bool = true) {
actionMessageDismissTask?.cancel()
actionMessageDismissTask = nil
@@ -1233,6 +1647,15 @@ struct CalendarView: View {
return formatter.string(from: date)
}
private func initials(for name: String) -> String {
let pieces = name
.split(separator: " ")
.prefix(2)
.compactMap { $0.first }
let initials = String(pieces).uppercased()
return initials.isEmpty ? "CL" : initials
}
private func menuIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.system(size: 17, weight: .semibold))

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,39 @@
import Combine
import SwiftUI
private struct ImportRemediationDraft: Identifiable {
let batchId: String
let proposal: VelocityImportProposalDTO
let workbenchRow: VelocityImportWorkbenchRowDTO?
let fields: [String]
var id: String { proposal.proposalId }
init(batchId: String, proposal: VelocityImportProposalDTO, workbenchRow: VelocityImportWorkbenchRowDTO?) {
self.batchId = batchId
self.proposal = proposal
self.workbenchRow = workbenchRow
let canonicalFields: [String] = proposal.payload?.canonicalPayload.map { Array($0.keys) } ?? []
let missingFields = proposal.payload?.missingRequired ?? []
let unresolvedFields = proposal.payload?.unresolvedFields ?? []
let diffFields = workbenchRow?.fieldDiffs.map(\.field) ?? []
let validationFields = workbenchRow?.validation.map(\.field) ?? []
let combinedFields: [String] = canonicalFields + missingFields + unresolvedFields + diffFields + validationFields
fields = Array(Set<String>(combinedFields)).sorted()
}
}
struct ImportsView: View {
@State private var appStore = AppStore.shared
@State private var batches: [VelocityImportBatchSummaryDTO] = []
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
@State private var detail: VelocityImportBatchDetailDTO?
@State private var workbench: VelocityImportWorkbenchDTO?
@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()
@State private var remediationDraft: ImportRemediationDraft?
var body: some View {
HStack(spacing: 0) {
@@ -24,20 +47,40 @@ struct ImportsView: View {
detailPane
}
.background(VelocityTheme.background)
.task { await loadBatches(selectFirst: true) }
.task {
await appStore.ensureCRMVocabulariesLoaded()
await loadBatches(selectFirst: true)
}
.refreshable { await loadBatches(selectFirst: false) }
.onReceive(refreshTimer) { _ in
Task { await loadBatches(selectFirst: false, silent: true) }
.sheet(item: $remediationDraft) { draft in
ImportRemediationSheet(
draft: draft,
duplicatePolicies: appStore.crmVocabularies.importDuplicatePolicies
) { decision, notes, fieldOverrides, duplicatePolicy in
Task {
await reviewProposal(
batchId: draft.batchId,
proposal: draft.proposal,
decision: decision,
notes: notes,
fieldOverrides: fieldOverrides,
duplicatePolicy: duplicatePolicy
)
}
}
}
}
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.")
HStack {
Text("Imports")
.font(.system(size: 26, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
}
Text("Read-only canonical CRM import review and remediation queue.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -77,6 +120,9 @@ struct ImportsView: View {
VStack(alignment: .leading, spacing: 18) {
if let detail {
detailHeader(detail)
if let workbench {
workbenchPanel(workbench)
}
proposalsPanel(detail)
} else if isLoading {
loadingCard("Loading import detail...")
@@ -195,8 +241,115 @@ struct ImportsView: View {
.glassCard(cornerRadius: 20)
}
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
private func workbenchPanel(_ workbench: VelocityImportWorkbenchDTO) -> some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Remediation Workbench")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Validation, duplicate detection, and canonical CRM row diffs before commit.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Button {
Task {
if let batchId = detail?.batchId {
await refreshWorkbench(batchId: batchId)
}
}
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.plain)
.foregroundStyle(VelocityTheme.accent)
}
HStack(spacing: 12) {
metricCard("Rows", value: "\(workbench.summary.proposalCount)", color: VelocityTheme.accent)
metricCard("Duplicates", value: "\(workbench.summary.duplicateCount)", color: VelocityTheme.warning)
metricCard("Errors", value: "\(workbench.summary.validationErrorCount)", color: VelocityTheme.danger)
metricCard("Warnings", value: "\(workbench.summary.validationWarningCount)", color: VelocityTheme.warning)
}
LazyVStack(spacing: 10) {
ForEach(workbench.rows.prefix(20)) { row in
workbenchRowCard(row)
}
}
}
.padding(18)
.glassCard(cornerRadius: 20)
}
private func workbenchRowCard(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text(row.rowNumber.map { "Row \($0)" } ?? "Proposal \(row.proposalId.prefix(8))")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("\(confidencePercent(row.confidence))%")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(lifecycleColor(row.status))
}
if !row.validation.isEmpty {
VStack(alignment: .leading, spacing: 5) {
ForEach(row.validation) { issue in
HStack(alignment: .top, spacing: 6) {
Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning)
Text("\(issue.field): \(issue.message)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
}
}
if let duplicate = row.duplicateCandidates.first {
Text("Duplicate candidate: \(duplicate.fullName) · \(duplicate.matchReason) match · \(duplicate.matchScore)%")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
}
let changedDiffs = row.fieldDiffs.filter(\.changed)
if !changedDiffs.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Changed fields")
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
ForEach(changedDiffs.prefix(4)) { diff in
Text("\(diff.field): \(diff.existing ?? "-")\(diff.proposed ?? "-")")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.foreground)
.lineLimit(1)
}
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
let rowDiagnostics = workbench?.row(for: proposal.proposalId)
return VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(proposal.rowLabel)
@@ -227,6 +380,16 @@ struct ImportsView: View {
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.danger)
}
if let unresolved = proposal.payload?.unresolvedFields, !unresolved.isEmpty {
Text("Needs review: \(unresolved.joined(separator: ", "))")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.warning)
}
if let rowDiagnostics {
proposalDiagnostics(rowDiagnostics)
}
}
.padding(14)
.background(
@@ -239,20 +402,49 @@ struct ImportsView: View {
)
}
private func proposalDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 5) {
if !row.validation.isEmpty {
Text("Validation: \(row.validation.map { "\($0.field) \($0.severity)" }.joined(separator: ", "))")
.font(.system(size: 11))
.foregroundStyle(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger : VelocityTheme.warning)
}
if let duplicate = row.duplicateCandidates.first {
Text("Possible duplicate: \(duplicate.fullName) (\(duplicate.matchReason), \(duplicate.matchScore)%)")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.warning)
}
}
}
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
HStack(spacing: 8) {
Button("Approve") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
Task {
await reviewProposal(
batchId: batchId,
proposal: proposal,
decision: "approved",
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
)
}
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "approved")
.disabled(proposal.status.lowercased() == "approved" || defaultDuplicatePolicyValue(for: proposal) == nil)
Button("Reject") {
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
Task {
await reviewProposal(
batchId: batchId,
proposal: proposal,
decision: "rejected",
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
)
}
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.danger)
@@ -260,9 +452,46 @@ struct ImportsView: View {
.padding(.vertical, 8)
.contentShape(Rectangle())
.disabled(proposal.status.lowercased() == "rejected")
Button("Needs Info") {
Task {
await reviewProposal(
batchId: batchId,
proposal: proposal,
decision: "needs_more_info",
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
)
}
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
Button("Remediate") {
remediationDraft = ImportRemediationDraft(
batchId: batchId,
proposal: proposal,
workbenchRow: workbench?.row(for: proposal.proposalId)
)
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.accent)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
}
private func defaultDuplicatePolicyValue(for proposal: VelocityImportProposalDTO) -> String? {
if let policy = workbench?.rows.first(where: { $0.proposalId == proposal.proposalId })?.duplicatePolicy,
appStore.crmVocabularies.importDuplicatePolicies.contains(where: { $0.value == policy }) {
return policy
}
return appStore.crmVocabularies.importDuplicatePolicies.first?.value
}
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
if !silent {
isLoading = true
@@ -291,6 +520,7 @@ struct ImportsView: View {
await MainActor.run {
selectedBatch = batch
detail = nil
workbench = nil
errorMessage = nil
successMessage = nil
isLoading = true
@@ -303,9 +533,13 @@ struct ImportsView: View {
await MainActor.run { isLoading = true }
}
do {
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
async let detailTask = VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
async let workbenchTask = VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId)
let fetched = try await detailTask
let fetchedWorkbench = try? await workbenchTask
await MainActor.run {
detail = fetched
workbench = fetchedWorkbench
errorMessage = nil
isLoading = false
}
@@ -317,11 +551,34 @@ struct ImportsView: View {
}
}
private func refreshWorkbench(batchId: String) async {
do {
let fetchedWorkbench = try await VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId)
await MainActor.run {
workbench = fetchedWorkbench
errorMessage = nil
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
}
}
}
private func reviewProposal(
batchId: String,
proposal: VelocityImportProposalDTO,
decision: String
decision: String,
notes: String = "Reviewed from iPad Imports workspace.",
fieldOverrides: [String: String] = [:],
duplicatePolicy: String?
) async {
guard let duplicatePolicy else {
await MainActor.run {
errorMessage = "Unable to review import row because backend duplicate policy vocabulary is unavailable."
}
return
}
await MainActor.run {
activeProposalID = proposal.proposalId
errorMessage = nil
@@ -332,12 +589,15 @@ struct ImportsView: View {
batchId: batchId,
proposalId: proposal.proposalId,
decision: decision,
notes: "Reviewed from iPad Imports workspace."
notes: notes,
fieldOverrides: fieldOverrides,
duplicatePolicy: duplicatePolicy
)
await refreshDetail(batchId: batchId, silent: true)
await MainActor.run {
activeProposalID = nil
successMessage = "Proposal \(decision)."
remediationDraft = nil
successMessage = "Proposal \(decision.replacingOccurrences(of: "_", with: " "))."
}
} catch {
await MainActor.run {
@@ -379,6 +639,11 @@ struct ImportsView: View {
detail.proposals.filter { $0.status == "approved" }.count
}
private func confidencePercent(_ value: Double) -> Int {
let normalized = value <= 1 ? value * 100 : value
return max(0, min(100, Int(normalized.rounded())))
}
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
payload
.sorted(by: { $0.key < $1.key })
@@ -462,6 +727,239 @@ struct ImportsView: View {
}
}
private struct ImportRemediationSheet: View {
let draft: ImportRemediationDraft
let duplicatePolicies: [VelocityVocabularyOptionDTO]
let onSubmit: (String, String, [String: String], String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var notes: String
@State private var fieldOverrides: [String: String]
@State private var duplicatePolicy: String
init(
draft: ImportRemediationDraft,
duplicatePolicies: [VelocityVocabularyOptionDTO],
onSubmit: @escaping (String, String, [String: String], String) -> Void
) {
self.draft = draft
self.duplicatePolicies = duplicatePolicies
self.onSubmit = onSubmit
_notes = State(initialValue: "")
let canonicalPayload = draft.proposal.payload?.canonicalPayload ?? [:]
let initialOverrides: [String: String] = Dictionary<String, String>(
uniqueKeysWithValues: draft.fields.map { field in
(field, canonicalPayload[field]?.stringValue ?? "")
}
)
_fieldOverrides = State(initialValue: initialOverrides)
let initialPolicy = duplicatePolicies.first(where: { $0.value == draft.workbenchRow?.duplicatePolicy })?.value
?? duplicatePolicies.first?.value
?? ""
_duplicatePolicy = State(initialValue: initialPolicy)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(draft.proposal.rowLabel)
.font(.system(size: 24, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("\(draft.proposal.confidencePercent)% confidence · \(draft.proposal.status.replacingOccurrences(of: "_", with: " ").capitalized)")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
if let workbenchRow = draft.workbenchRow {
remediationDiagnostics(workbenchRow)
duplicatePolicyPicker(workbenchRow)
}
if draft.fields.isEmpty {
Text("No canonical fields were returned for this proposal.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
} else {
VStack(alignment: .leading, spacing: 12) {
Text("Field Corrections")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
ForEach(draft.fields, id: \.self) { field in
VStack(alignment: .leading, spacing: 7) {
Text(field.replacingOccurrences(of: "_", with: " ").uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
TextField(field, text: Binding(
get: { fieldOverrides[field] ?? "" },
set: { fieldOverrides[field] = $0 }
))
.textFieldStyle(.plain)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.padding(12)
.background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface))
}
}
}
}
VStack(alignment: .leading, spacing: 7) {
Text("Review Notes")
.font(.system(size: 10, weight: .semibold))
.tracking(1.2)
.foregroundStyle(VelocityTheme.mutedFg)
TextEditor(text: $notes)
.frame(minHeight: 90)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
.scrollContentBackground(.hidden)
.padding(10)
.background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface))
}
}
.padding(24)
}
.background(VelocityTheme.background)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItemGroup(placement: .confirmationAction) {
Button("Needs Info") {
submit("needs_more_info")
}
Button("Approve Corrected") {
submit("approved")
}
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
}
private func remediationDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text("Validation and Duplicate Preview")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if row.validation.isEmpty && row.duplicateCandidates.isEmpty && row.fieldDiffs.filter(\.changed).isEmpty {
Text("No validation issues, duplicate candidates, or canonical row diffs were returned for this proposal.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
ForEach(row.validation) { issue in
HStack(alignment: .top, spacing: 8) {
Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle")
.foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning)
Text("\(issue.field): \(issue.message)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
ForEach(row.duplicateCandidates.prefix(3)) { candidate in
VStack(alignment: .leading, spacing: 4) {
Text("\(candidate.fullName) · \(candidate.matchReason) match · \(candidate.matchScore)%")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
Text([candidate.primaryPhone, candidate.primaryEmail].compactMap { $0?.trimmedNonEmpty }.joined(separator: " · "))
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
ForEach(row.fieldDiffs.filter(\.changed).prefix(6)) { diff in
Text("\(diff.field): \(diff.existing ?? "-")\(diff.proposed ?? "-")")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.foreground)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
}
private func duplicatePolicyPicker(_ row: VelocityImportWorkbenchRowDTO) -> some View {
VStack(alignment: .leading, spacing: 10) {
Text("Duplicate Merge Policy")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Picker("Duplicate policy", selection: $duplicatePolicy) {
ForEach(duplicatePolicyOptions()) { policy in
Text(policy.label).tag(policy.value)
}
}
.pickerStyle(.segmented)
if let guidance = duplicatePolicyOptions().first(where: { $0.value == duplicatePolicy })?.description?.trimmedNonEmpty {
Text(guidance)
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if row.duplicateCandidates.isEmpty {
Text("No duplicate candidates were returned by the backend for this row.")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
} else if let candidate = row.duplicateCandidates.first {
Text("Strongest candidate: \(candidate.fullName) · \(candidate.matchReason) · \(candidate.matchScore)%")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
}
private func duplicatePolicyOptions() -> [VelocityVocabularyOptionDTO] {
guard !duplicatePolicies.contains(where: { $0.value == duplicatePolicy }),
let current = duplicatePolicy.trimmedNonEmpty
else {
return duplicatePolicies
}
return [
VelocityVocabularyOptionDTO(
value: current,
label: current.replacingOccurrences(of: "_", with: " ").capitalized,
description: "Current backend value",
icon: nil
)
] + duplicatePolicies
}
private func submit(_ decision: String) {
let cleanedOverrides = fieldOverrides.compactMapValues { value in
value.trimmedNonEmpty
}
let defaultNote = decision == "needs_more_info"
? "Marked needs more information from iPad Imports remediation."
: "Approved with iPad Imports field corrections."
guard let selectedPolicy = duplicatePolicy.trimmedNonEmpty else {
return
}
onSubmit(decision, notes.trimmedNonEmpty ?? defaultNote, cleanedOverrides, selectedPolicy)
dismiss()
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
#Preview {
ImportsView()
}

View File

@@ -3,6 +3,7 @@ import CoreLocation
import CoreMotion
import SceneKit
import SwiftUI
import UIKit
// MARK: - ARSunOverlayView
@@ -22,6 +23,13 @@ struct ARSunOverlayView: UIViewRepresentable {
let config = ARWorldTrackingConfiguration()
config.worldAlignment = .gravityAndHeading // north = -Z axis
config.planeDetection = [.horizontal, .vertical]
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
config.sceneReconstruction = .mesh
}
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
config.frameSemantics.insert(.sceneDepth)
}
view.session.run(config)
context.coordinator.attach(to: view)
@@ -46,7 +54,9 @@ struct ARSunOverlayView: UIViewRepresentable {
// Scene node containers (replaced on each rebuild)
private var arcRootNode = SCNNode()
private var currentSunNode = SCNNode()
private var measurementRootNode = SCNNode()
private var isSceneBuilt = false
private var pendingMeasurementPoint: SCNVector3?
// Fallback timer for CoreMotion-only mode
private var fallbackTimer: Timer?
@@ -61,6 +71,10 @@ struct ARSunOverlayView: UIViewRepresentable {
self.sceneView = sceneView
sceneView.scene.rootNode.addChildNode(arcRootNode)
sceneView.scene.rootNode.addChildNode(currentSunNode)
sceneView.scene.rootNode.addChildNode(measurementRootNode)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:)))
sceneView.addGestureRecognizer(tap)
}
func stop() {
@@ -107,6 +121,59 @@ struct ARSunOverlayView: UIViewRepresentable {
}
}
// MARK: - Measurement
@objc private func handleMeasurementTap(_ recognizer: UITapGestureRecognizer) {
guard let sceneView else { return }
let point = recognizer.location(in: sceneView)
guard let query = sceneView.raycastQuery(
from: point,
allowing: .estimatedPlane,
alignment: .any
),
let result = sceneView.session.raycast(query).first else { return }
let transform = result.worldTransform
let worldPoint = SCNVector3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z)
addMeasurementPoint(worldPoint)
}
private func addMeasurementPoint(_ point: SCNVector3) {
measurementRootNode.addChildNode(makeMeasurementMarker(at: point))
if let start = pendingMeasurementPoint {
let distance = start.distance(to: point)
measurementRootNode.addChildNode(makeLineNode(through: [start, point], color: UIColor.white.withAlphaComponent(0.82)))
let midpoint = SCNVector3(
(start.x + point.x) / 2,
(start.y + point.y) / 2 + 0.045,
(start.z + point.z) / 2
)
let label = makeTextNode(
text: "\(String(format: "%.2f m", Double(distance))) \(String(format: "%.1f ft", Double(distance * 3.28084)))",
color: .white,
fontSize: 0.052
)
label.position = midpoint
measurementRootNode.addChildNode(label)
pendingMeasurementPoint = nil
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
} else {
pendingMeasurementPoint = point
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
}
}
private func makeMeasurementMarker(at position: SCNVector3) -> SCNNode {
let sphere = SCNSphere(radius: 0.018)
sphere.firstMaterial?.diffuse.contents = UIColor.white
sphere.firstMaterial?.emission.contents = UIColor.systemBlue.withAlphaComponent(0.65)
sphere.firstMaterial?.lightingModel = .constant
let node = SCNNode(geometry: sphere)
node.position = position
return node
}
// MARK: - Scene Building
private func buildScene() {
@@ -274,3 +341,12 @@ struct ARSunOverlayView: UIViewRepresentable {
private extension Double {
var radians: Double { self * .pi / 180.0 }
}
private extension SCNVector3 {
func distance(to other: SCNVector3) -> Float {
let dx = other.x - x
let dy = other.y - y
let dz = other.z - z
return sqrtf(dx * dx + dy * dy + dz * dz)
}
}

View File

@@ -13,11 +13,7 @@ enum InventoryModeAvailability {
}
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
if hasDollhouseAsset {
modes.append(.dollhouse)
}
return modes
[.sunseeker, .dreamWeaver]
}
static func sanitizedProductionSelection(
@@ -28,8 +24,9 @@ enum InventoryModeAvailability {
}
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
.map(\.rawValue)
.joined(separator: " · ")
let base = productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).map(\.rawValue)
return hasDollhouseAsset
? (base + ["Map-to-Dollhouse"]).joined(separator: " · ")
: base.joined(separator: " · ")
}
}

View File

@@ -1,252 +1,18 @@
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 {
struct SimulatorSunOverlayView: View {
@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)
var body: some View {
ContentUnavailableView(
"Sunseeker Unavailable",
systemImage: "arkit",
description: Text("Run on a physical iPad to use live location, heading, and ARKit camera data.")
)
.onAppear {
sunNodesReady = false
}
}
}

View File

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

View File

@@ -1,4 +1,6 @@
import Combine
import AVFoundation
import AudioToolbox
import Speech
import SwiftUI
enum OracleMode: String, CaseIterable {
@@ -27,9 +29,180 @@ enum OracleMode: String, CaseIterable {
}
}
struct OracleConciergeSheet: View {
@State private var transcript = ""
@State private var resultText = ""
@State private var errorText: String?
@State private var isRecording = false
@State private var isQuerying = false
@State private var speechAuthorized = false
@State private var audioEngine = AVAudioEngine()
@State private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
@State private var recognitionTask: SFSpeechRecognitionTask?
private let recognizer = SFSpeechRecognizer()
var body: some View {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Oracle Concierge")
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("Push to talk. Live query routes to `/api/oracle/query`.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Button {
isRecording ? stopRecordingAndQuery() : startRecording()
} label: {
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.frame(width: 54, height: 54)
.background(Circle().fill(isRecording ? VelocityTheme.danger : VelocityTheme.accent))
.shadow(color: (isRecording ? VelocityTheme.danger : VelocityTheme.accent).opacity(0.45), radius: isRecording ? 18 : 10)
}
.buttonStyle(.plain)
.disabled(!speechAuthorized || isQuerying)
}
if !transcript.isEmpty {
Text(transcript)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(VelocityTheme.foreground)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
.transition(.opacity.combined(with: .scale))
}
if isQuerying {
ProgressView("Asking Oracle...")
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
.foregroundStyle(VelocityTheme.mutedFg)
} else if !resultText.isEmpty {
Text(resultText)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.foreground)
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.10)))
.transition(.move(edge: .top).combined(with: .opacity))
}
if let errorText {
Text(errorText)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
}
}
.padding(24)
Divider().background(VelocityTheme.borderSubtle)
OracleView()
}
.background(VelocityTheme.background)
.task { await requestSpeechAuthorization() }
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: isRecording)
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: resultText)
}
private func requestSpeechAuthorization() async {
let status = await withCheckedContinuation { continuation in
SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
}
await MainActor.run {
speechAuthorized = status == .authorized
if !speechAuthorized {
errorText = "Speech recognition permission is required for voice Oracle."
}
}
}
private func startRecording() {
recognitionTask?.cancel()
recognitionTask = nil
transcript = ""
resultText = ""
errorText = nil
let session = AVAudioSession.sharedInstance()
do {
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
try session.setActive(true, options: .notifyOthersOnDeactivation)
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
recognitionRequest = request
let inputNode = audioEngine.inputNode
let format = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
request.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) {
isRecording = true
}
recognitionTask = recognizer?.recognitionTask(with: request) { result, error in
Task { @MainActor in
if let result {
transcript = result.bestTranscription.formattedString
}
if let error {
errorText = error.localizedDescription
stopRecording()
}
}
}
} catch {
errorText = error.localizedDescription
stopRecording()
}
}
private func stopRecordingAndQuery() {
stopRecording()
let prompt = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !prompt.isEmpty else { return }
Task { await queryOracle(prompt) }
}
private func stopRecording() {
audioEngine.stop()
audioEngine.inputNode.removeTap(onBus: 0)
recognitionRequest?.endAudio()
recognitionTask?.cancel()
recognitionRequest = nil
recognitionTask = nil
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) {
isRecording = false
}
}
@MainActor
private func queryOracle(_ prompt: String) async {
isQuerying = true
errorText = nil
do {
let response = try await VelocityAPIClient.shared.queryOracle(prompt: prompt)
resultText = response.displaySummary
} catch {
errorText = error.localizedDescription
}
isQuerying = false
}
}
struct OracleView: View {
@State private var store = AppStore.shared
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.pipeline)
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.accountTimeline)
@State private var selectedClient360: VelocityClient360DTO?
@State private var selectedClient360PersonID: String?
@State private var isClient360Loading = false
@@ -39,7 +212,11 @@ struct OracleView: View {
@State private var activeTaskMutationID: String?
@State private var activeLeadMutationID: String?
@State private var activeOpportunityMutationID: String?
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
@State private var editingOpportunity: VelocityOpportunityDTO?
@State private var teamPerformance: VelocityOracleTeamPerformanceDTO?
@State private var leadMap: VelocityOracleLeadMapDTO?
@State private var isOracleInsightLoading = false
@State private var oracleInsightError: String?
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -77,17 +254,39 @@ struct OracleView: View {
}
}
.background(VelocityTheme.background)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.task {
await store.refresh()
await loadOracleInsightData(for: selectedMode)
}
.refreshable {
await store.refresh()
await loadOracleInsightData(for: selectedMode)
}
.onAppear {
selectedMode = OracleModeAvailability.sanitizedProductionSelection(selectedMode)
}
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.onChange(of: selectedMode) { _, mode in
Task { await loadOracleInsightData(for: mode) }
}
.sheet(isPresented: client360PresentationBinding) {
client360Sheet
}
.sheet(item: $editingOpportunity) { opportunity in
OpportunityEditSheet(
opportunity: opportunity,
stages: store.crmVocabularies.opportunityStages
) { stage, value, probability, expectedCloseDate, nextAction, notes in
saveOpportunityEdits(
opportunity,
stage: stage,
value: value,
probability: probability,
expectedCloseDate: expectedCloseDate,
nextAction: nextAction,
notes: notes
)
}
}
}
private var header: some View {
@@ -96,7 +295,7 @@ struct OracleView: View {
Text("Oracle")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Live sales intelligence assembled from canonical CRM, communication events, and calendar data.")
Text("Ambient sales intelligence assembled from canonical CRM, communication events, and calendar data.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -148,11 +347,10 @@ struct OracleView: View {
timelineCanvas
case .calendarTasks:
calendarCanvas
case .teamPerformance, .leadMap:
unavailableCanvas(
title: "Oracle mode unavailable",
message: "This Oracle mode is intentionally hidden from the production iPad scope until a real backend contract exists."
)
case .teamPerformance:
teamPerformanceCanvas
case .leadMap:
leadMapCanvas
}
}
@@ -217,9 +415,15 @@ struct OracleView: View {
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
Text("\(lead.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
if store.isShowroomModeEnabled {
Image(systemName: "eye.slash")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(VelocityTheme.warning)
} else {
Text("\(lead.displayIntentScore)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(VelocityTheme.accent)
}
if activeLeadMutationID == lead.leadId {
ProgressView()
@@ -382,6 +586,9 @@ struct OracleView: View {
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
if store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-") {
pendingSyncBadge
}
Text(event.status.capitalized)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(color(for: event.status))
@@ -424,16 +631,21 @@ struct OracleView: View {
Text("\(task.ownerLabel) · \(task.clientPhone ?? "No phone")")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
if !store.isShowroomModeEnabled {
Text(taskNote(task))
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
VStack(alignment: .trailing, spacing: 8) {
if store.pendingSyncTaskIDs.contains(task.reminderId) {
pendingSyncBadge
}
Text(task.priorityLabel)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(taskColor(for: task.priority))
@@ -461,7 +673,7 @@ struct OracleView: View {
Text("Mobile Oracle Scope")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text("This production iPad build shows only live-backed Oracle views. Team Performance and Lead Map stay hidden until the backend exposes real mobile contracts.")
Text("This production iPad build shows live-backed Oracle views only. Team Performance and Lead Map now read dedicated mobile Oracle contracts instead of synthetic local projections.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -470,6 +682,13 @@ struct OracleView: View {
.glassCard(cornerRadius: 16)
}
private var pendingSyncBadge: some View {
Circle()
.fill(VelocityTheme.warning)
.frame(width: 8, height: 8)
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
}
private func unavailableCanvas(title: String, message: String) -> some View {
VStack(alignment: .leading, spacing: 16) {
summaryCard(title: title, body: message)
@@ -485,18 +704,16 @@ struct OracleView: View {
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(18)
.padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func emptyCard(_ message: String) -> some View {
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
.padding(18)
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 18)
}
private func color(for status: String) -> Color {
@@ -533,74 +750,198 @@ struct OracleView: View {
return formatter.string(from: start)
}
private var teamPerformanceCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Team Performance",
body: "Broker performance is read from canonical users, leads, opportunities, reminders, and interaction activity through `/api/oracle/v1/mobile/team-performance`."
)
if isOracleInsightLoading && teamPerformance == nil {
progressCard("Loading team performance...")
} else if let oracleInsightError {
errorBanner(oracleInsightError)
} else if let teamPerformance, !teamPerformance.performers.isEmpty {
HStack(spacing: 12) {
metricPill("Members", "\(teamPerformance.summary.teamMembers)")
metricPill("Assigned", "\(teamPerformance.summary.assignedLeads)")
metricPill("Open Tasks", "\(teamPerformance.summary.openTasks)")
metricPill("Pipeline", moneyLabel(teamPerformance.summary.pipelineValue))
}
ForEach(teamPerformance.performers) { performer in
VStack(alignment: .leading, spacing: 10) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(performer.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(performer.email ?? "No email")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
Text("\(Int(performer.conversionRate.rounded()))%")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(VelocityTheme.success)
}
HStack(spacing: 10) {
metricPill("Leads", "\(performer.assignedLeads)")
metricPill("Deals", "\(performer.activeOpportunities)")
metricPill("Tasks", "\(performer.openTasks)")
metricPill("Won", moneyLabel(performer.closedWonValue))
}
Text("Last activity \(performer.lastActivityAt ?? "not recorded")")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.glassCard(cornerRadius: 16)
}
} else {
emptyCard("No canonical team performance rows are available for this tenant yet.")
}
}
}
private var leadMapCanvas: some View {
VStack(alignment: .leading, spacing: 16) {
productionScopeNote
summaryCard(
title: "Lead Map",
body: "Lead geography is read from the Oracle lead geo rollup when present, with a canonical CRM city rollup fallback when precise coordinates are not stored."
)
if isOracleInsightLoading && leadMap == nil {
progressCard("Loading lead map...")
} else if let oracleInsightError {
errorBanner(oracleInsightError)
} else if let leadMap, !leadMap.points.isEmpty {
HStack(spacing: 12) {
metricPill("Locations", "\(leadMap.summary.locations)")
metricPill("Leads", "\(leadMap.summary.leadCount)")
metricPill("Hot Leads", "\(leadMap.summary.hotLeadCount)")
}
VStack(alignment: .leading, spacing: 10) {
ForEach(leadMap.points) { point in
HStack(spacing: 12) {
Circle()
.fill(point.hotLeadCount > 0 ? VelocityTheme.danger : VelocityTheme.accent)
.frame(width: max(12, min(34, CGFloat(point.leadCount + 10))), height: max(12, min(34, CGFloat(point.leadCount + 10))))
VStack(alignment: .leading, spacing: 4) {
Text(point.label)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Text(store.isShowroomModeEnabled ? "\(point.leadCount) leads · buyer-safe" : "\(point.leadCount) leads · \(point.hotLeadCount) hot · QD \(Int((point.avgQdScore * 100).rounded()))")
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
Spacer()
if let latitude = point.latitude, let longitude = point.longitude {
Text(String(format: "%.3f, %.3f", latitude, longitude))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
)
)
}
}
.padding(16)
.glassCard(cornerRadius: 16)
} else {
emptyCard("No canonical location or city-level CRM lead rollups are available yet.")
}
}
}
private func progressCard(_ message: String) -> some View {
HStack(spacing: 10) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
Text(message)
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.vertical, 14)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func metricPill(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title.uppercased())
.font(.system(size: 9, weight: .semibold))
.tracking(0.8)
.foregroundStyle(VelocityTheme.mutedFg)
Text(value)
.font(.system(size: 14, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
}
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func moneyLabel(_ value: Double) -> String {
if value >= 1_000_000 {
return String(format: "AED %.1fM", value / 1_000_000)
}
if value >= 1_000 {
return String(format: "AED %.0fK", value / 1_000)
}
return String(format: "AED %.0f", value)
}
private func loadOracleInsightData(for mode: OracleMode, silent: Bool = false) async {
guard mode == .teamPerformance || mode == .leadMap else {
return
}
if !silent {
await MainActor.run {
isOracleInsightLoading = true
oracleInsightError = nil
}
}
do {
switch mode {
case .teamPerformance:
let response = try await VelocityAPIClient.shared.fetchOracleTeamPerformance()
await MainActor.run { teamPerformance = response }
case .leadMap:
let response = try await VelocityAPIClient.shared.fetchOracleLeadMap()
await MainActor.run { leadMap = response }
default:
break
}
await MainActor.run {
isOracleInsightLoading = false
oracleInsightError = nil
}
} catch {
await MainActor.run {
isOracleInsightLoading = false
oracleInsightError = error.localizedDescription
}
}
}
private func opportunityActionsMenu(_ opportunity: VelocityOpportunityDTO) -> some View {
Menu {
Menu("Move Stage") {
ForEach(canonicalOpportunityStages.filter { $0 != opportunity.stage.lowercased() }, id: \.self) { stage in
Button {
mutateOpportunity(
opportunity,
stage: stage,
probability: nil,
nextAction: opportunity.nextAction,
notes: "Moved from the iPad Oracle deal workspace."
)
} label: {
Text(stageLabel(stage))
}
}
}
Menu("Set Probability") {
ForEach([25, 50, 75, 90], id: \.self) { probability in
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: probability,
nextAction: opportunity.nextAction,
notes: "Probability updated from the iPad Oracle deal workspace."
)
} label: {
Text("\(probability)%")
}
}
}
Button {
mutateOpportunity(
opportunity,
stage: nil,
probability: nil,
nextAction: "Schedule commercial follow-up",
notes: "Next action updated from the iPad Oracle deal workspace."
)
editingOpportunity = opportunity
} label: {
Label("Set Follow-Up Action", systemImage: "phone.arrow.up.right")
}
Button {
mutateOpportunity(
opportunity,
stage: "closed_won",
probability: 100,
nextAction: "Complete booking documentation",
notes: "Marked closed won from the iPad Oracle deal workspace."
)
} label: {
Label("Close Won", systemImage: "checkmark.seal")
}
Button(role: .destructive) {
mutateOpportunity(
opportunity,
stage: "closed_lost",
probability: 0,
nextAction: "Capture loss reason",
notes: "Marked closed lost from the iPad Oracle deal workspace."
)
} label: {
Label("Close Lost", systemImage: "xmark.seal")
Label("Edit Deal", systemImage: "square.and.pencil")
}
} label: {
menuIcon("ellipsis.circle")
@@ -669,16 +1010,16 @@ struct OracleView: View {
currentStatus: String
) -> some View {
Menu {
ForEach(canonicalLeadStages.filter { $0 != currentStatus.lowercased() }, id: \.self) { status in
ForEach(leadStageOptions(currentStatus: currentStatus)) { stage in
Button {
mutateLeadStage(
leadId: leadId,
personId: personId,
status: status,
status: stage.value,
notes: "Moved from the iPad Oracle pipeline."
)
} label: {
Text(stageLabel(status))
Text(stage.label)
}
}
} label: {
@@ -773,6 +1114,47 @@ struct OracleView: View {
await MainActor.run {
activeOpportunityMutationID = nil
actionMessage = opportunityActionMessage(stage: stage, probability: probability, nextAction: nextAction)
rewardClosedWonIfNeeded(stage)
}
} catch {
await MainActor.run {
activeOpportunityMutationID = nil
actionError = error.localizedDescription
}
}
}
}
private func saveOpportunityEdits(
_ opportunity: VelocityOpportunityDTO,
stage: String?,
value: Double?,
probability: Int?,
expectedCloseDate: String?,
nextAction: String?,
notes: String?
) {
actionError = nil
actionMessage = nil
activeOpportunityMutationID = opportunity.opportunityId
Task {
do {
_ = try await store.updateOpportunity(
opportunityId: opportunity.opportunityId,
stage: stage,
value: value,
probability: probability,
expectedCloseDate: expectedCloseDate,
nextAction: nextAction,
notes: notes
)
await refreshClient360IfNeeded(for: opportunity.personId ?? selectedClient360PersonID)
await MainActor.run {
activeOpportunityMutationID = nil
editingOpportunity = nil
actionMessage = "Opportunity updated."
rewardClosedWonIfNeeded(stage)
}
} catch {
await MainActor.run {
@@ -801,39 +1183,21 @@ struct OracleView: View {
}
}
private var canonicalLeadStages: [String] {
[
"new",
"contacted",
"qualified",
"site_visit_scheduled",
"site_visited",
"negotiation",
"booking_initiated",
"booked",
"lost",
"dormant",
]
}
private var canonicalOpportunityStages: [String] {
[
"prospect",
"qualified",
"proposal",
"site_visit",
"negotiation",
"booking",
"agreement",
"closed_won",
"closed_lost",
]
private func leadStageOptions(currentStatus: String) -> [VelocityVocabularyOptionDTO] {
store.crmVocabularies.leadStages.filter { $0.value != currentStatus.lowercased() }
}
private func stageLabel(_ status: String) -> String {
status.replacingOccurrences(of: "_", with: " ").capitalized
}
private func rewardClosedWonIfNeeded(_ stage: String?) {
let normalized = stage?.lowercased().replacingOccurrences(of: " ", with: "_") ?? ""
guard normalized.contains("closed_won") || normalized == "won" else { return }
UIImpactFeedbackGenerator(style: .heavy).impactOccurred(intensity: 1.0)
AudioServicesPlaySystemSound(1104)
}
private func iso8601Timestamp(_ date: Date) -> String {
ISO8601DateFormatter().string(from: date)
}
@@ -1123,17 +1487,17 @@ struct OracleView: View {
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
if let score = snapshot.primaryQDScore {
if !store.isShowroomModeEnabled, let score = snapshot.primaryQDScore {
Text("\(score.scoreType.replacingOccurrences(of: "_", with: " ").capitalized) score: \(score.displayScore)")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.foreground)
}
if !snapshot.riskFlags.isEmpty {
if !store.isShowroomModeEnabled && !snapshot.riskFlags.isEmpty {
Text("Risk flags: \(snapshot.riskFlags.joined(separator: ", "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
if !snapshot.recommendedNextActions.isEmpty {
if !store.isShowroomModeEnabled && !snapshot.recommendedNextActions.isEmpty {
Text("Next actions: \(snapshot.recommendedNextActions.joined(separator: " · "))")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
@@ -1218,6 +1582,153 @@ struct OracleView: View {
}
}
private struct OpportunityEditSheet: View {
let opportunity: VelocityOpportunityDTO
let stages: [VelocityVocabularyOptionDTO]
let onSave: (String?, Double?, Int?, String?, String?, String?) -> Void
@Environment(\.dismiss) private var dismiss
@State private var stage: String
@State private var valueText: String
@State private var probabilityText: String
@State private var expectedCloseDate: String
@State private var nextAction: String
@State private var notes: String
@State private var validationMessage: String?
init(
opportunity: VelocityOpportunityDTO,
stages: [VelocityVocabularyOptionDTO],
onSave: @escaping (String?, Double?, Int?, String?, String?, String?) -> Void
) {
self.opportunity = opportunity
self.stages = stages
self.onSave = onSave
_stage = State(initialValue: opportunity.stage)
_valueText = State(initialValue: opportunity.value.map { String(format: "%.0f", $0) } ?? "")
_probabilityText = State(initialValue: opportunity.probabilityPercent.map(String.init) ?? "")
_expectedCloseDate = State(initialValue: opportunity.expectedCloseDate ?? "")
_nextAction = State(initialValue: opportunity.nextAction ?? "")
_notes = State(initialValue: opportunity.notes ?? "")
}
var body: some View {
NavigationStack {
Form {
Section("Deal") {
Picker("Stage", selection: $stage) {
ForEach(stageOptions()) { value in
Text(value.label)
.tag(value.value)
}
}
TextField("Value", text: $valueText)
.keyboardType(.decimalPad)
TextField("Probability", text: $probabilityText)
.keyboardType(.numberPad)
TextField("Expected close date", text: $expectedCloseDate)
.textInputAutocapitalization(.never)
}
Section("Operator Context") {
TextField("Next action", text: $nextAction, axis: .vertical)
TextField("Notes", text: $notes, axis: .vertical)
}
if let validationMessage {
Section {
Text(validationMessage)
.foregroundStyle(VelocityTheme.danger)
}
}
}
.scrollContentBackground(.hidden)
.background(VelocityTheme.background)
.navigationTitle("Edit Deal")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
save()
}
.fontWeight(.semibold)
}
}
}
}
private func stageOptions() -> [VelocityVocabularyOptionDTO] {
guard !stages.contains(where: { $0.value == stage }) else {
return stages
}
return [
VelocityVocabularyOptionDTO(
value: stage,
label: stage.replacingOccurrences(of: "_", with: " ").capitalized,
description: "Current backend value",
icon: nil
)
] + stages
}
private func save() {
let value: Double?
if let rawValue = valueText.trimmedNonEmpty {
guard let parsedValue = Double(rawValue) else {
validationMessage = "Enter a valid numeric opportunity value."
return
}
value = parsedValue
} else {
value = nil
}
let probability: Int?
if let rawProbability = probabilityText.trimmedNonEmpty {
guard let parsedProbability = Int(rawProbability), (0...100).contains(parsedProbability) else {
validationMessage = "Probability must be a whole number from 0 to 100."
return
}
probability = parsedProbability
} else {
probability = nil
}
if let closeDate = expectedCloseDate.trimmedNonEmpty,
!Self.isValidISODate(closeDate) {
validationMessage = "Expected close date must be YYYY-MM-DD."
return
}
onSave(
stage,
value,
probability,
expectedCloseDate.trimmedNonEmpty,
nextAction.trimmedNonEmpty,
notes.trimmedNonEmpty
)
dismiss()
}
private static func isValidISODate(_ value: String) -> Bool {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: value) != nil
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
}
#Preview {
OracleView()
}

View File

@@ -1,9 +1,9 @@
import Foundation
enum SentinelScope {
static let navigationTitle = "Operator Posture"
static let navigationTitle = "Sentinel"
static let productFamilyName = "Sentinel"
static let availabilityBadge = "Operator posture only"
static let availabilityBadge = "Live perception analytics"
static let disabledAnalyticsCapabilities: [String] = [
"visitor counting",
@@ -12,6 +12,9 @@ enum SentinelScope {
]
static let liveBackedCapabilities: [String] = [
"visitor counting",
"sentiment distribution",
"journey intelligence",
"alert posture",
"transcription queue visibility",
"upcoming calendar pressure",

View File

@@ -1,9 +1,10 @@
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()
@State private var analytics: VelocitySentinelLiveAnalyticsDTO?
@State private var analyticsError: String?
@State private var isAnalyticsLoading = false
var body: some View {
ScrollView {
@@ -15,6 +16,9 @@ struct SentinelView: View {
}
availabilityCard
analyticsCards
sentimentCard
journeyCard
postureCards
timelineCard
}
@@ -22,10 +26,13 @@ struct SentinelView: View {
}
.background(VelocityTheme.background)
.scrollContentBackground(.hidden)
.task { await store.refresh() }
.refreshable { await store.refresh() }
.onReceive(refreshTimer) { _ in
Task { await store.refresh(silent: true) }
.task {
await store.refresh()
await loadAnalytics()
}
.refreshable {
await store.refresh()
await loadAnalytics()
}
}
@@ -38,7 +45,7 @@ struct SentinelView: View {
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.")
Text("Live showroom perception analytics from the production Sentinel websocket and persisted perception intelligence.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
@@ -53,17 +60,122 @@ struct SentinelView: View {
Spacer()
statusBadge(
label: SentinelScope.availabilityBadge,
color: VelocityTheme.warning
color: VelocityTheme.success
)
}
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
Text("This iPad build reads `/api/sentinel/analytics/live`, which summarizes the real `/api/sentinel/ws/perception` stream after biometric packets are persisted by the backend.")
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
Text("Live-backed capabilities: \(SentinelScope.liveBackedSummary).")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
if let analyticsError {
Text(analyticsError)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
} else if isAnalyticsLoading {
Text("Loading live perception analytics...")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
} else if let analytics {
Text("Stream: \(analytics.liveStreamPath)")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(VelocityTheme.accent)
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var analyticsCards: some View {
HStack(spacing: 14) {
SentinelCard(
title: "Active visitors",
value: "\(analytics?.activeSessions ?? 0)",
subtitle: "Open Sentinel perception sessions",
color: VelocityTheme.accent
)
SentinelCard(
title: "Visitors 24h",
value: "\(analytics?.visitorCount24h ?? 0)",
subtitle: "Sessions started in the last day",
color: VelocityTheme.success
)
SentinelCard(
title: "Avg QD",
value: String(format: "%.0f", analytics?.averageQdScore ?? 0),
subtitle: "Average finalized session score",
color: VelocityTheme.warning
)
}
}
private var sentimentCard: some View {
let distribution = analytics?.sentimentDistribution ?? [:]
return VStack(alignment: .leading, spacing: 14) {
Text("Sentiment Distribution")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
HStack(spacing: 12) {
sentimentPill("Positive", distribution["positive"] ?? 0, VelocityTheme.success)
sentimentPill("Neutral", distribution["neutral"] ?? 0, VelocityTheme.accent)
sentimentPill("Negative", distribution["negative"] ?? 0, VelocityTheme.danger)
}
}
.padding(20)
.glassCard(cornerRadius: 18)
}
private var journeyCard: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("Showroom Journey")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text("Live feed")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(VelocityTheme.success)
}
if analytics?.journey.isEmpty ?? true {
Text("No perception journey events have been persisted yet.")
.font(.system(size: 13))
.foregroundStyle(VelocityTheme.mutedFg)
} else {
ForEach(analytics?.journey.prefix(8) ?? []) { event in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(event.eventType.replacingOccurrences(of: "_", with: " ").capitalized)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(VelocityTheme.foreground)
Spacer()
Text(String(format: "%.0f%%", event.engagementScore * 100))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(event.engagementScore >= 0.7 ? VelocityTheme.success : VelocityTheme.accent)
}
Text(event.sceneLabel?.trimmedNonEmpty ?? event.sessionRef ?? "No scene label")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
Text(event.happenedAt ?? "Timestamp pending")
.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)
@@ -174,6 +286,54 @@ struct SentinelView: View {
)
)
}
private func sentimentPill(_ label: String, _ value: Int, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(label.uppercased())
.font(.system(size: 10, weight: .semibold))
.tracking(1)
.foregroundStyle(VelocityTheme.mutedFg)
Text("\(value)")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(color)
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(VelocityTheme.surface)
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(color.opacity(0.22), lineWidth: 1)
)
)
}
private func loadAnalytics(silent: Bool = false) async {
if !silent {
await MainActor.run {
isAnalyticsLoading = true
analyticsError = nil
}
}
do {
let response = try await VelocityAPIClient.shared.fetchSentinelLiveAnalytics()
await MainActor.run {
analytics = response
analyticsError = nil
isAnalyticsLoading = false
}
} catch {
await MainActor.run {
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
analyticsError = "Sentinel analytics is not available on the configured backend yet."
} else {
analyticsError = error.localizedDescription
}
isAnalyticsLoading = false
}
}
}
}
private struct SentinelCard: View {
@@ -207,3 +367,10 @@ private struct SentinelCard: View {
#Preview {
SentinelView()
}
private extension String {
var trimmedNonEmpty: String? {
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@@ -24,9 +24,9 @@ struct SessionConfigurationPanel: View {
VStack(spacing: 14) {
SessionInputField(
label: "Backend endpoint",
placeholder: "https://velocity.desineuron.in/api"
placeholder: SessionConfigurationDefaults.productionBaseURL
) {
TextField("", text: $session.draftBaseURL, prompt: Text("https://velocity.desineuron.in/api"))
TextField("", text: $session.draftBaseURL, prompt: Text(SessionConfigurationDefaults.productionBaseURL))
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)

View File

@@ -1,20 +1,36 @@
import SwiftUI
import UIKit
struct SettingsView: View {
@State private var store = AppStore.shared
@State private var session = SessionStore.shared
@State private var ssoProviders: VelocitySSOProvidersDTO?
@State private var mdmConfig: VelocityMDMConfigDTO?
@State private var tenantUsers: [VelocityAuthUserDTO] = []
@State private var identityMessage: String?
@State private var identityError: String?
@State private var isSwitchingSession = false
@State private var isAdvancedConfigurationUnlocked = false
@AppStorage("velocity.notifications.clientInsights") private var clientInsightNotifications = true
@AppStorage("velocity.notifications.calendar") private var calendarNotifications = true
@AppStorage("velocity.notifications.showroom") private var showroomNotifications = false
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)
}
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 4) {
Text("Settings")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(VelocityTheme.foreground)
Text("Profile and notification preferences")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
profileSection
notificationPreferencesSection
if isAdvancedConfigurationUnlocked {
SettingsSection(title: "Connectivity") {
SettingsRow(
label: "Backend endpoint",
@@ -96,6 +112,81 @@ struct SettingsView: View {
)
}
SettingsSection(title: "Enterprise Identity") {
SettingsRow(
label: "SSO providers",
value: ssoProviders?.providers.map(\.name).joined(separator: ", ") ?? "Not loaded",
icon: "person.badge.key",
accentColor: ssoProviders?.enabled == true ? VelocityTheme.success : VelocityTheme.warning
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "MDM configuration",
value: mdmConfig?.managedConfigurationRequired == true ? "Required · \(mdmConfig?.configurationKeys.count ?? 0) keys" : "Optional · \(mdmConfig?.configurationKeys.count ?? 0) keys",
icon: "iphone.badge.gearshape",
accentColor: mdmConfig == nil ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Tenant users",
value: tenantUsers.isEmpty ? "Not loaded" : "\(tenantUsers.count) available",
icon: "person.2.badge.gearshape",
accentColor: tenantUsers.isEmpty ? VelocityTheme.warning : VelocityTheme.success
)
Divider().background(VelocityTheme.borderSubtle)
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Button {
Task { await requestPasswordRecovery() }
} label: {
Label("Request Recovery", systemImage: "lock.rotation")
}
.buttonStyle(.borderedProminent)
.tint(VelocityTheme.accent)
Button {
Task { await loadEnterpriseIdentity() }
} label: {
Label("Refresh Identity", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.tint(VelocityTheme.accent)
Menu {
if tenantUsers.isEmpty {
Text("No tenant users returned")
} else {
ForEach(tenantUsers) { user in
Button {
Task { await switchSession(to: user) }
} label: {
Label("\(user.displayName) · \(user.role)", systemImage: "person.crop.circle.badge.checkmark")
}
}
}
} label: {
Label(isSwitchingSession ? "Switching..." : "Switch User", systemImage: "person.2")
}
.buttonStyle(.bordered)
.tint(VelocityTheme.accent)
.disabled(isSwitchingSession || tenantUsers.isEmpty)
}
if let identityMessage {
Text(identityMessage)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.success)
}
if let identityError {
Text(identityError)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(VelocityTheme.danger)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
SettingsSection(title: "Production Readiness") {
SettingsRow(
label: "Canonical contacts",
@@ -145,19 +236,151 @@ struct SettingsView: View {
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.")
Text("\(SentinelScope.navigationTitle) now reads persisted perception analytics from the production Sentinel stream; Communications, Calendar, Dashboard, Oracle, and inventory media are live-backed. Dream Weaver can use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are enforced and reported truthfully.")
.font(.system(size: 12))
.foregroundStyle(VelocityTheme.mutedFg)
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
}
}
Spacer()
Spacer(minLength: 24)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.padding(24)
.overlay(alignment: .topTrailing) {
ThreeFingerLongPressGate {
withAnimation(.interactiveSpring(response: 0.45, dampingFraction: 0.86)) {
isAdvancedConfigurationUnlocked = true
}
}
.frame(width: 180, height: 180)
.allowsHitTesting(!isAdvancedConfigurationUnlocked)
}
.scrollIndicators(.visible)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(VelocityTheme.background)
.task {
if isAdvancedConfigurationUnlocked {
await loadEnterpriseIdentity()
}
}
.onChange(of: isAdvancedConfigurationUnlocked) { _, unlocked in
guard unlocked else { return }
Task { await loadEnterpriseIdentity() }
}
}
private var profileSection: some View {
SettingsSection(title: "Profile") {
SettingsRow(
label: "Signed in",
value: session.operatorIdentity,
icon: "person.crop.circle",
accentColor: VelocityTheme.accent
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Session",
value: session.authModeDescription,
icon: "lock.shield",
accentColor: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
)
Divider().background(VelocityTheme.borderSubtle)
SettingsRow(
label: "Showroom privacy",
value: store.isShowroomModeEnabled ? "Buyer-safe" : "Broker private",
icon: store.isShowroomModeEnabled ? "eye.slash" : "eye",
accentColor: store.isShowroomModeEnabled ? VelocityTheme.warning : VelocityTheme.success
)
}
}
private var notificationPreferencesSection: some View {
SettingsSection(title: "Notifications") {
ToggleRow(
label: "Client insight alerts",
detail: "Private broker recommendations",
icon: "sparkles",
accentColor: VelocityTheme.accent,
isOn: $clientInsightNotifications
)
Divider().background(VelocityTheme.borderSubtle)
ToggleRow(
label: "Calendar reminders",
detail: "Confirmed events and follow-ups",
icon: "calendar.badge.clock",
accentColor: VelocityTheme.warning,
isOn: $calendarNotifications
)
Divider().background(VelocityTheme.borderSubtle)
ToggleRow(
label: "Showroom mode changes",
detail: "Buyer-safe privacy transitions",
icon: "eye.slash",
accentColor: VelocityTheme.success,
isOn: $showroomNotifications
)
}
}
private func loadEnterpriseIdentity() async {
do {
async let providers = VelocityAPIClient.shared.fetchSSOProviders()
async let mdm = VelocityAPIClient.shared.fetchMDMConfig()
async let users = VelocityAPIClient.shared.fetchAuthUsers()
let resolvedProviders = try await providers
let resolvedMDM = try await mdm
let resolvedUsers = (try? await users) ?? []
await MainActor.run {
ssoProviders = resolvedProviders
mdmConfig = resolvedMDM
tenantUsers = resolvedUsers
identityError = nil
}
} catch {
await MainActor.run { identityError = error.localizedDescription }
}
}
private func requestPasswordRecovery() async {
do {
try await VelocityAPIClient.shared.requestPasswordRecovery(email: session.operatorIdentity)
await MainActor.run {
identityMessage = "Password recovery request recorded for \(session.operatorIdentity)."
identityError = nil
}
} catch {
await MainActor.run {
identityError = error.localizedDescription
identityMessage = nil
}
}
}
private func switchSession(to user: VelocityAuthUserDTO) async {
await MainActor.run {
isSwitchingSession = true
identityError = nil
identityMessage = nil
}
do {
let result = try await VelocityAPIClient.shared.requestSessionSwitch(userId: user.userId)
await store.refresh(silent: true)
await MainActor.run {
isSwitchingSession = false
identityMessage = result.requiresReauthentication
? "Session switch approved for \(user.displayName); reauthentication is required."
: "Session switched to \(user.displayName)."
}
} catch {
await MainActor.run {
isSwitchingSession = false
identityError = error.localizedDescription
}
}
}
}
@@ -222,3 +445,76 @@ private struct SettingsRow: View {
.padding(.vertical, 12)
}
}
private struct ToggleRow: View {
let label: String
let detail: String
let icon: String
let accentColor: Color
@Binding var isOn: Bool
var body: some View {
Toggle(isOn: $isOn) {
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)
}
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 14))
.foregroundStyle(VelocityTheme.foreground)
Text(detail)
.font(.system(size: 11))
.foregroundStyle(VelocityTheme.mutedFg)
}
}
}
.toggleStyle(.switch)
.tint(VelocityTheme.accent)
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
private struct ThreeFingerLongPressGate: UIViewRepresentable {
let onUnlock: () -> Void
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.backgroundColor = .clear
let recognizer = UILongPressGestureRecognizer(
target: context.coordinator,
action: #selector(Coordinator.didLongPress(_:))
)
recognizer.minimumPressDuration = 1.15
recognizer.numberOfTouchesRequired = 3
view.addGestureRecognizer(recognizer)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onUnlock: onUnlock)
}
final class Coordinator: NSObject {
let onUnlock: () -> Void
init(onUnlock: @escaping () -> Void) {
self.onUnlock = onUnlock
}
@objc func didLongPress(_ recognizer: UILongPressGestureRecognizer) {
guard recognizer.state == .began else { return }
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
onUnlock()
}
}
}