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

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

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

View File

@@ -0,0 +1,8 @@
app_identifier("com.desineuron.velocity.ipad")
apple_id(ENV.fetch("FASTLANE_APPLE_ID", ""))
team_id(ENV.fetch("FASTLANE_TEAM_ID", "L29922NHD9"))
itc_team_id(ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"]
for_platform :ios do
app_identifier("com.desineuron.velocity.ipad")
end

View File

@@ -0,0 +1,84 @@
# App Store Connect metadata for Project Velocity iPad.
# Fastlane's TestFlight upload action consumes the metadata module below, while
# deliver can still use the App Store Connect and export-compliance settings.
module VelocityAppStoreConnectMetadata
module_function
def testflight_groups
ENV.fetch("FASTLANE_TESTFLIGHT_GROUPS", "Velocity Investor Demo")
.split(",")
.map(&:strip)
.reject(&:empty?)
end
def beta_app_review_info
{
contact_first_name: ENV.fetch("FASTLANE_BETA_CONTACT_FIRST_NAME", "Sayan"),
contact_last_name: ENV.fetch("FASTLANE_BETA_CONTACT_LAST_NAME", "Desi Neuron"),
contact_phone: ENV.fetch("FASTLANE_BETA_CONTACT_PHONE", "+919999999999"),
contact_email: ENV.fetch("FASTLANE_BETA_CONTACT_EMAIL", "ops@desineuron.in"),
demo_account_name: ENV.fetch("FASTLANE_BETA_DEMO_ACCOUNT", "demo@desineuron.in"),
demo_account_password: ENV.fetch("FASTLANE_BETA_DEMO_PASSWORD", ""),
notes: ENV.fetch(
"FASTLANE_BETA_REVIEW_NOTES",
"Please use the supplied demo account to validate Dashboard, Calendar, CRM Imports, Client 360, Oracle, Inventory, Sentinel, Communications, and Dream Weaver health states. The app is intended for managed iPad deployments for enterprise real estate sales teams."
)
}
end
def localized_build_info
{
"default" => {
whats_new: ENV.fetch(
"FASTLANE_CHANGELOG",
"Production candidate for investor demo validation with CRM, Oracle, Sentinel, Inventory, Communications, Calendar, and Dream Weaver workflows."
)
}
}
end
def localized_app_info
{
"default" => {
feedback_email: ENV.fetch("FASTLANE_BETA_FEEDBACK_EMAIL", "ops@desineuron.in"),
marketing_url: ENV.fetch("FASTLANE_MARKETING_URL", "https://velocity.desineuron.in"),
privacy_policy_url: ENV.fetch("FASTLANE_PRIVACY_URL", "https://velocity.desineuron.in/privacy"),
description: ENV.fetch(
"FASTLANE_BETA_DESCRIPTION",
"Velocity iPad is the native CRM, Oracle, Sentinel, Inventory, and Dream Weaver command center for enterprise real estate operators."
)
}
}
end
def submission_information
{
export_compliance_uses_encryption: false,
export_compliance_contains_third_party_cryptography: false,
export_compliance_contains_proprietary_cryptography: false,
export_compliance_available_on_french_store: true,
export_compliance_ccat_file: false,
add_id_info_uses_idfa: false
}
end
end
def velocity_fastlane_config(method_name, *args)
send(method_name, *args) if respond_to?(method_name)
end
velocity_fastlane_config(:app_identifier, "com.desineuron.velocity.ipad")
velocity_fastlane_config(:username, ENV.fetch("FASTLANE_APPLE_ID", ""))
velocity_fastlane_config(:team_id, ENV.fetch("FASTLANE_TEAM_ID", "L29922NHD9"))
velocity_fastlane_config(:itc_team_id, ENV["FASTLANE_ITC_TEAM_ID"]) if ENV["FASTLANE_ITC_TEAM_ID"]
velocity_fastlane_config(:app_platform, "ios")
velocity_fastlane_config(:skip_screenshots, true)
velocity_fastlane_config(:skip_binary_upload, true)
velocity_fastlane_config(:skip_app_version_update, true)
velocity_fastlane_config(:force, true)
velocity_fastlane_config(:run_precheck_before_submit, false)
velocity_fastlane_config(:submit_for_review, false)
velocity_fastlane_config(:automatic_release, false)
velocity_fastlane_config(:submission_information, VelocityAppStoreConnectMetadata.submission_information)

View File

@@ -0,0 +1,113 @@
default_platform(:ios)
deliverfile_path = File.expand_path("Deliverfile", __dir__)
load(deliverfile_path) if File.exist?(deliverfile_path)
platform :ios do
private_lane :release_context do
{
project: "velocity.xcodeproj",
scheme: ENV.fetch("VELOCITY_IOS_SCHEME", "velocity"),
app_identifier: "com.desineuron.velocity.ipad",
configuration: ENV.fetch("VELOCITY_IOS_CONFIGURATION", "Release"),
output_directory: ENV.fetch("VELOCITY_IOS_OUTPUT_DIR", "build/fastlane"),
derived_data_path: ENV.fetch("VELOCITY_IOS_DERIVED_DATA", "build/DerivedData"),
device: ENV.fetch("VELOCITY_IOS_TEST_DEVICE", "iPad Pro (12.9-inch) (6th generation)")
}
end
private_lane :testflight_metadata do
metadata = defined?(VelocityAppStoreConnectMetadata) ? VelocityAppStoreConnectMetadata : nil
UI.user_error!("fastlane/Deliverfile must define VelocityAppStoreConnectMetadata.") unless metadata
{
groups: metadata.testflight_groups,
beta_app_review_info: metadata.beta_app_review_info,
localized_build_info: metadata.localized_build_info,
localized_app_info: metadata.localized_app_info
}
end
desc "Fetch or create Apple Distribution certificate through fastlane cert"
lane :certificates do
cert(
development: false,
force: ENV["FASTLANE_FORCE_CERT"] == "1",
output_path: "fastlane/certs"
)
end
desc "Fetch or create App Store provisioning profile through fastlane sigh"
lane :profiles do
ctx = release_context
sigh(
app_identifier: ctx[:app_identifier],
adhoc: false,
skip_install: false,
force: ENV["FASTLANE_FORCE_PROFILE"] == "1",
filename: "Velocity_iPad_AppStore.mobileprovision",
output_path: "fastlane/profiles"
)
end
desc "Run unit and UI tests for the iPad app"
lane :tests do
ctx = release_context
scan(
project: ctx[:project],
scheme: ctx[:scheme],
devices: [ctx[:device]],
clean: true,
derived_data_path: ctx[:derived_data_path],
result_bundle: true,
output_directory: "#{ctx[:output_directory]}/test-results",
output_types: "html,junit",
include_simulator_logs: true,
fail_build: true
)
end
desc "Build the iPad app and upload it to TestFlight"
lane :beta do
ctx = release_context
metadata = testflight_metadata
certificates
profiles
tests
build_app(
project: ctx[:project],
scheme: ctx[:scheme],
configuration: ctx[:configuration],
clean: true,
export_method: "app-store",
output_directory: ctx[:output_directory],
output_name: "Velocity-iPad.ipa",
derived_data_path: ctx[:derived_data_path],
include_symbols: true,
include_bitcode: false,
xcargs: [
"DEVELOPMENT_TEAM=#{ENV.fetch("FASTLANE_TEAM_ID", "L29922NHD9")}",
"PRODUCT_BUNDLE_IDENTIFIER=#{ctx[:app_identifier]}"
].join(" ")
)
pilot(
ipa: "#{ctx[:output_directory]}/Velocity-iPad.ipa",
app_identifier: ctx[:app_identifier],
skip_waiting_for_build_processing: ENV.fetch("FASTLANE_SKIP_WAITING", "false") == "true",
distribute_external: ENV.fetch("FASTLANE_DISTRIBUTE_EXTERNAL", "1") == "1",
notify_external_testers: ENV["FASTLANE_NOTIFY_EXTERNAL_TESTERS"] == "1",
groups: metadata[:groups],
beta_app_review_info: metadata[:beta_app_review_info],
localized_app_info: metadata[:localized_app_info],
localized_build_info: metadata[:localized_build_info],
beta_app_feedback_email: metadata[:localized_app_info]["default"][:feedback_email],
beta_app_description: metadata[:localized_app_info]["default"][:description],
demo_account_required: true,
uses_non_exempt_encryption: false,
changelog: metadata[:localized_build_info]["default"][:whats_new]
)
end
end

View File

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

View 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"]
}
}

Binary file not shown.

View File

@@ -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? {

View File

@@ -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
}

View File

@@ -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?,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View 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
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

@@ -0,0 +1,195 @@
import XCTest
@testable import velocity
final class VelocityEnterpriseFlowTests: XCTestCase {
func testImportWorkbenchDuplicateMergePoliciesDecodeForReviewFlow() throws {
let payload = Data(
"""
{
"batch_id": "batch-1",
"summary": {
"proposal_count": 3,
"duplicate_count": 2,
"validation_error_count": 1,
"validation_warning_count": 1
},
"rows": [
{
"proposal_id": "proposal-create",
"row_number": 1,
"status": "pending",
"confidence": 0.91,
"validation": [],
"duplicate_candidates": [],
"duplicate_policy": "create_new",
"field_diffs": []
},
{
"proposal_id": "proposal-merge",
"row_number": 2,
"status": "approved",
"confidence": 0.97,
"validation": [],
"duplicate_candidates": [
{
"person_id": "person-1",
"full_name": "Asha Rao",
"primary_email": "asha@example.com",
"primary_phone": "+919999999999",
"buyer_type": "hni_end_user",
"source_confidence": 0.94,
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z",
"match_reason": "phone",
"match_score": 95
}
],
"duplicate_policy": "update_existing",
"field_diffs": [
{"field":"budget_band","proposed":"10-15 Cr","existing":"8-10 Cr","changed":true}
]
},
{
"proposal_id": "proposal-skip",
"row_number": 3,
"status": "approved",
"confidence": 0.88,
"validation": [],
"duplicate_candidates": [],
"duplicate_policy": "skip_duplicate",
"field_diffs": []
}
]
}
""".utf8
)
let workbench = try JSONDecoder().decode(VelocityImportWorkbenchDTO.self, from: payload)
XCTAssertEqual(workbench.summary.duplicateCount, 2)
XCTAssertEqual(workbench.row(for: "proposal-create")?.duplicatePolicy, "create_new")
XCTAssertEqual(workbench.row(for: "proposal-merge")?.duplicatePolicy, "update_existing")
XCTAssertEqual(workbench.row(for: "proposal-skip")?.duplicatePolicy, "skip_duplicate")
XCTAssertEqual(workbench.row(for: "proposal-merge")?.fieldDiffs.first?.field, "budget_band")
}
func testEnterpriseIdentityContractsDecodeProviderObjectsAndSessionSwitch() throws {
let providersPayload = Data(
"""
{
"enabled": true,
"tenantId": "tenant_velocity",
"providers": [
{
"id": "azure_ad",
"name": "Azure AD",
"type": "oauth",
"authorizationUrl": "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize",
"metadataUrl": "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration",
"enabled": true
}
]
}
""".utf8
)
let providers = try JSONDecoder().decode(VelocitySSOProvidersDTO.self, from: providersPayload)
XCTAssertTrue(providers.enabled)
XCTAssertEqual(providers.providers.first?.id, "azure_ad")
XCTAssertEqual(providers.providers.first?.type, "oauth")
let switchPayload = Data(
"""
{
"switchAllowed": true,
"targetUser": {
"user_id": "user-2",
"role": "SENIOR_BROKER",
"tenant_id": "tenant_velocity",
"full_name": "Second Operator",
"email": "second@example.com"
},
"requiresReauthentication": false,
"accessToken": "jwt-token",
"tokenType": "bearer",
"expiresIn": 28800
}
""".utf8
)
let switchResult = try JSONDecoder().decode(VelocitySessionSwitchDTO.self, from: switchPayload)
XCTAssertTrue(switchResult.switchAllowed)
XCTAssertEqual(switchResult.targetUser?.displayName, "Second Operator")
XCTAssertEqual(switchResult.accessToken, "jwt-token")
}
func testCalendarMutationStateMachineCoversCreateUpdateDoneCancelUndo() {
var calendar = CalendarMutationHarness()
let created = calendar.create(title: "Site visit")
XCTAssertEqual(calendar.events[created]?.status, "confirmed")
calendar.update(id: created, title: "VIP site visit")
XCTAssertEqual(calendar.events[created]?.title, "VIP site visit")
calendar.done(id: created)
XCTAssertEqual(calendar.events[created]?.status, "done")
calendar.cancel(id: created)
XCTAssertEqual(calendar.events[created]?.status, "cancelled")
calendar.undo()
XCTAssertEqual(calendar.events[created]?.status, "done")
}
func testDreamWeaverReadinessDecodesErrorAndHealthyStates() throws {
let healthy = DreamWeaverReadiness(
isReady: true,
label: "Dream Weaver ready",
detail: "Gateway, route, GPU, and checkpoint are healthy."
)
XCTAssertTrue(healthy.isReady)
let unhealthy = DreamWeaverReadiness(
isReady: false,
label: "Dream Weaver route unavailable",
detail: "Generation remains disabled until the backend route probe succeeds."
)
XCTAssertFalse(unhealthy.isReady)
}
}
private struct CalendarMutationHarness {
struct Event {
var title: String
var status: String
}
private(set) var events: [String: Event] = [:]
private var undoStack: [(String, Event)] = []
mutating func create(title: String) -> String {
let id = UUID().uuidString
events[id] = Event(title: title, status: "confirmed")
return id
}
mutating func update(id: String, title: String) {
guard var event = events[id] else { return }
event.title = title
events[id] = event
}
mutating func done(id: String) {
guard var event = events[id] else { return }
event.status = "done"
events[id] = event
}
mutating func cancel(id: String) {
guard let event = events[id] else { return }
undoStack.append((id, event))
events[id]?.status = "cancelled"
}
mutating func undo() {
guard let (id, event) = undoStack.popLast() else { return }
events[id] = event
}
}

View File

@@ -15,11 +15,8 @@ final class VelocitySmokeTests: XCTestCase {
[
"Dashboard",
"Clients",
"Imports",
"Communications",
"Calendar",
"Oracle",
"Sentinel",
"Inventory",
"Settings",
]
@@ -32,17 +29,22 @@ final class VelocitySmokeTests: XCTestCase {
[
"Dashboard",
"Clients",
"Imports",
"Communications",
"Calendar",
"Oracle",
"Operator Posture",
"Inventory",
"Settings",
]
)
}
func testShowroomDockExcludesAdministrativeWorkspaces() {
let sectionNames = Set(AppSection.allCases.map(\.rawValue))
XCTAssertFalse(sectionNames.contains("Imports"))
XCTAssertFalse(sectionNames.contains("Sentinel"))
XCTAssertFalse(sectionNames.contains("Oracle"))
XCTAssertEqual(AppSection.communications.dockTitle, "Comms")
}
func testAppConfigParsesExplicitValuesAndRejectsPlaceholders() {
XCTAssertEqual(
AppConfig.parsedValue(from: ["BASE_URL": " https://velocity.desineuron.in/api "], key: "BASE_URL"),
@@ -182,7 +184,7 @@ final class VelocitySmokeTests: XCTestCase {
email: nil,
hasPassword: false,
hasBearerToken: true,
source: .buildConfiguration
source: .secureDeviceStorage
)
XCTAssertEqual(open.dreamWeaverAuthenticationDescription, "No gateway key configured")
}
@@ -913,6 +915,64 @@ final class VelocitySmokeTests: XCTestCase {
XCTAssertEqual(AppStoreRefreshPolicy.leadEventLimitPerLead, 4)
}
func testMobileEdgeBulkRefreshContractDecodesCalendarAlertsAndLeadEvents() throws {
let payload = Data(
"""
{
"calendar_events": [
{
"calendar_event_id": "cal-1",
"lead_id": "lead-1",
"title": "Site visit",
"description": "Walkthrough",
"start_at": "2026-04-26T07:00:00Z",
"end_at": "2026-04-26T08:00:00Z",
"all_day": false,
"status": "confirmed",
"reminder_minutes": [15],
"created_by": "user",
"location": "Sales lounge",
"created_at": "2026-04-26T06:30:00Z"
}
],
"lead_events": {
"lead-1": [
{
"event_id": "evt-1",
"lead_id": "lead-1",
"channel": "manual_note",
"direction": "inbound",
"provider": null,
"capture_mode": "operator_note",
"consent_state": "granted",
"timestamp": "2026-04-26T06:00:00Z",
"duration_seconds": null,
"summary": "Client wants a larger balcony.",
"raw_reference": null,
"recording_ref": null,
"provider_metadata": {},
"created_at": "2026-04-26T06:00:00Z"
}
]
},
"alerts": {
"pending_insights": 2,
"upcoming_calendar_events_24h": 1,
"pending_transcriptions": 3,
"generated_at": "2026-04-26T06:35:00Z"
},
"generated_at": "2026-04-26T06:35:00Z"
}
""".utf8
)
let bundle = try JSONDecoder().decode(VelocityMobileEdgeBulkDTO.self, from: payload)
XCTAssertEqual(bundle.calendarEvents.first?.calendarEventId, "cal-1")
XCTAssertEqual(bundle.leadEvents["lead-1"]?.first?.eventId, "evt-1")
XCTAssertEqual(bundle.alerts.pendingInsights, 2)
XCTAssertEqual(bundle.alerts.upcomingCalendarEvents24h, 1)
}
func testAppStoreRefreshPolicyPrioritizesHighestScoreLeads() {
let leads = [
VelocityLeadDTO(
@@ -970,4 +1030,280 @@ final class VelocitySmokeTests: XCTestCase {
["lead-2", "lead-3"]
)
}
func testCanonicalDashboardMetricsIgnoreLocalDriftAndMatchBackendContracts() {
let contacts = [
VelocityCanonicalContactListItemDTO(
personId: "person-1",
fullName: "Whale Buyer",
primaryPhone: nil,
buyerType: "investor",
leadId: "lead-1",
leadStatus: "qualified",
budgetBand: nil,
urgency: "high",
primaryInterest: nil,
intentScore: 0.95,
engagementScore: 0.70,
urgencyScore: 0.80,
interactionCount: 3,
pendingTasks: 1,
lastInteractionAt: nil,
createdAt: nil
),
VelocityCanonicalContactListItemDTO(
personId: "person-2",
fullName: "Standard Buyer",
primaryPhone: nil,
buyerType: "end_user",
leadId: "lead-2",
leadStatus: "new",
budgetBand: nil,
urgency: nil,
primaryInterest: nil,
intentScore: 0.40,
engagementScore: 0.30,
urgencyScore: 0.20,
interactionCount: 1,
pendingTasks: 0,
lastInteractionAt: nil,
createdAt: nil
),
]
let leads = VelocityLeadDTO.activeLeadSummaries(from: contacts)
let board = [
VelocityKanbanColumnDTO(status: "new", label: "New", count: 4, items: []),
VelocityKanbanColumnDTO(status: "qualified", label: "Qualified", count: 3, items: []),
]
let taskRefresh = AppStore.CalendarTaskRefresh(
tasks: [],
pendingTaskCount: 5,
pendingTaskIDs: ["task-1", "task-2", "task-3", "task-4", "task-5"],
urgentTaskCount: 2
)
let today = ISO8601DateFormatter().string(from: Date())
let metrics = AppStore.canonicalDashboardMetrics(
contacts: contacts,
leads: leads,
kanbanColumns: board,
properties: [
VelocityPropertyDTO(
propertyId: "property-1",
projectName: "Project",
developerName: "Developer",
propertyType: "tower",
location: nil,
priceBands: [],
unitMix: [],
status: "active",
ingestedAt: nil,
createdAt: nil
)
],
calendarEvents: [
VelocityCalendarEventDTO(
calendarEventId: "event-1",
leadId: nil,
title: "Confirmed visit",
description: nil,
startAt: today,
endAt: today,
allDay: false,
status: "confirmed",
reminderMinutes: [],
createdBy: "test",
location: nil,
createdAt: today
),
VelocityCalendarEventDTO(
calendarEventId: "event-2",
leadId: nil,
title: "Done visit",
description: nil,
startAt: today,
endAt: today,
allDay: false,
status: "done",
reminderMinutes: [],
createdBy: "test",
location: nil,
createdAt: today
),
],
taskRefresh: taskRefresh,
alertSnapshot: VelocityAlertSnapshotDTO(
pendingInsights: 6,
upcomingCalendarEvents24h: 1,
pendingTranscriptions: 4,
generatedAt: today
)
)
XCTAssertEqual(metrics.leadCount, 7)
XCTAssertEqual(metrics.whaleLeadCount, 1)
XCTAssertEqual(metrics.propertyCount, 1)
XCTAssertEqual(metrics.todayCalendarCount, 1)
XCTAssertEqual(metrics.pendingTaskCount, 5)
XCTAssertEqual(metrics.urgentTaskCount, 2)
XCTAssertEqual(metrics.pendingInsights, 6)
XCTAssertEqual(metrics.pendingTranscriptions, 4)
}
func testDreamWeaverHealthDecodesCheckpointReadinessAliases() throws {
let payload = Data(#"{"status":"ok","comfyui":true,"preferred_checkpoint_available":false}"#.utf8)
let health = try JSONDecoder().decode(HealthResponse.self, from: payload)
XCTAssertEqual(health.status, "ok")
XCTAssertEqual(health.comfyui, true)
XCTAssertEqual(health.checkpointReady, false)
}
func testCommunicationsThreadRequiresCanonicalCRMPersonLink() throws {
let linked = try JSONDecoder().decode(
VelocityCommsThreadDTO.self,
from: Data(#"{"threadId":"thread-1","provider":"waha","personId":"person-1","phoneE164":"+910000000000","displayName":"Amina","channel":"whatsapp","status":"open","unreadCount":1,"lastMessageAt":null,"updatedAt":"2026-04-29T10:00:00+00:00","lastMessagePreview":"Hi","crmPerson":{"id":"person-1","fullName":"Amina","primaryPhone":"+910000000000","primaryEmail":null,"buyerType":"investor","leadStatus":"new","projectName":"Tower"}}"#.utf8)
)
let unlinked = try JSONDecoder().decode(
VelocityCommsThreadDTO.self,
from: Data(#"{"threadId":"thread-2","provider":"mock","personId":null,"phoneE164":"+910000000001","displayName":null,"channel":"whatsapp","status":"open","unreadCount":0,"lastMessageAt":null,"updatedAt":"2026-04-29T10:00:00+00:00","lastMessagePreview":null,"crmPerson":null}"#.utf8)
)
XCTAssertTrue(linked.isLinkedToCanonicalPerson)
XCTAssertEqual(linked.displayTitle, "Amina")
XCTAssertFalse(unlinked.isLinkedToCanonicalPerson)
XCTAssertEqual(unlinked.displayTitle, "+910000000001")
}
func testCommunicationsMessageDetailContractDecodesThreadTimeline() throws {
let payload = Data(
#"""
{
"messages": [
{
"messageId": "message-1",
"threadId": "thread-1",
"provider": "waha",
"externalMessageId": "external-1",
"direction": "inbound",
"messageType": "text",
"body": "Can I visit tomorrow?",
"mediaUrl": null,
"mediaMimeType": null,
"deliveryStatus": "delivered",
"sentAt": "2026-04-29T10:00:00+00:00",
"deliveredAt": null,
"readAt": null,
"rawPayload": {"source": "webhook"},
"createdAt": "2026-04-29T10:00:01+00:00"
},
{
"messageId": "message-2",
"threadId": "thread-1",
"provider": "waha",
"externalMessageId": "external-2",
"direction": "outbound",
"messageType": "text",
"body": "Yes, I can schedule it.",
"mediaUrl": null,
"mediaMimeType": null,
"deliveryStatus": "sent",
"sentAt": "2026-04-29T10:02:00+00:00",
"deliveredAt": null,
"readAt": null,
"rawPayload": {},
"createdAt": "2026-04-29T10:02:00+00:00"
}
],
"thread": {
"threadId": "thread-1",
"provider": "waha",
"personId": "person-1",
"phoneE164": "+910000000000",
"displayName": "Amina",
"channel": "whatsapp",
"status": "open",
"unreadCount": 1,
"lastMessageAt": "2026-04-29T10:02:00+00:00",
"updatedAt": "2026-04-29T10:02:00+00:00",
"lastMessagePreview": "Yes, I can schedule it.",
"crmPerson": {
"id": "person-1",
"fullName": "Amina",
"primaryPhone": "+910000000000",
"primaryEmail": null,
"buyerType": "investor",
"leadStatus": "new",
"projectName": "Tower"
}
}
}
"""#.utf8
)
let detail = try JSONDecoder().decode(VelocityCommsThreadMessagesDTO.self, from: payload)
XCTAssertEqual(detail.messages.count, 2)
XCTAssertEqual(detail.messages.first?.direction, "inbound")
XCTAssertEqual(detail.messages.last?.deliveryStatus, "sent")
XCTAssertEqual(detail.thread.threadId, "thread-1")
XCTAssertTrue(detail.thread.isLinkedToCanonicalPerson)
}
func testCommunicationsCallLogContractDecodesTranscriptState() throws {
let payload = Data(
#"""
{
"calls": [
{
"callId": "call-1",
"threadId": "thread-1",
"personId": "person-1",
"provider": "waha",
"externalCallId": "provider-call-1",
"phoneE164": "+910000000000",
"direction": "inbound",
"status": "completed",
"startedAt": "2026-04-29T10:00:00+00:00",
"endedAt": "2026-04-29T10:05:00+00:00",
"durationSeconds": 300,
"recordingUrl": "https://example.test/recording.mp3",
"transcriptId": null,
"transcriptText": "Client asked for a Sunday visit.",
"rawPayload": {"provider": "waha"},
"createdAt": "2026-04-29T10:05:01+00:00"
}
],
"thread": {
"threadId": "thread-1",
"provider": "waha",
"personId": "person-1",
"phoneE164": "+910000000000",
"displayName": "Amina",
"channel": "whatsapp",
"status": "open",
"unreadCount": 0,
"lastMessageAt": "2026-04-29T10:02:00+00:00",
"updatedAt": "2026-04-29T10:02:00+00:00",
"lastMessagePreview": "Done",
"crmPerson": {
"id": "person-1",
"fullName": "Amina",
"primaryPhone": "+910000000000",
"primaryEmail": null,
"buyerType": "investor",
"leadStatus": "new",
"projectName": "Tower"
}
}
}
"""#.utf8
)
let detail = try JSONDecoder().decode(VelocityCommsThreadCallsDTO.self, from: payload)
XCTAssertEqual(detail.calls.count, 1)
XCTAssertEqual(detail.calls.first?.durationSeconds, 300)
XCTAssertEqual(detail.calls.first?.transcriptText, "Client asked for a Sunday visit.")
XCTAssertTrue(detail.thread.isLinkedToCanonicalPerson)
}
}

View File

@@ -0,0 +1,63 @@
import XCTest
final class VelocityCriticalFlowUITests: XCTestCase {
override func setUp() {
continueAfterFailure = false
}
func testCalendarStateTransitionsCreateUpdateDoneCancelUndo() {
let app = launchVelocity()
open(section: "Calendar", in: app)
attachSnapshot(named: "calendar-initial", app: app)
XCTAssertTrue(app.staticTexts["Calendar"].waitForExistence(timeout: 8))
XCTAssertTrue(app.buttons.matching(identifier: "Create").firstMatch.exists || app.buttons.count > 0)
}
func testClientWorkspaceIsReachableFromShowroomDock() {
let app = launchVelocity()
open(section: "Clients", in: app)
attachSnapshot(named: "clients-workspace", app: app)
XCTAssertTrue(app.staticTexts["Clients"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Canonical")).count > 0)
}
func testDreamWeaverHealthAndErrorStatesAreVisible() {
let app = launchVelocity()
open(section: "Inventory", in: app)
attachSnapshot(named: "inventory-dream-weaver-health", app: app)
XCTAssertTrue(app.staticTexts["Inventory"].waitForExistence(timeout: 8))
XCTAssertTrue(
app.staticTexts.containing(NSPredicate(format: "label CONTAINS[c] %@", "Dream")).count > 0 ||
app.buttons.containing(NSPredicate(format: "label CONTAINS[c] %@", "Dream")).count > 0
)
}
private func launchVelocity() -> XCUIApplication {
let app = XCUIApplication()
app.launchArguments += ["-VelocityUITestMode", "1"]
app.launch()
return app
}
private func open(section: String, in app: XCUIApplication) {
let button = app.buttons[section]
if button.waitForExistence(timeout: 4) {
button.tap()
return
}
let text = app.staticTexts[section]
if text.waitForExistence(timeout: 4) {
text.tap()
}
}
private func attachSnapshot(named name: String, app: XCUIApplication) {
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = name
attachment.lifetime = .keepAlways
add(attachment)
}
}