feat: Ipad app production readiness, Colony orchestration, Social posting
This commit is contained in:
@@ -1,21 +1,24 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
import UIKit
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case clients = "Clients"
|
||||
case imports = "Imports"
|
||||
case communications = "Communications"
|
||||
case calendar = "Calendar"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var displayTitle: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var dockTitle: String {
|
||||
switch self {
|
||||
case .sentinel:
|
||||
return SentinelScope.navigationTitle
|
||||
case .communications:
|
||||
return "Comms"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
@@ -25,11 +28,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .clients: return "person.text.rectangle"
|
||||
case .imports: return "tray.and.arrow.down"
|
||||
case .communications: return "phone.connection"
|
||||
case .calendar: return "calendar.badge.clock"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
@@ -39,11 +39,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .clients: return Color(red: 0.22, green: 0.78, blue: 0.96)
|
||||
case .imports: return Color(red: 0.94, green: 0.70, blue: 0.25)
|
||||
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
|
||||
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
|
||||
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
|
||||
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
|
||||
case .inventory: return VelocityTheme.warning
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
@@ -51,102 +48,233 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
@State private var selectedSection: AppSection = .dashboard
|
||||
@State private var session = SessionStore.shared
|
||||
@State private var store = AppStore.shared
|
||||
@State private var isOraclePresented = false
|
||||
@State private var dockFocusedSection: AppSection?
|
||||
@State private var isPrivacyLocked = true
|
||||
@State private var isAuthenticating = false
|
||||
@State private var privacyMessage = "Unlock Velocity"
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Namespace private var dockSelectionNamespace
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if session.isConfigured {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
ZStack(alignment: .bottom) {
|
||||
detailContent
|
||||
floatingNavigationPill
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
OracleFloatingOrb(alertCount: oracleAlertCount) {
|
||||
isOraclePresented = true
|
||||
}
|
||||
.padding(.trailing, 28)
|
||||
.padding(.bottom, selectedSection == .clients ? 154 : 112)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
OfflineSyncGlow(isActive: hasPendingSync)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
VaultShareToast(
|
||||
message: store.vaultShareMessage,
|
||||
error: store.vaultShareError
|
||||
) {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
store.vaultShareMessage = nil
|
||||
store.vaultShareError = nil
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
}
|
||||
.overlay {
|
||||
if store.isShowroomModeEnabled, store.errorMessage != nil {
|
||||
ShowroomAmbientFallback()
|
||||
.transition(.opacity.animation(.interactiveSpring(response: 0.55, dampingFraction: 0.9)))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if isPrivacyLocked {
|
||||
PrivacyLockOverlay(
|
||||
message: privacyMessage,
|
||||
isAuthenticating: isAuthenticating
|
||||
) {
|
||||
authenticateSession()
|
||||
}
|
||||
.transition(.opacity.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.88)))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isOraclePresented) {
|
||||
OracleConciergeSheet()
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
} else {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
.onAppear {
|
||||
authenticateSession()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background, .inactive:
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.9)) {
|
||||
isPrivacyLocked = true
|
||||
privacyMessage = "Velocity is locked"
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
Button {
|
||||
selectedSection = section
|
||||
} label: {
|
||||
SidebarRow(section: section, isSelected: selectedSection == section)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.displayTitle)
|
||||
.accessibilityAddTraits(selectedSection == section ? [.isSelected] : [])
|
||||
}
|
||||
case .active:
|
||||
if isPrivacyLocked {
|
||||
authenticateSession()
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var floatingNavigationPill: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
let isSelected = selectedSection == section
|
||||
let isFocused = dockFocusedSection == section
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.78)) {
|
||||
selectedSection = section
|
||||
dockFocusedSection = section
|
||||
}
|
||||
hideDockTooltipAfterTap(for: section)
|
||||
} label: {
|
||||
dockItem(for: section, isSelected: isSelected, isFocused: isFocused)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.dockTitle)
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
.onHover { hovering in
|
||||
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.76)) {
|
||||
dockFocusedSection = hovering ? section : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.top, 9)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color.black.opacity(0.34))
|
||||
.blur(radius: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.42), radius: 28, y: 18)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func dockItem(for section: AppSection, isSelected: Bool, isFocused: Bool) -> some View {
|
||||
VStack(spacing: 7) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
section.accentColor.opacity(isSelected ? 0.34 : 0.18),
|
||||
Color.white.opacity(isSelected ? 0.12 : 0.05),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.white.opacity(isSelected ? 0.26 : 0.12), lineWidth: 1)
|
||||
)
|
||||
.shadow(
|
||||
color: section.accentColor.opacity(isSelected || isFocused ? 0.32 : 0.10),
|
||||
radius: isSelected || isFocused ? 16 : 7,
|
||||
y: isSelected || isFocused ? 8 : 3
|
||||
)
|
||||
.if(isSelected) { view in
|
||||
view.matchedGeometryEffect(id: "selectedDockTile", in: dockSelectionNamespace)
|
||||
}
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: isFocused ? 24 : (isSelected ? 22 : 19), weight: .semibold))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : section.accentColor)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.scaleEffect(isFocused ? 1.34 : (isSelected ? 1.16 : 1.0), anchor: .bottom)
|
||||
.offset(y: isFocused ? -13 : (isSelected ? -5 : 0))
|
||||
|
||||
Circle()
|
||||
.fill(isSelected ? section.accentColor.opacity(0.92) : Color.clear)
|
||||
.frame(width: 5, height: 5)
|
||||
.shadow(color: section.accentColor.opacity(isSelected ? 0.45 : 0), radius: 5)
|
||||
}
|
||||
.frame(width: 58, height: 70, alignment: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if isFocused {
|
||||
dockTooltip(section.dockTitle)
|
||||
.offset(y: -46)
|
||||
.transition(
|
||||
.asymmetric(
|
||||
insertion: .scale(scale: 0.88, anchor: .bottom).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.animation(.interactiveSpring(response: 0.44, dampingFraction: 0.76), value: isSelected)
|
||||
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.70), value: isFocused)
|
||||
}
|
||||
|
||||
private func dockTooltip(_ title: String) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
Triangle()
|
||||
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
|
||||
.frame(width: 12, height: 7)
|
||||
}
|
||||
.fixedSize()
|
||||
.shadow(color: Color.black.opacity(0.42), radius: 10, y: 6)
|
||||
}
|
||||
|
||||
private func hideDockTooltipAfterTap(for section: AppSection) {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
await MainActor.run {
|
||||
guard dockFocusedSection == section else { return }
|
||||
withAnimation(.interactiveSpring(response: 0.32, dampingFraction: 0.78)) {
|
||||
dockFocusedSection = nil
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text(operatorInitials)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(operatorName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(session.authModeDescription)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
@@ -156,16 +284,17 @@ struct ContentView: View {
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .dashboard:
|
||||
DashboardView { section in
|
||||
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.82)) {
|
||||
selectedSection = section
|
||||
}
|
||||
}
|
||||
case .clients: ClientsView()
|
||||
case .imports: ImportsView()
|
||||
case .communications: CommunicationsView()
|
||||
case .calendar: CalendarView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -185,37 +314,253 @@ struct ContentView: View {
|
||||
let initials = parts.prefix(2).compactMap(\.first)
|
||||
return initials.isEmpty ? "VO" : String(initials)
|
||||
}
|
||||
|
||||
private var hasPendingSync: Bool {
|
||||
!store.isShowroomModeEnabled && (!store.pendingSyncTaskIDs.isEmpty || !store.pendingSyncCalendarEventIDs.isEmpty)
|
||||
}
|
||||
|
||||
private var oracleAlertCount: Int {
|
||||
guard let alertSnapshot = store.alertSnapshot else { return 0 }
|
||||
return alertSnapshot.pendingInsights + alertSnapshot.pendingTranscriptions + alertSnapshot.upcomingCalendarEvents24h
|
||||
}
|
||||
|
||||
private func authenticateSession() {
|
||||
guard session.isConfigured, !isAuthenticating else { return }
|
||||
isAuthenticating = true
|
||||
privacyMessage = "Authenticating..."
|
||||
|
||||
let context = LAContext()
|
||||
context.localizedCancelTitle = "Lock"
|
||||
var error: NSError?
|
||||
let policy: LAPolicy = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
? .deviceOwnerAuthenticationWithBiometrics
|
||||
: .deviceOwnerAuthentication
|
||||
|
||||
guard context.canEvaluatePolicy(policy, error: &error) else {
|
||||
isAuthenticating = false
|
||||
privacyMessage = error?.localizedDescription ?? "Device authentication is unavailable."
|
||||
return
|
||||
}
|
||||
|
||||
context.evaluatePolicy(policy, localizedReason: "Unlock Project Velocity") { success, authError in
|
||||
Task { @MainActor in
|
||||
isAuthenticating = false
|
||||
withAnimation(.interactiveSpring(response: 0.38, dampingFraction: 0.86)) {
|
||||
isPrivacyLocked = !success
|
||||
}
|
||||
privacyMessage = success
|
||||
? "Unlocked"
|
||||
: (authError?.localizedDescription ?? "Authentication failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Triangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct OracleFloatingOrb: View {
|
||||
let alertCount: Int
|
||||
let action: () -> Void
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.displayTitle)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
action()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 64, height: 64)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.55), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.35), radius: isPressed ? 10 : 18)
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(Color(red: 0.68, green: 0.95, blue: 1.0))
|
||||
if alertCount > 0 {
|
||||
Text(alertCount > 99 ? "99+" : "\(alertCount)")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(VelocityTheme.danger))
|
||||
.offset(x: 22, y: -22)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open Oracle")
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.interactiveSpring(response: 0.24, dampingFraction: 0.8)) {
|
||||
isPressed = true
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.82)) {
|
||||
isPressed = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private struct OfflineSyncGlow: View {
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(VelocityTheme.warning.opacity(isActive ? 0.28 : 0))
|
||||
.frame(height: isActive ? 5 : 0)
|
||||
.shadow(color: VelocityTheme.warning.opacity(isActive ? 0.65 : 0), radius: 14, y: 5)
|
||||
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.9), value: isActive)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private struct VaultShareToast: View {
|
||||
let message: String?
|
||||
let error: String?
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
if let text = message ?? error {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: error == nil ? "link.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(error == nil ? VelocityTheme.success : VelocityTheme.warning)
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.16), lineWidth: 1))
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShowroomAmbientFallback: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.88),
|
||||
Color(red: 0.045, green: 0.055, blue: 0.075).opacity(0.96),
|
||||
Color.black.opacity(0.92),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "building.2.crop.circle")
|
||||
.font(.system(size: 58, weight: .light))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
Text("Velocity")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Preparing the showroom view")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrivacyLockOverlay: View {
|
||||
let message: String
|
||||
let isAuthenticating: Bool
|
||||
let unlock: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.ignoresSafeArea()
|
||||
Color.black.opacity(0.62)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity")
|
||||
.font(.system(size: 30, weight: .semibold, design: .default))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
Button {
|
||||
unlock()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isAuthenticating {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
} else {
|
||||
Image(systemName: "lock.open")
|
||||
}
|
||||
Text(isAuthenticating ? "Unlocking" : "Unlock")
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 11)
|
||||
.background(Capsule().fill(VelocityTheme.accent))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAuthenticating)
|
||||
}
|
||||
.padding(28)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 26)
|
||||
.fill(Color.black.opacity(0.32))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26)
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
iOS/velocity-ipad/velocity/Building.usda
Normal file
49
iOS/velocity-ipad/velocity/Building.usda
Normal file
@@ -0,0 +1,49 @@
|
||||
#usda 1.0
|
||||
(
|
||||
defaultPrim = "Building"
|
||||
metersPerUnit = 1
|
||||
upAxis = "Y"
|
||||
)
|
||||
|
||||
def Xform "Building"
|
||||
{
|
||||
def Cube "Podium"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (0, 0.08, 0)
|
||||
double3 xformOp:scale = (4.8, 0.16, 3.4)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "TowerA"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (-1.15, 1.38, -0.35)
|
||||
double3 xformOp:scale = (1.05, 2.6, 1.0)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "TowerB"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (1.05, 1.75, 0.25)
|
||||
double3 xformOp:scale = (1.25, 3.35, 1.1)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "AmenityDeck"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (0, 0.42, 1.2)
|
||||
double3 xformOp:scale = (3.2, 0.18, 0.85)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "Courtyard"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (0, 0.18, -1.05)
|
||||
double3 xformOp:scale = (1.5, 0.05, 0.9)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
}
|
||||
BIN
iOS/velocity-ipad/velocity/Building.usdz
Normal file
BIN
iOS/velocity-ipad/velocity/Building.usdz
Normal file
Binary file not shown.
@@ -2,8 +2,7 @@ import Foundation
|
||||
import Security
|
||||
|
||||
/// Central app configuration.
|
||||
/// Build settings remain the fallback, but production installs should prefer
|
||||
/// runtime configuration stored on-device.
|
||||
/// Enterprise installs must use runtime configuration stored on-device.
|
||||
enum AppConfig {
|
||||
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
|
||||
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
|
||||
@@ -42,10 +41,6 @@ enum AppConfig {
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
private static func value(for key: String) -> String? {
|
||||
parsedValue(from: Bundle.main.infoDictionary, key: key)
|
||||
}
|
||||
|
||||
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
@@ -59,7 +54,7 @@ enum AppConfig {
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static var baseURL: String {
|
||||
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
|
||||
runtimeBaseURL ?? SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
|
||||
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
|
||||
@@ -76,19 +71,19 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
static var dreamWeaverAPIKey: String? {
|
||||
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
|
||||
runtimeDreamWeaverAPIKey
|
||||
}
|
||||
|
||||
static var apiEmail: String? {
|
||||
runtimeEmail ?? value(for: "API_EMAIL")
|
||||
runtimeEmail
|
||||
}
|
||||
|
||||
static var apiPassword: String? {
|
||||
runtimePassword ?? value(for: "API_PASSWORD")
|
||||
runtimePassword
|
||||
}
|
||||
|
||||
static var apiBearerToken: String? {
|
||||
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
|
||||
runtimeBearerToken
|
||||
}
|
||||
|
||||
static var apiAccessToken: String? {
|
||||
@@ -132,7 +127,7 @@ enum AppConfig {
|
||||
email: apiEmail,
|
||||
hasPassword: apiPassword != nil,
|
||||
hasBearerToken: apiBearerToken != nil,
|
||||
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
|
||||
source: .secureDeviceStorage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,23 +139,15 @@ enum AppConfig {
|
||||
password: String?,
|
||||
bearerToken: String?
|
||||
) throws {
|
||||
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
|
||||
|
||||
if let dreamWeaverBaseURL {
|
||||
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
}
|
||||
|
||||
if let email {
|
||||
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
}
|
||||
|
||||
try storeSecret(baseURL, account: runtimeBaseURLKey)
|
||||
try storeSecret(dreamWeaverBaseURL, account: runtimeDreamWeaverBaseURLKey)
|
||||
try storeSecret(email, account: runtimeEmailKey)
|
||||
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
|
||||
try storeSecret(password, account: runtimePasswordKey)
|
||||
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
@@ -168,6 +155,9 @@ enum AppConfig {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeBaseURLKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverBaseURLKey)
|
||||
try deleteSecret(account: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
try deleteSecret(account: runtimePasswordKey)
|
||||
try deleteSecret(account: runtimeBearerTokenKey)
|
||||
@@ -186,16 +176,29 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
private static var runtimeBaseURL: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
canonicalizedBackendBaseURL(
|
||||
sanitizedValue(secret(account: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
)
|
||||
}
|
||||
|
||||
private static func canonicalizedBackendBaseURL(_ value: String?) -> String? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
|
||||
return SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static var configuredDreamWeaverBaseURL: String? {
|
||||
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
|
||||
runtimeDreamWeaverBaseURL
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverBaseURL: String? {
|
||||
sanitizedValue(
|
||||
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
|
||||
secret(account: runtimeDreamWeaverBaseURLKey),
|
||||
key: runtimeDreamWeaverBaseURLKey
|
||||
)
|
||||
}
|
||||
@@ -205,7 +208,7 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
private static var runtimeEmail: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
|
||||
sanitizedValue(secret(account: runtimeEmailKey), key: runtimeEmailKey)
|
||||
}
|
||||
|
||||
private static var runtimePassword: String? {
|
||||
|
||||
@@ -12,6 +12,12 @@ enum SessionConfigurationSource: String {
|
||||
case secureDeviceStorage = "Secure device storage"
|
||||
}
|
||||
|
||||
enum SessionConfigurationDefaults {
|
||||
static let productionBaseURL = "https://api.desineuron.in/api"
|
||||
static let legacyVelocityWebBaseURL = "https://velocity.desineuron.in/api"
|
||||
static let dreamWeaverBaseURL = "https://dreamweaver.desineuron.in"
|
||||
}
|
||||
|
||||
struct AppSessionConfiguration: Equatable {
|
||||
let baseURL: String
|
||||
let dreamWeaverBaseURL: String
|
||||
@@ -89,7 +95,13 @@ struct SessionConfigurationDraft: Equatable {
|
||||
}
|
||||
|
||||
var normalizedBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
|
||||
guard let normalized = Self.normalizedHTTPSOrigin(from: trimmedBaseURL) else {
|
||||
return nil
|
||||
}
|
||||
if normalized.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
|
||||
return SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverBaseURL: String? {
|
||||
@@ -118,7 +130,7 @@ struct SessionConfigurationDraft: Equatable {
|
||||
}
|
||||
|
||||
guard normalizedBaseURL != nil else {
|
||||
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
|
||||
errors.append("Backend endpoint must be an HTTPS API base like \(SessionConfigurationDefaults.productionBaseURL).")
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ final class SessionStore {
|
||||
|
||||
func reloadFromPersistedConfiguration() {
|
||||
currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
draftBaseURL = currentConfiguration.baseURL
|
||||
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
|
||||
draftBaseURL = trimmedNonEmpty(currentConfiguration.baseURL) ?? SessionConfigurationDefaults.productionBaseURL
|
||||
draftDreamWeaverBaseURL = trimmedNonEmpty(persistedDreamWeaverDraftValue) ?? SessionConfigurationDefaults.dreamWeaverBaseURL
|
||||
draftDreamWeaverAPIKey = ""
|
||||
draftAuthMode = currentConfiguration.authMode
|
||||
draftEmail = currentConfiguration.email ?? ""
|
||||
@@ -88,6 +88,11 @@ final class SessionStore {
|
||||
baselineEmail = currentConfiguration.email
|
||||
}
|
||||
|
||||
func markDraftEdited() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
}
|
||||
|
||||
func discardDraftChanges() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
@@ -185,6 +190,11 @@ final class SessionStore {
|
||||
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
|
||||
}
|
||||
|
||||
private func trimmedNonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func verificationStatusMessage(
|
||||
successPrefix: String,
|
||||
backendRefreshError: String?,
|
||||
|
||||
@@ -18,29 +18,71 @@ final class ComfyClient {
|
||||
|
||||
// MARK: - Health Check
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
func checkReadiness() async -> DreamWeaverReadiness {
|
||||
do {
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
return false
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway offline",
|
||||
detail: "Dream Weaver gateway did not return a healthy /health response."
|
||||
)
|
||||
}
|
||||
|
||||
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
|
||||
return false
|
||||
let health = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(health.status.lowercased()) else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway unhealthy",
|
||||
detail: "Dream Weaver gateway reported status: \(health.status)."
|
||||
)
|
||||
}
|
||||
guard health.comfyui != false else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "ComfyUI offline",
|
||||
detail: "The gateway is online, but ComfyUI/GPU is not reachable."
|
||||
)
|
||||
}
|
||||
guard health.checkpointReady != false else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Checkpoint missing",
|
||||
detail: "ComfyUI is online, but no compatible Dream Weaver checkpoint is available."
|
||||
)
|
||||
}
|
||||
guard try await probeDreamWeaverRoute() else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Route not mounted",
|
||||
detail: "The /dream-weaver route family is not mounted behind the configured gateway."
|
||||
)
|
||||
}
|
||||
|
||||
return try await probeDreamWeaverRoute()
|
||||
return DreamWeaverReadiness(
|
||||
isReady: true,
|
||||
label: "Ready",
|
||||
detail: "Gateway, Dream Weaver route, ComfyUI, and checkpoint checks passed."
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway offline",
|
||||
detail: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
let readiness = await checkReadiness()
|
||||
return readiness.isReady
|
||||
}
|
||||
|
||||
// MARK: - Main Generation Pipeline
|
||||
|
||||
/// Full pipeline: upload → queue → poll → download.
|
||||
@@ -49,6 +91,10 @@ final class ComfyClient {
|
||||
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
|
||||
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
||||
func generateImage(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
|
||||
try await generateImageResult(source: source, roomType: roomType, keywords: keywords).image
|
||||
}
|
||||
|
||||
func generateImageResult(source: UIImage, roomType: String, keywords: String) async throws -> DreamWeaverGenerationResult {
|
||||
let normalised = source.fixedOrientation()
|
||||
let resized = normalised.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
||||
@@ -62,7 +108,8 @@ final class ComfyClient {
|
||||
let resultURL = try await pollUntilReady(job: job)
|
||||
|
||||
// 3. Download result PNG
|
||||
return try await downloadResult(from: resultURL)
|
||||
let image = try await downloadResult(from: resultURL)
|
||||
return DreamWeaverGenerationResult(image: image, resultURL: resultURL)
|
||||
}
|
||||
|
||||
// MARK: - Step 1: POST /dream-weaver
|
||||
@@ -266,9 +313,48 @@ struct JobStatus: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
struct DreamWeaverReadiness: Equatable {
|
||||
let isReady: Bool
|
||||
let label: String
|
||||
let detail: String
|
||||
}
|
||||
|
||||
struct DreamWeaverGenerationResult {
|
||||
let image: UIImage
|
||||
let resultURL: URL
|
||||
}
|
||||
|
||||
struct HealthResponse: Decodable {
|
||||
let status: String
|
||||
let comfyui: Bool?
|
||||
let checkpointReady: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case comfyui
|
||||
case checkpointReady = "checkpoint_ready"
|
||||
case preferredCheckpointAvailable = "preferred_checkpoint_available"
|
||||
case checkpointAvailable = "checkpoint_available"
|
||||
case hasCheckpoint = "has_checkpoint"
|
||||
case gpuReady = "gpu_ready"
|
||||
}
|
||||
|
||||
init(status: String, comfyui: Bool?, checkpointReady: Bool?) {
|
||||
self.status = status
|
||||
self.comfyui = comfyui
|
||||
self.checkpointReady = checkpointReady
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
|
||||
comfyui = try container.decodeIfPresent(Bool.self, forKey: .comfyui)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .gpuReady)
|
||||
checkpointReady = try container.decodeIfPresent(Bool.self, forKey: .checkpointReady)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .preferredCheckpointAvailable)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .checkpointAvailable)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .hasCheckpoint)
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamWeaverErrorResponse: Codable {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct VelocityVaultShareAsset {
|
||||
let leadId: String?
|
||||
let assetName: String
|
||||
let assetType: String
|
||||
let storagePath: String?
|
||||
|
||||
var isShareable: Bool {
|
||||
leadId?.trimmedNonEmpty != nil && storagePath?.trimmedNonEmpty != nil
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var velocityStoragePath: String {
|
||||
let cleaned = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if cleaned.hasPrefix("assets/") {
|
||||
return String(cleaned.dropFirst("assets/".count))
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func vaultSwipeToShare(asset: VelocityVaultShareAsset?) -> some View {
|
||||
modifier(VaultSwipeToShareModifier(asset: asset))
|
||||
}
|
||||
}
|
||||
|
||||
private struct VaultSwipeToShareModifier: ViewModifier {
|
||||
@State private var appStore = AppStore.shared
|
||||
let asset: VelocityVaultShareAsset?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
ThreeFingerSwipeUpRecognizer {
|
||||
Task { await shareAsset() }
|
||||
}
|
||||
.allowsHitTesting(asset != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func shareAsset() async {
|
||||
guard let asset else { return }
|
||||
guard let leadId = asset.leadId?.trimmedNonEmpty,
|
||||
let storagePath = asset.storagePath?.trimmedNonEmpty else {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = "Vault share requires a backend lead and stored asset path."
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let threadId = appStore.activeCommunicationsThreadID?.trimmedNonEmpty else {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = "Open a Communications thread before using Vault Swipe-to-Share."
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
do {
|
||||
let link = try await VelocityAPIClient.shared.generateVaultLink(
|
||||
leadId: leadId,
|
||||
assetName: asset.assetName,
|
||||
assetType: asset.assetType,
|
||||
storagePath: storagePath
|
||||
)
|
||||
_ = try await VelocityAPIClient.shared.sendCommsMessage(
|
||||
threadId: threadId,
|
||||
body: "Secure Velocity Vault link: \(link.vaultUrl)"
|
||||
)
|
||||
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.82)) {
|
||||
appStore.vaultShareMessage = "Vault link shared to the active thread."
|
||||
appStore.vaultShareError = nil
|
||||
}
|
||||
} catch {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = error.localizedDescription
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThreeFingerSwipeUpRecognizer: UIViewRepresentable {
|
||||
let onSwipe: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
let recognizer = UISwipeGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.didSwipe(_:))
|
||||
)
|
||||
recognizer.direction = .up
|
||||
recognizer.numberOfTouchesRequired = 3
|
||||
view.addGestureRecognizer(recognizer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSwipe: onSwipe)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
let onSwipe: () -> Void
|
||||
|
||||
init(onSwipe: @escaping () -> Void) {
|
||||
self.onSwipe = onSwipe
|
||||
}
|
||||
|
||||
@objc func didSwipe(_ recognizer: UISwipeGestureRecognizer) {
|
||||
guard recognizer.state == .ended else { return }
|
||||
onSwipe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum AppStoreRefreshPolicy {
|
||||
/// Native iPad surfaces refresh on initial view load, pull-to-refresh, and
|
||||
/// explicit user mutations. View-local repeating timers are intentionally
|
||||
/// avoided so AppStore can coalesce in-flight refreshes and hydrate mobile
|
||||
/// edge state through one bulk request.
|
||||
static let timerDrivenRefreshesEnabled = false
|
||||
|
||||
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
|
||||
/// are based on the same production property slice by default.
|
||||
static let inventoryPropertyLimit = 100
|
||||
@@ -9,8 +15,9 @@ enum AppStoreRefreshPolicy {
|
||||
/// the operator's active task load on iPad surfaces.
|
||||
static let canonicalTaskLimit = 50
|
||||
|
||||
/// iPad surfaces only render a small operator-focused timeline, so keep the
|
||||
/// lead-event hydration set intentionally narrower than WebOS.
|
||||
/// Lead timelines are hydrated through the mobile-edge bulk endpoint. Keep
|
||||
/// the selected lead set bounded so every shared refresh remains one
|
||||
/// predictable backend call rather than N per-lead calls.
|
||||
static let leadTimelineHydrationLimit = 6
|
||||
|
||||
/// Fetch enough recent communication context for the visible iPad rails
|
||||
|
||||
220
iOS/velocity-ipad/velocity/Core/State/OfflineReplayStore.swift
Normal file
220
iOS/velocity-ipad/velocity/Core/State/OfflineReplayStore.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
struct OfflineReplayRecord: Identifiable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let operation: String
|
||||
let targetID: String?
|
||||
let payload: Data
|
||||
let queuedAt: Date
|
||||
let attemptCount: Int
|
||||
let lastAttemptAt: Date?
|
||||
let lastError: String?
|
||||
}
|
||||
|
||||
actor OfflineReplayStore {
|
||||
static let shared = OfflineReplayStore()
|
||||
|
||||
private enum Schema {
|
||||
static let entityName = "OfflineReplayItem"
|
||||
}
|
||||
|
||||
private let container: NSPersistentContainer
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init() {
|
||||
let model = Self.makeModel()
|
||||
container = NSPersistentContainer(name: "VelocityOfflineReplay", managedObjectModel: model)
|
||||
|
||||
let applicationSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first ?? FileManager.default.temporaryDirectory
|
||||
let directory = applicationSupport.appendingPathComponent("Velocity", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
let description = NSPersistentStoreDescription(
|
||||
url: directory.appendingPathComponent("OfflineReplay.sqlite")
|
||||
)
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
#if os(iOS)
|
||||
description.setOption(
|
||||
FileProtectionType.complete.rawValue as NSString,
|
||||
forKey: NSPersistentStoreFileProtectionKey
|
||||
)
|
||||
#endif
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
var loadError: Error?
|
||||
container.loadPersistentStores { _, error in
|
||||
loadError = error
|
||||
}
|
||||
if let loadError {
|
||||
assertionFailure("Velocity offline replay store failed to load: \(loadError.localizedDescription)")
|
||||
}
|
||||
|
||||
context = container.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
func enqueue(kind: String, operation: String, targetID: String?, payload: Data) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
if let targetID {
|
||||
let existing = Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context)
|
||||
existing.forEach(context.delete)
|
||||
}
|
||||
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: Schema.entityName, in: context) else {
|
||||
return
|
||||
}
|
||||
let item = NSManagedObject(entity: entity, insertInto: context)
|
||||
item.setValue(UUID().uuidString, forKey: "id")
|
||||
item.setValue(kind, forKey: "kind")
|
||||
item.setValue(operation, forKey: "operation")
|
||||
item.setValue(targetID, forKey: "targetID")
|
||||
item.setValue(payload, forKey: "payload")
|
||||
item.setValue(Date(), forKey: "queuedAt")
|
||||
item.setValue(0, forKey: "attemptCount")
|
||||
item.setValue(nil, forKey: "lastAttemptAt")
|
||||
item.setValue(nil, forKey: "lastError")
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func pendingRecords(limit: Int = 100) -> [OfflineReplayRecord] {
|
||||
let context = context
|
||||
var records: [OfflineReplayRecord] = []
|
||||
context.performAndWait {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: "queuedAt", ascending: true)
|
||||
]
|
||||
request.fetchLimit = limit
|
||||
let items = (try? context.fetch(request)) ?? []
|
||||
records = items.compactMap(Self.record(from:))
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func markCompleted(id: String) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
Self.fetchManagedObjects(id: id, in: context).forEach(context.delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func markFailed(id: String, error: Error) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
for item in Self.fetchManagedObjects(id: id, in: context) {
|
||||
let currentAttempts = item.value(forKey: "attemptCount") as? Int ?? 0
|
||||
item.setValue(currentAttempts + 1, forKey: "attemptCount")
|
||||
item.setValue(Date(), forKey: "lastAttemptAt")
|
||||
item.setValue(error.localizedDescription, forKey: "lastError")
|
||||
}
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(kind: String, targetID: String) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context).forEach(context.delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
let request = NSFetchRequest<NSFetchRequestResult>(entityName: Schema.entityName)
|
||||
let delete = NSBatchDeleteRequest(fetchRequest: request)
|
||||
_ = try? context.execute(delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchManagedObjects(id: String, in context: NSManagedObjectContext) -> [NSManagedObject] {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
request.fetchLimit = 1
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func fetchManagedObjects(
|
||||
kind: String,
|
||||
targetID: String,
|
||||
in context: NSManagedObjectContext
|
||||
) -> [NSManagedObject] {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.predicate = NSPredicate(format: "kind == %@ AND targetID == %@", kind, targetID)
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func saveIfNeeded(_ context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else { return }
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private static func record(from object: NSManagedObject) -> OfflineReplayRecord? {
|
||||
guard
|
||||
let id = object.value(forKey: "id") as? String,
|
||||
let kind = object.value(forKey: "kind") as? String,
|
||||
let operation = object.value(forKey: "operation") as? String,
|
||||
let payload = object.value(forKey: "payload") as? Data,
|
||||
let queuedAt = object.value(forKey: "queuedAt") as? Date
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return OfflineReplayRecord(
|
||||
id: id,
|
||||
kind: kind,
|
||||
operation: operation,
|
||||
targetID: object.value(forKey: "targetID") as? String,
|
||||
payload: payload,
|
||||
queuedAt: queuedAt,
|
||||
attemptCount: object.value(forKey: "attemptCount") as? Int ?? 0,
|
||||
lastAttemptAt: object.value(forKey: "lastAttemptAt") as? Date,
|
||||
lastError: object.value(forKey: "lastError") as? String
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeModel() -> NSManagedObjectModel {
|
||||
let model = NSManagedObjectModel()
|
||||
let entity = NSEntityDescription()
|
||||
entity.name = Schema.entityName
|
||||
entity.managedObjectClassName = NSStringFromClass(NSManagedObject.self)
|
||||
|
||||
entity.properties = [
|
||||
attribute("id", type: .stringAttributeType, optional: false),
|
||||
attribute("kind", type: .stringAttributeType, optional: false),
|
||||
attribute("operation", type: .stringAttributeType, optional: false),
|
||||
attribute("targetID", type: .stringAttributeType, optional: true),
|
||||
attribute("payload", type: .binaryDataAttributeType, optional: false),
|
||||
attribute("queuedAt", type: .dateAttributeType, optional: false),
|
||||
attribute("attemptCount", type: .integer64AttributeType, optional: false, defaultValue: 0),
|
||||
attribute("lastAttemptAt", type: .dateAttributeType, optional: true),
|
||||
attribute("lastError", type: .stringAttributeType, optional: true),
|
||||
]
|
||||
model.entities = [entity]
|
||||
return model
|
||||
}
|
||||
|
||||
private static func attribute(
|
||||
_ name: String,
|
||||
type: NSAttributeType,
|
||||
optional: Bool,
|
||||
defaultValue: Any? = nil
|
||||
) -> NSAttributeDescription {
|
||||
let attribute = NSAttributeDescription()
|
||||
attribute.name = name
|
||||
attribute.attributeType = type
|
||||
attribute.isOptional = optional
|
||||
attribute.defaultValue = defaultValue
|
||||
return attribute
|
||||
}
|
||||
}
|
||||
@@ -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 today’s 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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,21 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_BEARER_TOKEN</key>
|
||||
<string>$(API_BEARER_TOKEN)</string>
|
||||
<key>API_EMAIL</key>
|
||||
<string>$(API_EMAIL)</string>
|
||||
<key>API_PASSWORD</key>
|
||||
<string>$(API_PASSWORD)</string>
|
||||
<key>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>DREAM_WEAVER_BASE_URL</key>
|
||||
<string>$(DREAM_WEAVER_BASE_URL)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Velocity uses the microphone for push-to-talk Oracle concierge queries.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Velocity uses Face ID to protect client, broker, and property intelligence when the app resumes.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Velocity uses speech recognition to transcribe broker Oracle concierge queries on request.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user