feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
#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:
8
iOS/velocity-ipad/fastlane/Appfile
Normal file
8
iOS/velocity-ipad/fastlane/Appfile
Normal 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
|
||||
84
iOS/velocity-ipad/fastlane/Deliverfile
Normal file
84
iOS/velocity-ipad/fastlane/Deliverfile
Normal 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)
|
||||
113
iOS/velocity-ipad/fastlane/Fastfile
Normal file
113
iOS/velocity-ipad/fastlane/Fastfile
Normal 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
|
||||
@@ -1,21 +1,24 @@
|
||||
import SwiftUI
|
||||
import LocalAuthentication
|
||||
import UIKit
|
||||
|
||||
enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
case dashboard = "Dashboard"
|
||||
case clients = "Clients"
|
||||
case imports = "Imports"
|
||||
case communications = "Communications"
|
||||
case calendar = "Calendar"
|
||||
case oracle = "Oracle"
|
||||
case sentinel = "Sentinel"
|
||||
case inventory = "Inventory"
|
||||
case settings = "Settings"
|
||||
|
||||
var displayTitle: String {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var dockTitle: String {
|
||||
switch self {
|
||||
case .sentinel:
|
||||
return SentinelScope.navigationTitle
|
||||
case .communications:
|
||||
return "Comms"
|
||||
default:
|
||||
return rawValue
|
||||
}
|
||||
@@ -25,11 +28,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .clients: return "person.text.rectangle"
|
||||
case .imports: return "tray.and.arrow.down"
|
||||
case .communications: return "phone.connection"
|
||||
case .calendar: return "calendar.badge.clock"
|
||||
case .oracle: return "message.and.waveform"
|
||||
case .sentinel: return "person.crop.rectangle"
|
||||
case .inventory: return "shippingbox"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
@@ -39,11 +39,8 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
switch self {
|
||||
case .dashboard: return VelocityTheme.accent
|
||||
case .clients: return Color(red: 0.22, green: 0.78, blue: 0.96)
|
||||
case .imports: return Color(red: 0.94, green: 0.70, blue: 0.25)
|
||||
case .communications: return Color(red: 0.19, green: 0.84, blue: 0.63)
|
||||
case .calendar: return Color(red: 0.96, green: 0.67, blue: 0.16)
|
||||
case .oracle: return Color(red: 0.13, green: 0.83, blue: 0.93) // cyan
|
||||
case .sentinel: return Color(red: 0.60, green: 0.57, blue: 0.99) // indigo
|
||||
case .inventory: return VelocityTheme.warning
|
||||
case .settings: return VelocityTheme.mutedFg
|
||||
}
|
||||
@@ -51,102 +48,233 @@ enum AppSection: String, CaseIterable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var selectedSection: AppSection? = .dashboard
|
||||
@State private var selectedSection: AppSection = .dashboard
|
||||
@State private var session = SessionStore.shared
|
||||
@State private var store = AppStore.shared
|
||||
@State private var isOraclePresented = false
|
||||
@State private var dockFocusedSection: AppSection?
|
||||
@State private var isPrivacyLocked = true
|
||||
@State private var isAuthenticating = false
|
||||
@State private var privacyMessage = "Unlock Velocity"
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Namespace private var dockSelectionNamespace
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if session.isConfigured {
|
||||
NavigationSplitView(columnVisibility: .constant(.all)) {
|
||||
sidebarContent
|
||||
} detail: {
|
||||
ZStack(alignment: .bottom) {
|
||||
detailContent
|
||||
floatingNavigationPill
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
OracleFloatingOrb(alertCount: oracleAlertCount) {
|
||||
isOraclePresented = true
|
||||
}
|
||||
.padding(.trailing, 28)
|
||||
.padding(.bottom, selectedSection == .clients ? 154 : 112)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
OfflineSyncGlow(isActive: hasPendingSync)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
VaultShareToast(
|
||||
message: store.vaultShareMessage,
|
||||
error: store.vaultShareError
|
||||
) {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
store.vaultShareMessage = nil
|
||||
store.vaultShareError = nil
|
||||
}
|
||||
}
|
||||
.padding(.top, 18)
|
||||
}
|
||||
.overlay {
|
||||
if store.isShowroomModeEnabled, store.errorMessage != nil {
|
||||
ShowroomAmbientFallback()
|
||||
.transition(.opacity.animation(.interactiveSpring(response: 0.55, dampingFraction: 0.9)))
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if isPrivacyLocked {
|
||||
PrivacyLockOverlay(
|
||||
message: privacyMessage,
|
||||
isAuthenticating: isAuthenticating
|
||||
) {
|
||||
authenticateSession()
|
||||
}
|
||||
.transition(.opacity.animation(.interactiveSpring(response: 0.35, dampingFraction: 0.88)))
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $isOraclePresented) {
|
||||
OracleConciergeSheet()
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
} else {
|
||||
ConfigurationGateView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar
|
||||
private var sidebarContent: some View {
|
||||
ZStack {
|
||||
VelocityTheme.sidebarBg.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// App title
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9)
|
||||
.fill(VelocityTheme.accent.opacity(0.18))
|
||||
.frame(width: 34, height: 34)
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Velocity")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Project Velocity · v1.1")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
.onAppear {
|
||||
authenticateSession()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
switch phase {
|
||||
case .background, .inactive:
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.9)) {
|
||||
isPrivacyLocked = true
|
||||
privacyMessage = "Velocity is locked"
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Nav items
|
||||
VStack(spacing: 2) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
Button {
|
||||
selectedSection = section
|
||||
} label: {
|
||||
SidebarRow(section: section, isSelected: selectedSection == section)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.displayTitle)
|
||||
.accessibilityAddTraits(selectedSection == section ? [.isSelected] : [])
|
||||
}
|
||||
case .active:
|
||||
if isPrivacyLocked {
|
||||
authenticateSession()
|
||||
}
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var floatingNavigationPill: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(alignment: .bottom, spacing: 12) {
|
||||
ForEach(AppSection.allCases) { section in
|
||||
let isSelected = selectedSection == section
|
||||
let isFocused = dockFocusedSection == section
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.78)) {
|
||||
selectedSection = section
|
||||
dockFocusedSection = section
|
||||
}
|
||||
hideDockTooltipAfterTap(for: section)
|
||||
} label: {
|
||||
dockItem(for: section, isSelected: isSelected, isFocused: isFocused)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.dockTitle)
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
.onHover { hovering in
|
||||
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.76)) {
|
||||
dockFocusedSection = hovering ? section : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.top, 9)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(.ultraThinMaterial)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.fill(Color.black.opacity(0.34))
|
||||
.blur(radius: 0.5)
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.42), radius: 28, y: 18)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 30, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func dockItem(for section: AppSection, isSelected: Bool, isFocused: Bool) -> some View {
|
||||
VStack(spacing: 7) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
section.accentColor.opacity(isSelected ? 0.34 : 0.18),
|
||||
Color.white.opacity(isSelected ? 0.12 : 0.05),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(Color.white.opacity(isSelected ? 0.26 : 0.12), lineWidth: 1)
|
||||
)
|
||||
.shadow(
|
||||
color: section.accentColor.opacity(isSelected || isFocused ? 0.32 : 0.10),
|
||||
radius: isSelected || isFocused ? 16 : 7,
|
||||
y: isSelected || isFocused ? 8 : 3
|
||||
)
|
||||
.if(isSelected) { view in
|
||||
view.matchedGeometryEffect(id: "selectedDockTile", in: dockSelectionNamespace)
|
||||
}
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: isFocused ? 24 : (isSelected ? 22 : 19), weight: .semibold))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : section.accentColor)
|
||||
}
|
||||
.frame(width: 50, height: 50)
|
||||
.scaleEffect(isFocused ? 1.34 : (isSelected ? 1.16 : 1.0), anchor: .bottom)
|
||||
.offset(y: isFocused ? -13 : (isSelected ? -5 : 0))
|
||||
|
||||
Circle()
|
||||
.fill(isSelected ? section.accentColor.opacity(0.92) : Color.clear)
|
||||
.frame(width: 5, height: 5)
|
||||
.shadow(color: section.accentColor.opacity(isSelected ? 0.45 : 0), radius: 5)
|
||||
}
|
||||
.frame(width: 58, height: 70, alignment: .bottom)
|
||||
.overlay(alignment: .top) {
|
||||
if isFocused {
|
||||
dockTooltip(section.dockTitle)
|
||||
.offset(y: -46)
|
||||
.transition(
|
||||
.asymmetric(
|
||||
insertion: .scale(scale: 0.88, anchor: .bottom).combined(with: .opacity),
|
||||
removal: .opacity
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.animation(.interactiveSpring(response: 0.44, dampingFraction: 0.76), value: isSelected)
|
||||
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.70), value: isFocused)
|
||||
}
|
||||
|
||||
private func dockTooltip(_ title: String) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(title)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
Triangle()
|
||||
.fill(Color(red: 0.13, green: 0.13, blue: 0.13).opacity(0.96))
|
||||
.frame(width: 12, height: 7)
|
||||
}
|
||||
.fixedSize()
|
||||
.shadow(color: Color.black.opacity(0.42), radius: 10, y: 6)
|
||||
}
|
||||
|
||||
private func hideDockTooltipAfterTap(for section: AppSection) {
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
await MainActor.run {
|
||||
guard dockFocusedSection == section else { return }
|
||||
withAnimation(.interactiveSpring(response: 0.32, dampingFraction: 0.78)) {
|
||||
dockFocusedSection = nil
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// User footer
|
||||
Divider()
|
||||
.background(VelocityTheme.borderSubtle)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(VelocityTheme.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
Text(operatorInitials)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(operatorName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(session.authModeDescription)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.toolbar(.hidden, for: .navigationBar)
|
||||
}
|
||||
|
||||
// MARK: – Detail
|
||||
@@ -156,16 +284,17 @@ struct ContentView: View {
|
||||
|
||||
Group {
|
||||
switch selectedSection {
|
||||
case .dashboard: DashboardView()
|
||||
case .dashboard:
|
||||
DashboardView { section in
|
||||
withAnimation(.interactiveSpring(response: 0.46, dampingFraction: 0.82)) {
|
||||
selectedSection = section
|
||||
}
|
||||
}
|
||||
case .clients: ClientsView()
|
||||
case .imports: ImportsView()
|
||||
case .communications: CommunicationsView()
|
||||
case .calendar: CalendarView()
|
||||
case .oracle: OracleView()
|
||||
case .sentinel: SentinelView()
|
||||
case .inventory: InventoryView()
|
||||
case .settings: SettingsView()
|
||||
case .none: DashboardView()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -185,37 +314,253 @@ struct ContentView: View {
|
||||
let initials = parts.prefix(2).compactMap(\.first)
|
||||
return initials.isEmpty ? "VO" : String(initials)
|
||||
}
|
||||
|
||||
private var hasPendingSync: Bool {
|
||||
!store.isShowroomModeEnabled && (!store.pendingSyncTaskIDs.isEmpty || !store.pendingSyncCalendarEventIDs.isEmpty)
|
||||
}
|
||||
|
||||
private var oracleAlertCount: Int {
|
||||
guard let alertSnapshot = store.alertSnapshot else { return 0 }
|
||||
return alertSnapshot.pendingInsights + alertSnapshot.pendingTranscriptions + alertSnapshot.upcomingCalendarEvents24h
|
||||
}
|
||||
|
||||
private func authenticateSession() {
|
||||
guard session.isConfigured, !isAuthenticating else { return }
|
||||
isAuthenticating = true
|
||||
privacyMessage = "Authenticating..."
|
||||
|
||||
let context = LAContext()
|
||||
context.localizedCancelTitle = "Lock"
|
||||
var error: NSError?
|
||||
let policy: LAPolicy = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
|
||||
? .deviceOwnerAuthenticationWithBiometrics
|
||||
: .deviceOwnerAuthentication
|
||||
|
||||
guard context.canEvaluatePolicy(policy, error: &error) else {
|
||||
isAuthenticating = false
|
||||
privacyMessage = error?.localizedDescription ?? "Device authentication is unavailable."
|
||||
return
|
||||
}
|
||||
|
||||
context.evaluatePolicy(policy, localizedReason: "Unlock Project Velocity") { success, authError in
|
||||
Task { @MainActor in
|
||||
isAuthenticating = false
|
||||
withAnimation(.interactiveSpring(response: 0.38, dampingFraction: 0.86)) {
|
||||
isPrivacyLocked = !success
|
||||
}
|
||||
privacyMessage = success
|
||||
? "Unlocked"
|
||||
: (authError?.localizedDescription ?? "Authentication failed.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: – Sidebar Row
|
||||
private struct SidebarRow: View {
|
||||
let section: AppSection
|
||||
let isSelected: Bool
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
|
||||
if condition {
|
||||
transform(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct Triangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
private struct OracleFloatingOrb: View {
|
||||
let alertCount: Int
|
||||
let action: () -> Void
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 11) {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(isSelected ? section.accentColor : VelocityTheme.mutedFg)
|
||||
.frame(width: 20)
|
||||
|
||||
Text(section.displayTitle)
|
||||
.font(.system(size: 14, weight: isSelected ? .semibold : .regular))
|
||||
.foregroundStyle(isSelected ? VelocityTheme.foreground : VelocityTheme.mutedFg)
|
||||
|
||||
Spacer()
|
||||
Button {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
action()
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.frame(width: 64, height: 64)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.55), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.35), radius: isPressed ? 10 : 18)
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(Color(red: 0.68, green: 0.95, blue: 1.0))
|
||||
if alertCount > 0 {
|
||||
Text(alertCount > 99 ? "99+" : "\(alertCount)")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(Capsule().fill(VelocityTheme.danger))
|
||||
.offset(x: 22, y: -22)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isSelected ? section.accentColor.opacity(0.12) : .clear)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(isSelected ? section.accentColor.opacity(0.25) : .clear, lineWidth: 1)
|
||||
)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open Oracle")
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(.interactiveSpring(response: 0.24, dampingFraction: 0.8)) {
|
||||
isPressed = true
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.82)) {
|
||||
isPressed = false
|
||||
}
|
||||
}
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private struct OfflineSyncGlow: View {
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(VelocityTheme.warning.opacity(isActive ? 0.28 : 0))
|
||||
.frame(height: isActive ? 5 : 0)
|
||||
.shadow(color: VelocityTheme.warning.opacity(isActive ? 0.65 : 0), radius: 14, y: 5)
|
||||
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.9), value: isActive)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private struct VaultShareToast: View {
|
||||
let message: String?
|
||||
let error: String?
|
||||
let dismiss: () -> Void
|
||||
|
||||
var body: some View {
|
||||
if let text = message ?? error {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: error == nil ? "link.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(error == nil ? VelocityTheme.success : VelocityTheme.warning)
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 9)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay(Capsule().stroke(Color.white.opacity(0.16), lineWidth: 1))
|
||||
)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShowroomAmbientFallback: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.black.opacity(0.88),
|
||||
Color(red: 0.045, green: 0.055, blue: 0.075).opacity(0.96),
|
||||
Color.black.opacity(0.92),
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "building.2.crop.circle")
|
||||
.font(.system(size: 58, weight: .light))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
Text("Velocity")
|
||||
.font(.system(size: 34, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
Text("Preparing the showroom view")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.58))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrivacyLockOverlay: View {
|
||||
let message: String
|
||||
let isAuthenticating: Bool
|
||||
let unlock: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.ignoresSafeArea()
|
||||
Color.black.opacity(0.62)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "faceid")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Velocity")
|
||||
.font(.system(size: 30, weight: .semibold, design: .default))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(message)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
Button {
|
||||
unlock()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isAuthenticating {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
} else {
|
||||
Image(systemName: "lock.open")
|
||||
}
|
||||
Text(isAuthenticating ? "Unlocking" : "Unlock")
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 11)
|
||||
.background(Capsule().fill(VelocityTheme.accent))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isAuthenticating)
|
||||
}
|
||||
.padding(28)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 26)
|
||||
.fill(Color.black.opacity(0.32))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 26)
|
||||
.stroke(Color.white.opacity(0.16), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
iOS/velocity-ipad/velocity/Building.usda
Normal file
49
iOS/velocity-ipad/velocity/Building.usda
Normal file
@@ -0,0 +1,49 @@
|
||||
#usda 1.0
|
||||
(
|
||||
defaultPrim = "Building"
|
||||
metersPerUnit = 1
|
||||
upAxis = "Y"
|
||||
)
|
||||
|
||||
def Xform "Building"
|
||||
{
|
||||
def Cube "Podium"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (0, 0.08, 0)
|
||||
double3 xformOp:scale = (4.8, 0.16, 3.4)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "TowerA"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (-1.15, 1.38, -0.35)
|
||||
double3 xformOp:scale = (1.05, 2.6, 1.0)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "TowerB"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (1.05, 1.75, 0.25)
|
||||
double3 xformOp:scale = (1.25, 3.35, 1.1)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "AmenityDeck"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (0, 0.42, 1.2)
|
||||
double3 xformOp:scale = (3.2, 0.18, 0.85)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
|
||||
def Cube "Courtyard"
|
||||
{
|
||||
double size = 1
|
||||
double3 xformOp:translate = (0, 0.18, -1.05)
|
||||
double3 xformOp:scale = (1.5, 0.05, 0.9)
|
||||
uniform token[] xformOpOrder = ["xformOp:translate", "xformOp:scale"]
|
||||
}
|
||||
}
|
||||
BIN
iOS/velocity-ipad/velocity/Building.usdz
Normal file
BIN
iOS/velocity-ipad/velocity/Building.usdz
Normal file
Binary file not shown.
@@ -2,8 +2,7 @@ import Foundation
|
||||
import Security
|
||||
|
||||
/// Central app configuration.
|
||||
/// Build settings remain the fallback, but production installs should prefer
|
||||
/// runtime configuration stored on-device.
|
||||
/// Enterprise installs must use runtime configuration stored on-device.
|
||||
enum AppConfig {
|
||||
private static let runtimeBaseURLKey = "velocity.runtime.base_url"
|
||||
private static let runtimeDreamWeaverBaseURLKey = "velocity.runtime.dream_weaver_base_url"
|
||||
@@ -42,10 +41,6 @@ enum AppConfig {
|
||||
return "Credentials required"
|
||||
}
|
||||
|
||||
private static func value(for key: String) -> String? {
|
||||
parsedValue(from: Bundle.main.infoDictionary, key: key)
|
||||
}
|
||||
|
||||
private static func sanitizedValue(_ raw: String?, key: String) -> String? {
|
||||
guard let raw else {
|
||||
return nil
|
||||
@@ -59,7 +54,7 @@ enum AppConfig {
|
||||
|
||||
/// Base URL for the Velocity backend / gateway.
|
||||
static var baseURL: String {
|
||||
runtimeBaseURL ?? value(for: "BASE_URL") ?? "https://velocity.desineuron.in/api"
|
||||
runtimeBaseURL ?? SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
|
||||
/// Dedicated Dream Weaver gateway endpoint when configured; otherwise
|
||||
@@ -76,19 +71,19 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
static var dreamWeaverAPIKey: String? {
|
||||
runtimeDreamWeaverAPIKey ?? value(for: "DREAM_WEAVER_API_KEY")
|
||||
runtimeDreamWeaverAPIKey
|
||||
}
|
||||
|
||||
static var apiEmail: String? {
|
||||
runtimeEmail ?? value(for: "API_EMAIL")
|
||||
runtimeEmail
|
||||
}
|
||||
|
||||
static var apiPassword: String? {
|
||||
runtimePassword ?? value(for: "API_PASSWORD")
|
||||
runtimePassword
|
||||
}
|
||||
|
||||
static var apiBearerToken: String? {
|
||||
runtimeBearerToken ?? value(for: "API_BEARER_TOKEN")
|
||||
runtimeBearerToken
|
||||
}
|
||||
|
||||
static var apiAccessToken: String? {
|
||||
@@ -132,7 +127,7 @@ enum AppConfig {
|
||||
email: apiEmail,
|
||||
hasPassword: apiPassword != nil,
|
||||
hasBearerToken: apiBearerToken != nil,
|
||||
source: hasStoredRuntimeConfiguration ? .secureDeviceStorage : .buildConfiguration
|
||||
source: .secureDeviceStorage
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,23 +139,15 @@ enum AppConfig {
|
||||
password: String?,
|
||||
bearerToken: String?
|
||||
) throws {
|
||||
UserDefaults.standard.set(baseURL, forKey: runtimeBaseURLKey)
|
||||
|
||||
if let dreamWeaverBaseURL {
|
||||
UserDefaults.standard.set(dreamWeaverBaseURL, forKey: runtimeDreamWeaverBaseURLKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
}
|
||||
|
||||
if let email {
|
||||
UserDefaults.standard.set(email, forKey: runtimeEmailKey)
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
}
|
||||
|
||||
try storeSecret(baseURL, account: runtimeBaseURLKey)
|
||||
try storeSecret(dreamWeaverBaseURL, account: runtimeDreamWeaverBaseURLKey)
|
||||
try storeSecret(email, account: runtimeEmailKey)
|
||||
try storeSecret(dreamWeaverAPIKey, account: runtimeDreamWeaverAPIKeyKey)
|
||||
try storeSecret(password, account: runtimePasswordKey)
|
||||
try storeSecret(bearerToken, account: runtimeBearerTokenKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try clearStoredAccessToken()
|
||||
}
|
||||
|
||||
@@ -168,6 +155,9 @@ enum AppConfig {
|
||||
UserDefaults.standard.removeObject(forKey: runtimeBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeDreamWeaverBaseURLKey)
|
||||
UserDefaults.standard.removeObject(forKey: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeBaseURLKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverBaseURLKey)
|
||||
try deleteSecret(account: runtimeEmailKey)
|
||||
try deleteSecret(account: runtimeDreamWeaverAPIKeyKey)
|
||||
try deleteSecret(account: runtimePasswordKey)
|
||||
try deleteSecret(account: runtimeBearerTokenKey)
|
||||
@@ -186,16 +176,29 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
private static var runtimeBaseURL: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
canonicalizedBackendBaseURL(
|
||||
sanitizedValue(secret(account: runtimeBaseURLKey), key: runtimeBaseURLKey)
|
||||
)
|
||||
}
|
||||
|
||||
private static func canonicalizedBackendBaseURL(_ value: String?) -> String? {
|
||||
guard let value else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
|
||||
return SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static var configuredDreamWeaverBaseURL: String? {
|
||||
runtimeDreamWeaverBaseURL ?? value(for: "DREAM_WEAVER_BASE_URL")
|
||||
runtimeDreamWeaverBaseURL
|
||||
}
|
||||
|
||||
private static var runtimeDreamWeaverBaseURL: String? {
|
||||
sanitizedValue(
|
||||
UserDefaults.standard.string(forKey: runtimeDreamWeaverBaseURLKey),
|
||||
secret(account: runtimeDreamWeaverBaseURLKey),
|
||||
key: runtimeDreamWeaverBaseURLKey
|
||||
)
|
||||
}
|
||||
@@ -205,7 +208,7 @@ enum AppConfig {
|
||||
}
|
||||
|
||||
private static var runtimeEmail: String? {
|
||||
sanitizedValue(UserDefaults.standard.string(forKey: runtimeEmailKey), key: runtimeEmailKey)
|
||||
sanitizedValue(secret(account: runtimeEmailKey), key: runtimeEmailKey)
|
||||
}
|
||||
|
||||
private static var runtimePassword: String? {
|
||||
|
||||
@@ -12,6 +12,12 @@ enum SessionConfigurationSource: String {
|
||||
case secureDeviceStorage = "Secure device storage"
|
||||
}
|
||||
|
||||
enum SessionConfigurationDefaults {
|
||||
static let productionBaseURL = "https://api.desineuron.in/api"
|
||||
static let legacyVelocityWebBaseURL = "https://velocity.desineuron.in/api"
|
||||
static let dreamWeaverBaseURL = "https://dreamweaver.desineuron.in"
|
||||
}
|
||||
|
||||
struct AppSessionConfiguration: Equatable {
|
||||
let baseURL: String
|
||||
let dreamWeaverBaseURL: String
|
||||
@@ -89,7 +95,13 @@ struct SessionConfigurationDraft: Equatable {
|
||||
}
|
||||
|
||||
var normalizedBaseURL: String? {
|
||||
Self.normalizedHTTPSOrigin(from: trimmedBaseURL)
|
||||
guard let normalized = Self.normalizedHTTPSOrigin(from: trimmedBaseURL) else {
|
||||
return nil
|
||||
}
|
||||
if normalized.caseInsensitiveCompare(SessionConfigurationDefaults.legacyVelocityWebBaseURL) == .orderedSame {
|
||||
return SessionConfigurationDefaults.productionBaseURL
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
var trimmedDreamWeaverBaseURL: String? {
|
||||
@@ -118,7 +130,7 @@ struct SessionConfigurationDraft: Equatable {
|
||||
}
|
||||
|
||||
guard normalizedBaseURL != nil else {
|
||||
errors.append("Backend endpoint must be an HTTPS API base like https://velocity.desineuron.in/api.")
|
||||
errors.append("Backend endpoint must be an HTTPS API base like \(SessionConfigurationDefaults.productionBaseURL).")
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ final class SessionStore {
|
||||
|
||||
func reloadFromPersistedConfiguration() {
|
||||
currentConfiguration = AppConfig.currentSessionConfiguration()
|
||||
draftBaseURL = currentConfiguration.baseURL
|
||||
draftDreamWeaverBaseURL = persistedDreamWeaverDraftValue
|
||||
draftBaseURL = trimmedNonEmpty(currentConfiguration.baseURL) ?? SessionConfigurationDefaults.productionBaseURL
|
||||
draftDreamWeaverBaseURL = trimmedNonEmpty(persistedDreamWeaverDraftValue) ?? SessionConfigurationDefaults.dreamWeaverBaseURL
|
||||
draftDreamWeaverAPIKey = ""
|
||||
draftAuthMode = currentConfiguration.authMode
|
||||
draftEmail = currentConfiguration.email ?? ""
|
||||
@@ -88,6 +88,11 @@ final class SessionStore {
|
||||
baselineEmail = currentConfiguration.email
|
||||
}
|
||||
|
||||
func markDraftEdited() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
}
|
||||
|
||||
func discardDraftChanges() {
|
||||
errorMessage = nil
|
||||
statusMessage = nil
|
||||
@@ -185,6 +190,11 @@ final class SessionStore {
|
||||
currentConfiguration.usesDedicatedDreamWeaverBaseURL ? currentConfiguration.dreamWeaverBaseURL : ""
|
||||
}
|
||||
|
||||
private func trimmedNonEmpty(_ value: String) -> String? {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func verificationStatusMessage(
|
||||
successPrefix: String,
|
||||
backendRefreshError: String?,
|
||||
|
||||
@@ -18,29 +18,71 @@ final class ComfyClient {
|
||||
|
||||
// MARK: - Health Check
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
func checkReadiness() async -> DreamWeaverReadiness {
|
||||
do {
|
||||
var request = authorizedRequest(url: try resolvedURL(candidate: nil, fallbackPath: "/health"))
|
||||
request.timeoutInterval = 30.0
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
|
||||
return false
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway offline",
|
||||
detail: "Dream Weaver gateway did not return a healthy /health response."
|
||||
)
|
||||
}
|
||||
|
||||
let json = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(json.status.lowercased()) else {
|
||||
return false
|
||||
let health = try JSONDecoder().decode(HealthResponse.self, from: data)
|
||||
guard ["ok", "healthy"].contains(health.status.lowercased()) else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway unhealthy",
|
||||
detail: "Dream Weaver gateway reported status: \(health.status)."
|
||||
)
|
||||
}
|
||||
guard health.comfyui != false else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "ComfyUI offline",
|
||||
detail: "The gateway is online, but ComfyUI/GPU is not reachable."
|
||||
)
|
||||
}
|
||||
guard health.checkpointReady != false else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Checkpoint missing",
|
||||
detail: "ComfyUI is online, but no compatible Dream Weaver checkpoint is available."
|
||||
)
|
||||
}
|
||||
guard try await probeDreamWeaverRoute() else {
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Route not mounted",
|
||||
detail: "The /dream-weaver route family is not mounted behind the configured gateway."
|
||||
)
|
||||
}
|
||||
|
||||
return try await probeDreamWeaverRoute()
|
||||
return DreamWeaverReadiness(
|
||||
isReady: true,
|
||||
label: "Ready",
|
||||
detail: "Gateway, Dream Weaver route, ComfyUI, and checkpoint checks passed."
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
return DreamWeaverReadiness(
|
||||
isReady: false,
|
||||
label: "Gateway offline",
|
||||
detail: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call on app launch to confirm the Dream Weaver gateway is reachable
|
||||
/// and the Dream Weaver routes are actually mounted behind it.
|
||||
func checkHealth() async -> Bool {
|
||||
let readiness = await checkReadiness()
|
||||
return readiness.isReady
|
||||
}
|
||||
|
||||
// MARK: - Main Generation Pipeline
|
||||
|
||||
/// Full pipeline: upload → queue → poll → download.
|
||||
@@ -49,6 +91,10 @@ final class ComfyClient {
|
||||
/// - roomType: The type of room being designed (e.g. "bedroom", "living_room").
|
||||
/// - keywords: Comma-separated user keywords appended to the style prompt (can be empty).
|
||||
func generateImage(source: UIImage, roomType: String, keywords: String) async throws -> UIImage {
|
||||
try await generateImageResult(source: source, roomType: roomType, keywords: keywords).image
|
||||
}
|
||||
|
||||
func generateImageResult(source: UIImage, roomType: String, keywords: String) async throws -> DreamWeaverGenerationResult {
|
||||
let normalised = source.fixedOrientation()
|
||||
let resized = normalised.resizedSquare(to: 1024)
|
||||
guard let imageData = resized.jpegData(compressionQuality: 0.85) else {
|
||||
@@ -62,7 +108,8 @@ final class ComfyClient {
|
||||
let resultURL = try await pollUntilReady(job: job)
|
||||
|
||||
// 3. Download result PNG
|
||||
return try await downloadResult(from: resultURL)
|
||||
let image = try await downloadResult(from: resultURL)
|
||||
return DreamWeaverGenerationResult(image: image, resultURL: resultURL)
|
||||
}
|
||||
|
||||
// MARK: - Step 1: POST /dream-weaver
|
||||
@@ -266,9 +313,48 @@ struct JobStatus: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
struct HealthResponse: Codable {
|
||||
struct DreamWeaverReadiness: Equatable {
|
||||
let isReady: Bool
|
||||
let label: String
|
||||
let detail: String
|
||||
}
|
||||
|
||||
struct DreamWeaverGenerationResult {
|
||||
let image: UIImage
|
||||
let resultURL: URL
|
||||
}
|
||||
|
||||
struct HealthResponse: Decodable {
|
||||
let status: String
|
||||
let comfyui: Bool?
|
||||
let checkpointReady: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case comfyui
|
||||
case checkpointReady = "checkpoint_ready"
|
||||
case preferredCheckpointAvailable = "preferred_checkpoint_available"
|
||||
case checkpointAvailable = "checkpoint_available"
|
||||
case hasCheckpoint = "has_checkpoint"
|
||||
case gpuReady = "gpu_ready"
|
||||
}
|
||||
|
||||
init(status: String, comfyui: Bool?, checkpointReady: Bool?) {
|
||||
self.status = status
|
||||
self.comfyui = comfyui
|
||||
self.checkpointReady = checkpointReady
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
status = try container.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
|
||||
comfyui = try container.decodeIfPresent(Bool.self, forKey: .comfyui)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .gpuReady)
|
||||
checkpointReady = try container.decodeIfPresent(Bool.self, forKey: .checkpointReady)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .preferredCheckpointAvailable)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .checkpointAvailable)
|
||||
?? container.decodeIfPresent(Bool.self, forKey: .hasCheckpoint)
|
||||
}
|
||||
}
|
||||
|
||||
struct DreamWeaverErrorResponse: Codable {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct VelocityVaultShareAsset {
|
||||
let leadId: String?
|
||||
let assetName: String
|
||||
let assetType: String
|
||||
let storagePath: String?
|
||||
|
||||
var isShareable: Bool {
|
||||
leadId?.trimmedNonEmpty != nil && storagePath?.trimmedNonEmpty != nil
|
||||
}
|
||||
}
|
||||
|
||||
extension URL {
|
||||
var velocityStoragePath: String {
|
||||
let cleaned = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if cleaned.hasPrefix("assets/") {
|
||||
return String(cleaned.dropFirst("assets/".count))
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func vaultSwipeToShare(asset: VelocityVaultShareAsset?) -> some View {
|
||||
modifier(VaultSwipeToShareModifier(asset: asset))
|
||||
}
|
||||
}
|
||||
|
||||
private struct VaultSwipeToShareModifier: ViewModifier {
|
||||
@State private var appStore = AppStore.shared
|
||||
let asset: VelocityVaultShareAsset?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
ThreeFingerSwipeUpRecognizer {
|
||||
Task { await shareAsset() }
|
||||
}
|
||||
.allowsHitTesting(asset != nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func shareAsset() async {
|
||||
guard let asset else { return }
|
||||
guard let leadId = asset.leadId?.trimmedNonEmpty,
|
||||
let storagePath = asset.storagePath?.trimmedNonEmpty else {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = "Vault share requires a backend lead and stored asset path."
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let threadId = appStore.activeCommunicationsThreadID?.trimmedNonEmpty else {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = "Open a Communications thread before using Vault Swipe-to-Share."
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
do {
|
||||
let link = try await VelocityAPIClient.shared.generateVaultLink(
|
||||
leadId: leadId,
|
||||
assetName: asset.assetName,
|
||||
assetType: asset.assetType,
|
||||
storagePath: storagePath
|
||||
)
|
||||
_ = try await VelocityAPIClient.shared.sendCommsMessage(
|
||||
threadId: threadId,
|
||||
body: "Secure Velocity Vault link: \(link.vaultUrl)"
|
||||
)
|
||||
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.82)) {
|
||||
appStore.vaultShareMessage = "Vault link shared to the active thread."
|
||||
appStore.vaultShareError = nil
|
||||
}
|
||||
} catch {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
appStore.vaultShareError = error.localizedDescription
|
||||
appStore.vaultShareMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThreeFingerSwipeUpRecognizer: UIViewRepresentable {
|
||||
let onSwipe: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
let recognizer = UISwipeGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.didSwipe(_:))
|
||||
)
|
||||
recognizer.direction = .up
|
||||
recognizer.numberOfTouchesRequired = 3
|
||||
view.addGestureRecognizer(recognizer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onSwipe: onSwipe)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
let onSwipe: () -> Void
|
||||
|
||||
init(onSwipe: @escaping () -> Void) {
|
||||
self.onSwipe = onSwipe
|
||||
}
|
||||
|
||||
@objc func didSwipe(_ recognizer: UISwipeGestureRecognizer) {
|
||||
guard recognizer.state == .ended else { return }
|
||||
onSwipe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum AppStoreRefreshPolicy {
|
||||
/// Native iPad surfaces refresh on initial view load, pull-to-refresh, and
|
||||
/// explicit user mutations. View-local repeating timers are intentionally
|
||||
/// avoided so AppStore can coalesce in-flight refreshes and hydrate mobile
|
||||
/// edge state through one bulk request.
|
||||
static let timerDrivenRefreshesEnabled = false
|
||||
|
||||
/// Match the WebOS bootstrap so Inventory, Dashboard, and shared summaries
|
||||
/// are based on the same production property slice by default.
|
||||
static let inventoryPropertyLimit = 100
|
||||
@@ -9,8 +15,9 @@ enum AppStoreRefreshPolicy {
|
||||
/// the operator's active task load on iPad surfaces.
|
||||
static let canonicalTaskLimit = 50
|
||||
|
||||
/// iPad surfaces only render a small operator-focused timeline, so keep the
|
||||
/// lead-event hydration set intentionally narrower than WebOS.
|
||||
/// Lead timelines are hydrated through the mobile-edge bulk endpoint. Keep
|
||||
/// the selected lead set bounded so every shared refresh remains one
|
||||
/// predictable backend call rather than N per-lead calls.
|
||||
static let leadTimelineHydrationLimit = 6
|
||||
|
||||
/// Fetch enough recent communication context for the visible iPad rails
|
||||
|
||||
220
iOS/velocity-ipad/velocity/Core/State/OfflineReplayStore.swift
Normal file
220
iOS/velocity-ipad/velocity/Core/State/OfflineReplayStore.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
struct OfflineReplayRecord: Identifiable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let operation: String
|
||||
let targetID: String?
|
||||
let payload: Data
|
||||
let queuedAt: Date
|
||||
let attemptCount: Int
|
||||
let lastAttemptAt: Date?
|
||||
let lastError: String?
|
||||
}
|
||||
|
||||
actor OfflineReplayStore {
|
||||
static let shared = OfflineReplayStore()
|
||||
|
||||
private enum Schema {
|
||||
static let entityName = "OfflineReplayItem"
|
||||
}
|
||||
|
||||
private let container: NSPersistentContainer
|
||||
private let context: NSManagedObjectContext
|
||||
|
||||
init() {
|
||||
let model = Self.makeModel()
|
||||
container = NSPersistentContainer(name: "VelocityOfflineReplay", managedObjectModel: model)
|
||||
|
||||
let applicationSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first ?? FileManager.default.temporaryDirectory
|
||||
let directory = applicationSupport.appendingPathComponent("Velocity", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
|
||||
let description = NSPersistentStoreDescription(
|
||||
url: directory.appendingPathComponent("OfflineReplay.sqlite")
|
||||
)
|
||||
description.shouldMigrateStoreAutomatically = true
|
||||
description.shouldInferMappingModelAutomatically = true
|
||||
#if os(iOS)
|
||||
description.setOption(
|
||||
FileProtectionType.complete.rawValue as NSString,
|
||||
forKey: NSPersistentStoreFileProtectionKey
|
||||
)
|
||||
#endif
|
||||
container.persistentStoreDescriptions = [description]
|
||||
|
||||
var loadError: Error?
|
||||
container.loadPersistentStores { _, error in
|
||||
loadError = error
|
||||
}
|
||||
if let loadError {
|
||||
assertionFailure("Velocity offline replay store failed to load: \(loadError.localizedDescription)")
|
||||
}
|
||||
|
||||
context = container.newBackgroundContext()
|
||||
context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
|
||||
}
|
||||
|
||||
func enqueue(kind: String, operation: String, targetID: String?, payload: Data) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
if let targetID {
|
||||
let existing = Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context)
|
||||
existing.forEach(context.delete)
|
||||
}
|
||||
|
||||
guard let entity = NSEntityDescription.entity(forEntityName: Schema.entityName, in: context) else {
|
||||
return
|
||||
}
|
||||
let item = NSManagedObject(entity: entity, insertInto: context)
|
||||
item.setValue(UUID().uuidString, forKey: "id")
|
||||
item.setValue(kind, forKey: "kind")
|
||||
item.setValue(operation, forKey: "operation")
|
||||
item.setValue(targetID, forKey: "targetID")
|
||||
item.setValue(payload, forKey: "payload")
|
||||
item.setValue(Date(), forKey: "queuedAt")
|
||||
item.setValue(0, forKey: "attemptCount")
|
||||
item.setValue(nil, forKey: "lastAttemptAt")
|
||||
item.setValue(nil, forKey: "lastError")
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func pendingRecords(limit: Int = 100) -> [OfflineReplayRecord] {
|
||||
let context = context
|
||||
var records: [OfflineReplayRecord] = []
|
||||
context.performAndWait {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(key: "queuedAt", ascending: true)
|
||||
]
|
||||
request.fetchLimit = limit
|
||||
let items = (try? context.fetch(request)) ?? []
|
||||
records = items.compactMap(Self.record(from:))
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func markCompleted(id: String) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
Self.fetchManagedObjects(id: id, in: context).forEach(context.delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func markFailed(id: String, error: Error) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
for item in Self.fetchManagedObjects(id: id, in: context) {
|
||||
let currentAttempts = item.value(forKey: "attemptCount") as? Int ?? 0
|
||||
item.setValue(currentAttempts + 1, forKey: "attemptCount")
|
||||
item.setValue(Date(), forKey: "lastAttemptAt")
|
||||
item.setValue(error.localizedDescription, forKey: "lastError")
|
||||
}
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(kind: String, targetID: String) {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
Self.fetchManagedObjects(kind: kind, targetID: targetID, in: context).forEach(context.delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
let context = context
|
||||
context.performAndWait {
|
||||
let request = NSFetchRequest<NSFetchRequestResult>(entityName: Schema.entityName)
|
||||
let delete = NSBatchDeleteRequest(fetchRequest: request)
|
||||
_ = try? context.execute(delete)
|
||||
Self.saveIfNeeded(context)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetchManagedObjects(id: String, in context: NSManagedObjectContext) -> [NSManagedObject] {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.predicate = NSPredicate(format: "id == %@", id)
|
||||
request.fetchLimit = 1
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func fetchManagedObjects(
|
||||
kind: String,
|
||||
targetID: String,
|
||||
in context: NSManagedObjectContext
|
||||
) -> [NSManagedObject] {
|
||||
let request = NSFetchRequest<NSManagedObject>(entityName: Schema.entityName)
|
||||
request.predicate = NSPredicate(format: "kind == %@ AND targetID == %@", kind, targetID)
|
||||
return (try? context.fetch(request)) ?? []
|
||||
}
|
||||
|
||||
private static func saveIfNeeded(_ context: NSManagedObjectContext) {
|
||||
guard context.hasChanges else { return }
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
private static func record(from object: NSManagedObject) -> OfflineReplayRecord? {
|
||||
guard
|
||||
let id = object.value(forKey: "id") as? String,
|
||||
let kind = object.value(forKey: "kind") as? String,
|
||||
let operation = object.value(forKey: "operation") as? String,
|
||||
let payload = object.value(forKey: "payload") as? Data,
|
||||
let queuedAt = object.value(forKey: "queuedAt") as? Date
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return OfflineReplayRecord(
|
||||
id: id,
|
||||
kind: kind,
|
||||
operation: operation,
|
||||
targetID: object.value(forKey: "targetID") as? String,
|
||||
payload: payload,
|
||||
queuedAt: queuedAt,
|
||||
attemptCount: object.value(forKey: "attemptCount") as? Int ?? 0,
|
||||
lastAttemptAt: object.value(forKey: "lastAttemptAt") as? Date,
|
||||
lastError: object.value(forKey: "lastError") as? String
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeModel() -> NSManagedObjectModel {
|
||||
let model = NSManagedObjectModel()
|
||||
let entity = NSEntityDescription()
|
||||
entity.name = Schema.entityName
|
||||
entity.managedObjectClassName = NSStringFromClass(NSManagedObject.self)
|
||||
|
||||
entity.properties = [
|
||||
attribute("id", type: .stringAttributeType, optional: false),
|
||||
attribute("kind", type: .stringAttributeType, optional: false),
|
||||
attribute("operation", type: .stringAttributeType, optional: false),
|
||||
attribute("targetID", type: .stringAttributeType, optional: true),
|
||||
attribute("payload", type: .binaryDataAttributeType, optional: false),
|
||||
attribute("queuedAt", type: .dateAttributeType, optional: false),
|
||||
attribute("attemptCount", type: .integer64AttributeType, optional: false, defaultValue: 0),
|
||||
attribute("lastAttemptAt", type: .dateAttributeType, optional: true),
|
||||
attribute("lastError", type: .stringAttributeType, optional: true),
|
||||
]
|
||||
model.entities = [entity]
|
||||
return model
|
||||
}
|
||||
|
||||
private static func attribute(
|
||||
_ name: String,
|
||||
type: NSAttributeType,
|
||||
optional: Bool,
|
||||
defaultValue: Any? = nil
|
||||
) -> NSAttributeDescription {
|
||||
let attribute = NSAttributeDescription()
|
||||
attribute.name = name
|
||||
attribute.attributeType = type
|
||||
attribute.isOptional = optional
|
||||
attribute.defaultValue = defaultValue
|
||||
return attribute
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
private struct CalendarAgendaItem: Identifiable {
|
||||
@@ -9,6 +8,7 @@ private struct CalendarAgendaItem: Identifiable {
|
||||
let location: String
|
||||
let type: String
|
||||
let color: Color
|
||||
let pendingSync: Bool
|
||||
let sortDate: Date?
|
||||
let event: VelocityCalendarEventDTO?
|
||||
let task: VelocityTaskDTO?
|
||||
@@ -58,15 +58,19 @@ struct CalendarView: View {
|
||||
@State private var actionError: String?
|
||||
@State private var actionMessage: String?
|
||||
@State private var actionMessageDismissTask: Task<Void, Never>?
|
||||
@State private var activeDashboardFocus: VelocityDashboardCalendarFocus?
|
||||
@State private var activeTaskMutationID: String?
|
||||
@State private var activeEventMutationID: String?
|
||||
@State private var undoCancelledTask: VelocityTaskDTO?
|
||||
@State private var undoCancelledEvent: VelocityCalendarEventDTO?
|
||||
@State private var isCreateEventPresented = false
|
||||
@State private var editingEvent: VelocityCalendarEventDTO?
|
||||
@State private var eventDraft = CalendarEventDraft()
|
||||
@State private var isCreatingEvent = false
|
||||
@State private var isSavingEvent = false
|
||||
@State private var createEventError: String?
|
||||
private let refreshTimer = Timer.publish(every: 15, on: .main, in: .common).autoconnect()
|
||||
@State private var schedulingClientPersonID: String?
|
||||
@State private var targetedDropDay: String?
|
||||
private let visibleWeekdays = Calendar.current.weekdaySymbols
|
||||
|
||||
var body: some View {
|
||||
@@ -82,10 +86,14 @@ struct CalendarView: View {
|
||||
if let actionMessage {
|
||||
successBanner(actionMessage)
|
||||
}
|
||||
if let activeDashboardFocus {
|
||||
dashboardFocusBanner(activeDashboardFocus)
|
||||
}
|
||||
if store.isLoading && store.lastRefreshAt == nil {
|
||||
loadingPanel
|
||||
} else {
|
||||
metricsRow
|
||||
clientSchedulingStrip
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
scheduleRail
|
||||
agendaPanel
|
||||
@@ -96,11 +104,14 @@ struct CalendarView: View {
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
.task {
|
||||
await store.refresh()
|
||||
consumeRequestedCalendarFocus()
|
||||
}
|
||||
.onChange(of: store.requestedCalendarFocus) { _, _ in
|
||||
consumeRequestedCalendarFocus()
|
||||
}
|
||||
.refreshable { await store.refresh() }
|
||||
.onDisappear {
|
||||
actionMessageDismissTask?.cancel()
|
||||
actionMessageDismissTask = nil
|
||||
@@ -108,6 +119,9 @@ struct CalendarView: View {
|
||||
.sheet(isPresented: $isCreateEventPresented) {
|
||||
createEventSheet
|
||||
}
|
||||
.sheet(item: $editingEvent) { event in
|
||||
editEventSheet(event)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -141,6 +155,9 @@ struct CalendarView: View {
|
||||
Button {
|
||||
selectedDay = Self.currentWeekdayName()
|
||||
actionError = nil
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
activeDashboardFocus = nil
|
||||
}
|
||||
} label: {
|
||||
Text("This week")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
@@ -207,14 +224,25 @@ struct CalendarView: View {
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface)
|
||||
.fill(targetedDropDay == day ? VelocityTheme.accent.opacity(0.22) : (selectedDay == day ? VelocityTheme.accent.opacity(0.12) : VelocityTheme.surface))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
.stroke(targetedDropDay == day || selectedDay == day ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.dropDestination(for: String.self) { personIDs, _ in
|
||||
guard let personID = personIDs.first?.trimmedNonEmpty else {
|
||||
return false
|
||||
}
|
||||
Task { await scheduleSiteVisit(forPersonID: personID, on: day) }
|
||||
return true
|
||||
} isTargeted: { isTargeted in
|
||||
withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) {
|
||||
targetedDropDay = isTargeted ? day : nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
@@ -222,6 +250,46 @@ struct CalendarView: View {
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private var clientSchedulingStrip: some View {
|
||||
Group {
|
||||
if !store.contacts.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(store.contacts.prefix(12)) { contact in
|
||||
HStack(spacing: 8) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(VelocityTheme.accent.opacity(0.16))
|
||||
.frame(width: 32, height: 32)
|
||||
Text(initials(for: contact.fullName))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
Text(contact.fullName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(Capsule().stroke(VelocityTheme.borderSubtle, lineWidth: 1))
|
||||
)
|
||||
.opacity(schedulingClientPersonID == contact.personId ? 0.55 : 1)
|
||||
.scaleEffect(schedulingClientPersonID == contact.personId ? 0.97 : 1)
|
||||
.draggable(contact.personId)
|
||||
.animation(.interactiveSpring(response: 0.32, dampingFraction: 0.86), value: schedulingClientPersonID)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var agendaPanel: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
@@ -263,6 +331,12 @@ struct CalendarView: View {
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
if item.pendingSync {
|
||||
Circle()
|
||||
.fill(VelocityTheme.warning)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
|
||||
}
|
||||
Text(item.type)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(item.color)
|
||||
@@ -499,7 +573,157 @@ struct CalendarView: View {
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private func editEventSheet(_ event: VelocityCalendarEventDTO) -> some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
VelocityTheme.background.ignoresSafeArea()
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Edit Event")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Update the backend-owned calendar slot details.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if let createEventError {
|
||||
errorBanner(createEventError)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
formLabel("Title")
|
||||
eventTextField("Site visit with client", text: $eventDraft.title)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Picker("Status", selection: $eventDraft.status) {
|
||||
Text("Normal Task").tag("tentative")
|
||||
Text("Confirmed Task").tag("confirmed")
|
||||
Text("Done").tag("done")
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.tint(VelocityTheme.accent)
|
||||
|
||||
Picker("Reminder", selection: $eventDraft.reminderMinutes) {
|
||||
Text("None").tag(0)
|
||||
Text("5 min").tag(5)
|
||||
Text("15 min").tag(15)
|
||||
Text("30 min").tag(30)
|
||||
Text("1 hour").tag(60)
|
||||
Text("1 day").tag(1_440)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.tint(VelocityTheme.accent)
|
||||
}
|
||||
.padding(12)
|
||||
.background(fieldBackground)
|
||||
|
||||
DatePicker(
|
||||
"Starts",
|
||||
selection: eventStartBinding,
|
||||
displayedComponents: eventDatePickerComponents
|
||||
)
|
||||
.tint(VelocityTheme.accent)
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(12)
|
||||
.background(fieldBackground)
|
||||
|
||||
if !eventDraft.allDay {
|
||||
DatePicker(
|
||||
"Ends",
|
||||
selection: $eventDraft.endDate,
|
||||
in: eventDraft.startDate.addingTimeInterval(60)...,
|
||||
displayedComponents: [.date, .hourAndMinute]
|
||||
)
|
||||
.tint(VelocityTheme.accent)
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(12)
|
||||
.background(fieldBackground)
|
||||
}
|
||||
|
||||
formLabel("Location")
|
||||
eventTextField("Project site, sales lounge, video call", text: $eventDraft.location)
|
||||
|
||||
formLabel("Description")
|
||||
TextEditor(text: $eventDraft.description)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 110)
|
||||
.padding(10)
|
||||
.background(fieldBackground)
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
.frame(height: 436)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
editingEvent = nil
|
||||
} label: {
|
||||
Text("Cancel")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 13)
|
||||
.background(fieldBackground)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
saveEventEdits(event)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isSavingEvent {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(isSavingEvent ? "Saving..." : "Save Event")
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 13)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(eventDraft.isValid && !isSavingEvent ? VelocityTheme.accent : VelocityTheme.surface3)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!eventDraft.isValid || isSavingEvent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: 620)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.presentationDetents([.height(690)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var filteredAgendaItems: [CalendarAgendaItem] {
|
||||
if let activeDashboardFocus {
|
||||
switch activeDashboardFocus {
|
||||
case .today:
|
||||
let weekday = Self.currentWeekdayName().lowercased()
|
||||
return agendaItems.filter {
|
||||
$0.slot.lowercased().contains(weekday) && !isInactiveAgendaItem($0)
|
||||
}
|
||||
case .pendingTasks:
|
||||
return agendaItems.filter { item in
|
||||
guard let task = item.task else { return false }
|
||||
return ["pending", "snoozed", "confirmed"].contains(task.status.lowercased())
|
||||
}
|
||||
case .urgentTasks:
|
||||
return agendaItems.filter { item in
|
||||
guard let task = item.task else { return false }
|
||||
return ["urgent", "high"].contains(task.priority.lowercased()) && !isInactiveAgendaItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
let weekday = selectedDay.lowercased()
|
||||
return agendaItems.filter { $0.slot.lowercased().contains(weekday) }
|
||||
}
|
||||
@@ -514,6 +738,7 @@ struct CalendarView: View {
|
||||
location: event.location ?? "No location",
|
||||
type: eventStatusLabel(event.status),
|
||||
color: color(for: event.status),
|
||||
pendingSync: store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-"),
|
||||
sortDate: event.startDate,
|
||||
event: event,
|
||||
task: nil
|
||||
@@ -528,6 +753,7 @@ struct CalendarView: View {
|
||||
location: task.clientPhone ?? "Canonical CRM task",
|
||||
type: taskStatusLabel(task),
|
||||
color: taskColor(for: task),
|
||||
pendingSync: store.pendingSyncTaskIDs.contains(task.reminderId),
|
||||
sortDate: task.dueDate,
|
||||
event: nil,
|
||||
task: task
|
||||
@@ -673,6 +899,34 @@ struct CalendarView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func dashboardFocusBanner(_ focus: VelocityDashboardCalendarFocus) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Label(calendarFocusLabel(focus), systemImage: "line.3.horizontal.decrease.circle")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
Text(calendarFocusDescription(focus))
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Spacer()
|
||||
Button("Clear") {
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
activeDashboardFocus = nil
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.warning.opacity(0.10))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.warning.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func buildMetrics(
|
||||
events: [VelocityCalendarEventDTO],
|
||||
tasks: [VelocityTaskDTO],
|
||||
@@ -884,6 +1138,14 @@ struct CalendarView: View {
|
||||
private func eventActionsMenu(_ event: VelocityCalendarEventDTO) -> some View {
|
||||
Menu {
|
||||
let status = event.status.lowercased()
|
||||
if status != "cancelled" {
|
||||
Button {
|
||||
presentEditEvent(event)
|
||||
} label: {
|
||||
Label("Edit Event", systemImage: "square.and.pencil")
|
||||
}
|
||||
}
|
||||
|
||||
if status == "done" {
|
||||
Button(role: .destructive) {
|
||||
cancelEvent(event, message: "Task removed.", supportsUndo: false)
|
||||
@@ -946,6 +1208,79 @@ struct CalendarView: View {
|
||||
isCreateEventPresented = true
|
||||
}
|
||||
|
||||
private func presentEditEvent(_ event: VelocityCalendarEventDTO) {
|
||||
let startDate = event.startDate ?? CalendarEventDraft.defaultStartDate()
|
||||
var draft = CalendarEventDraft(startDate: startDate)
|
||||
draft.title = event.title
|
||||
draft.description = event.description ?? ""
|
||||
draft.location = event.location ?? ""
|
||||
draft.endDate = event.endDate ?? startDate.addingTimeInterval(60 * 60)
|
||||
draft.allDay = event.allDay
|
||||
draft.status = event.status.lowercased()
|
||||
draft.reminderMinutes = event.reminderMinutes.first ?? 0
|
||||
eventDraft = draft
|
||||
createEventError = nil
|
||||
actionError = nil
|
||||
clearActionMessage()
|
||||
editingEvent = event
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleSiteVisit(forPersonID personID: String, on weekday: String) async {
|
||||
guard schedulingClientPersonID == nil else {
|
||||
return
|
||||
}
|
||||
guard let contact = store.contacts.first(where: { $0.personId == personID }) else {
|
||||
actionError = "Unable to schedule: this client is not present in the canonical CRM payload."
|
||||
return
|
||||
}
|
||||
guard let leadId = contact.leadId?.trimmedNonEmpty else {
|
||||
actionError = "Unable to schedule \(contact.fullName): no canonical lead is attached."
|
||||
return
|
||||
}
|
||||
|
||||
let startDate = defaultEventStartDate(for: weekday)
|
||||
let endDate = startDate.addingTimeInterval(60 * 60)
|
||||
var metadata = [
|
||||
"created_from": "ipad_calendar_drag_drop",
|
||||
"surface": "velocity_ipad",
|
||||
"person_id": contact.personId,
|
||||
"client_name": contact.fullName,
|
||||
]
|
||||
if let phone = contact.primaryPhone?.trimmedNonEmpty {
|
||||
metadata["client_phone"] = phone
|
||||
}
|
||||
|
||||
schedulingClientPersonID = personID
|
||||
actionError = nil
|
||||
clearActionMessage()
|
||||
|
||||
do {
|
||||
_ = try await store.createCalendarEvent(
|
||||
leadId: leadId,
|
||||
title: "Site visit with \(contact.fullName)",
|
||||
description: contact.primaryInterest.flatMap { "Property focus: \($0)".trimmedNonEmpty },
|
||||
startAt: iso8601Timestamp(startDate),
|
||||
endAt: iso8601Timestamp(endDate),
|
||||
allDay: false,
|
||||
status: "confirmed",
|
||||
reminderMinutes: [60, 15],
|
||||
location: contact.primaryInterest?.trimmedNonEmpty ?? "Project site",
|
||||
metadata: metadata
|
||||
)
|
||||
withAnimation(.interactiveSpring(response: 0.42, dampingFraction: 0.86)) {
|
||||
selectedDay = weekday
|
||||
schedulingClientPersonID = nil
|
||||
targetedDropDay = nil
|
||||
}
|
||||
showActionMessage("Site visit scheduled for \(contact.fullName).")
|
||||
} catch {
|
||||
schedulingClientPersonID = nil
|
||||
targetedDropDay = nil
|
||||
actionError = calendarActionErrorMessage(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func createEvent() {
|
||||
guard eventDraft.isValid else {
|
||||
createEventError = "Add an event title and make sure the end time is after the start time."
|
||||
@@ -1004,6 +1339,50 @@ struct CalendarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveEventEdits(_ event: VelocityCalendarEventDTO) {
|
||||
guard eventDraft.isValid else {
|
||||
createEventError = "Add an event title and make sure the end time is after the start time."
|
||||
return
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let startDate = eventDraft.allDay ? calendar.startOfDay(for: eventDraft.startDate) : eventDraft.startDate
|
||||
let endDate = eventDraft.allDay
|
||||
? (calendar.date(byAdding: .day, value: 1, to: startDate) ?? startDate.addingTimeInterval(24 * 60 * 60))
|
||||
: eventDraft.endDate
|
||||
let reminderMinutes = eventDraft.reminderMinutes > 0 ? [eventDraft.reminderMinutes] : []
|
||||
|
||||
createEventError = nil
|
||||
isSavingEvent = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
_ = try await store.updateCalendarEvent(
|
||||
event,
|
||||
title: eventDraft.title.trimmedNonEmpty ?? event.title,
|
||||
description: eventDraft.description,
|
||||
status: eventDraft.status,
|
||||
startAt: iso8601Timestamp(startDate),
|
||||
endAt: iso8601Timestamp(endDate),
|
||||
reminderMinutes: reminderMinutes,
|
||||
location: eventDraft.location
|
||||
)
|
||||
await MainActor.run {
|
||||
selectedDay = weekdayName(for: startDate)
|
||||
isSavingEvent = false
|
||||
editingEvent = nil
|
||||
showActionMessage("Event updated.")
|
||||
actionError = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isSavingEvent = false
|
||||
createEventError = calendarActionErrorMessage(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mutateEvent(
|
||||
_ event: VelocityCalendarEventDTO,
|
||||
status: String,
|
||||
@@ -1170,6 +1549,41 @@ struct CalendarView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func consumeRequestedCalendarFocus() {
|
||||
guard let focus = store.requestedCalendarFocus else {
|
||||
return
|
||||
}
|
||||
store.requestedCalendarFocus = nil
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.86)) {
|
||||
activeDashboardFocus = focus
|
||||
if focus == .today {
|
||||
selectedDay = Self.currentWeekdayName()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calendarFocusLabel(_ focus: VelocityDashboardCalendarFocus) -> String {
|
||||
switch focus {
|
||||
case .today:
|
||||
return "Today"
|
||||
case .pendingTasks:
|
||||
return "Pending tasks"
|
||||
case .urgentTasks:
|
||||
return "Urgent tasks"
|
||||
}
|
||||
}
|
||||
|
||||
private func calendarFocusDescription(_ focus: VelocityDashboardCalendarFocus) -> String {
|
||||
switch focus {
|
||||
case .today:
|
||||
return "Showing today’s confirmed events and CRM reminders."
|
||||
case .pendingTasks:
|
||||
return "Showing actionable CRM reminders across the week."
|
||||
case .urgentTasks:
|
||||
return "Showing high-priority and urgent CRM reminders."
|
||||
}
|
||||
}
|
||||
|
||||
private func clearActionMessage(clearUndo: Bool = true) {
|
||||
actionMessageDismissTask?.cancel()
|
||||
actionMessageDismissTask = nil
|
||||
@@ -1233,6 +1647,15 @@ struct CalendarView: View {
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func initials(for name: String) -> String {
|
||||
let pieces = name
|
||||
.split(separator: " ")
|
||||
.prefix(2)
|
||||
.compactMap { $0.first }
|
||||
let initials = String(pieces).uppercased()
|
||||
return initials.isEmpty ? "CL" : initials
|
||||
}
|
||||
|
||||
private func menuIcon(_ systemName: String) -> some View {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,39 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
private struct ImportRemediationDraft: Identifiable {
|
||||
let batchId: String
|
||||
let proposal: VelocityImportProposalDTO
|
||||
let workbenchRow: VelocityImportWorkbenchRowDTO?
|
||||
let fields: [String]
|
||||
|
||||
var id: String { proposal.proposalId }
|
||||
|
||||
init(batchId: String, proposal: VelocityImportProposalDTO, workbenchRow: VelocityImportWorkbenchRowDTO?) {
|
||||
self.batchId = batchId
|
||||
self.proposal = proposal
|
||||
self.workbenchRow = workbenchRow
|
||||
let canonicalFields: [String] = proposal.payload?.canonicalPayload.map { Array($0.keys) } ?? []
|
||||
let missingFields = proposal.payload?.missingRequired ?? []
|
||||
let unresolvedFields = proposal.payload?.unresolvedFields ?? []
|
||||
let diffFields = workbenchRow?.fieldDiffs.map(\.field) ?? []
|
||||
let validationFields = workbenchRow?.validation.map(\.field) ?? []
|
||||
let combinedFields: [String] = canonicalFields + missingFields + unresolvedFields + diffFields + validationFields
|
||||
fields = Array(Set<String>(combinedFields)).sorted()
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportsView: View {
|
||||
@State private var appStore = AppStore.shared
|
||||
@State private var batches: [VelocityImportBatchSummaryDTO] = []
|
||||
@State private var selectedBatch: VelocityImportBatchSummaryDTO?
|
||||
@State private var detail: VelocityImportBatchDetailDTO?
|
||||
@State private var workbench: VelocityImportWorkbenchDTO?
|
||||
@State private var isLoading = false
|
||||
@State private var isCommitting = false
|
||||
@State private var activeProposalID: String?
|
||||
@State private var errorMessage: String?
|
||||
@State private var successMessage: String?
|
||||
private let refreshTimer = Timer.publish(every: 30, on: .main, in: .common).autoconnect()
|
||||
@State private var remediationDraft: ImportRemediationDraft?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -24,20 +47,40 @@ struct ImportsView: View {
|
||||
detailPane
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await loadBatches(selectFirst: true) }
|
||||
.task {
|
||||
await appStore.ensureCRMVocabulariesLoaded()
|
||||
await loadBatches(selectFirst: true)
|
||||
}
|
||||
.refreshable { await loadBatches(selectFirst: false) }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await loadBatches(selectFirst: false, silent: true) }
|
||||
.sheet(item: $remediationDraft) { draft in
|
||||
ImportRemediationSheet(
|
||||
draft: draft,
|
||||
duplicatePolicies: appStore.crmVocabularies.importDuplicatePolicies
|
||||
) { decision, notes, fieldOverrides, duplicatePolicy in
|
||||
Task {
|
||||
await reviewProposal(
|
||||
batchId: draft.batchId,
|
||||
proposal: draft.proposal,
|
||||
decision: decision,
|
||||
notes: notes,
|
||||
fieldOverrides: fieldOverrides,
|
||||
duplicatePolicy: duplicatePolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var batchRail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Imports")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Canonical CRM import review and commit queue.")
|
||||
HStack {
|
||||
Text("Imports")
|
||||
.font(.system(size: 26, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
}
|
||||
Text("Read-only canonical CRM import review and remediation queue.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -77,6 +120,9 @@ struct ImportsView: View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if let detail {
|
||||
detailHeader(detail)
|
||||
if let workbench {
|
||||
workbenchPanel(workbench)
|
||||
}
|
||||
proposalsPanel(detail)
|
||||
} else if isLoading {
|
||||
loadingCard("Loading import detail...")
|
||||
@@ -195,8 +241,115 @@ struct ImportsView: View {
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
private func workbenchPanel(_ workbench: VelocityImportWorkbenchDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Remediation Workbench")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Validation, duplicate detection, and canonical CRM row diffs before commit.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
Task {
|
||||
if let batchId = detail?.batchId {
|
||||
await refreshWorkbench(batchId: batchId)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
metricCard("Rows", value: "\(workbench.summary.proposalCount)", color: VelocityTheme.accent)
|
||||
metricCard("Duplicates", value: "\(workbench.summary.duplicateCount)", color: VelocityTheme.warning)
|
||||
metricCard("Errors", value: "\(workbench.summary.validationErrorCount)", color: VelocityTheme.danger)
|
||||
metricCard("Warnings", value: "\(workbench.summary.validationWarningCount)", color: VelocityTheme.warning)
|
||||
}
|
||||
|
||||
LazyVStack(spacing: 10) {
|
||||
ForEach(workbench.rows.prefix(20)) { row in
|
||||
workbenchRowCard(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.glassCard(cornerRadius: 20)
|
||||
}
|
||||
|
||||
private func workbenchRowCard(_ row: VelocityImportWorkbenchRowDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(row.rowNumber.map { "Row \($0)" } ?? "Proposal \(row.proposalId.prefix(8))")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("\(confidencePercent(row.confidence))%")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
Text(row.status.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(lifecycleColor(row.status))
|
||||
}
|
||||
|
||||
if !row.validation.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(row.validation) { issue in
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning)
|
||||
Text("\(issue.field): \(issue.message)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let duplicate = row.duplicateCandidates.first {
|
||||
Text("Duplicate candidate: \(duplicate.fullName) · \(duplicate.matchReason) match · \(duplicate.matchScore)%")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
|
||||
let changedDiffs = row.fieldDiffs.filter(\.changed)
|
||||
if !changedDiffs.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Changed fields")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
ForEach(changedDiffs.prefix(4)) { diff in
|
||||
Text("\(diff.field): \(diff.existing ?? "-") → \(diff.proposed ?? "-")")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger.opacity(0.25) : VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func proposalCard(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
let rowDiagnostics = workbench?.row(for: proposal.proposalId)
|
||||
return VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(proposal.rowLabel)
|
||||
@@ -227,6 +380,16 @@ struct ImportsView: View {
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
}
|
||||
|
||||
if let unresolved = proposal.payload?.unresolvedFields, !unresolved.isEmpty {
|
||||
Text("Needs review: \(unresolved.joined(separator: ", "))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
|
||||
if let rowDiagnostics {
|
||||
proposalDiagnostics(rowDiagnostics)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(
|
||||
@@ -239,20 +402,49 @@ struct ImportsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
private func proposalDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
if !row.validation.isEmpty {
|
||||
Text("Validation: \(row.validation.map { "\($0.field) \($0.severity)" }.joined(separator: ", "))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(row.validation.contains { $0.severity == "error" } ? VelocityTheme.danger : VelocityTheme.warning)
|
||||
}
|
||||
if let duplicate = row.duplicateCandidates.first {
|
||||
Text("Possible duplicate: \(duplicate.fullName) (\(duplicate.matchReason), \(duplicate.matchScore)%)")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func proposalActions(_ proposal: VelocityImportProposalDTO, batchId: String) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
Button("Approve") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "approved") }
|
||||
Task {
|
||||
await reviewProposal(
|
||||
batchId: batchId,
|
||||
proposal: proposal,
|
||||
decision: "approved",
|
||||
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(proposal.status.lowercased() == "approved")
|
||||
.disabled(proposal.status.lowercased() == "approved" || defaultDuplicatePolicyValue(for: proposal) == nil)
|
||||
|
||||
Button("Reject") {
|
||||
Task { await reviewProposal(batchId: batchId, proposal: proposal, decision: "rejected") }
|
||||
Task {
|
||||
await reviewProposal(
|
||||
batchId: batchId,
|
||||
proposal: proposal,
|
||||
decision: "rejected",
|
||||
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
@@ -260,9 +452,46 @@ struct ImportsView: View {
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.disabled(proposal.status.lowercased() == "rejected")
|
||||
|
||||
Button("Needs Info") {
|
||||
Task {
|
||||
await reviewProposal(
|
||||
batchId: batchId,
|
||||
proposal: proposal,
|
||||
decision: "needs_more_info",
|
||||
duplicatePolicy: defaultDuplicatePolicyValue(for: proposal)
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
Button("Remediate") {
|
||||
remediationDraft = ImportRemediationDraft(
|
||||
batchId: batchId,
|
||||
proposal: proposal,
|
||||
workbenchRow: workbench?.row(for: proposal.proposalId)
|
||||
)
|
||||
}
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private func defaultDuplicatePolicyValue(for proposal: VelocityImportProposalDTO) -> String? {
|
||||
if let policy = workbench?.rows.first(where: { $0.proposalId == proposal.proposalId })?.duplicatePolicy,
|
||||
appStore.crmVocabularies.importDuplicatePolicies.contains(where: { $0.value == policy }) {
|
||||
return policy
|
||||
}
|
||||
return appStore.crmVocabularies.importDuplicatePolicies.first?.value
|
||||
}
|
||||
|
||||
private func loadBatches(selectFirst: Bool, silent: Bool = false) async {
|
||||
if !silent {
|
||||
isLoading = true
|
||||
@@ -291,6 +520,7 @@ struct ImportsView: View {
|
||||
await MainActor.run {
|
||||
selectedBatch = batch
|
||||
detail = nil
|
||||
workbench = nil
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
isLoading = true
|
||||
@@ -303,9 +533,13 @@ struct ImportsView: View {
|
||||
await MainActor.run { isLoading = true }
|
||||
}
|
||||
do {
|
||||
let fetched = try await VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
|
||||
async let detailTask = VelocityAPIClient.shared.fetchImportBatch(batchId: batchId)
|
||||
async let workbenchTask = VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId)
|
||||
let fetched = try await detailTask
|
||||
let fetchedWorkbench = try? await workbenchTask
|
||||
await MainActor.run {
|
||||
detail = fetched
|
||||
workbench = fetchedWorkbench
|
||||
errorMessage = nil
|
||||
isLoading = false
|
||||
}
|
||||
@@ -317,11 +551,34 @@ struct ImportsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshWorkbench(batchId: String) async {
|
||||
do {
|
||||
let fetchedWorkbench = try await VelocityAPIClient.shared.fetchImportWorkbench(batchId: batchId)
|
||||
await MainActor.run {
|
||||
workbench = fetchedWorkbench
|
||||
errorMessage = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reviewProposal(
|
||||
batchId: String,
|
||||
proposal: VelocityImportProposalDTO,
|
||||
decision: String
|
||||
decision: String,
|
||||
notes: String = "Reviewed from iPad Imports workspace.",
|
||||
fieldOverrides: [String: String] = [:],
|
||||
duplicatePolicy: String?
|
||||
) async {
|
||||
guard let duplicatePolicy else {
|
||||
await MainActor.run {
|
||||
errorMessage = "Unable to review import row because backend duplicate policy vocabulary is unavailable."
|
||||
}
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
activeProposalID = proposal.proposalId
|
||||
errorMessage = nil
|
||||
@@ -332,12 +589,15 @@ struct ImportsView: View {
|
||||
batchId: batchId,
|
||||
proposalId: proposal.proposalId,
|
||||
decision: decision,
|
||||
notes: "Reviewed from iPad Imports workspace."
|
||||
notes: notes,
|
||||
fieldOverrides: fieldOverrides,
|
||||
duplicatePolicy: duplicatePolicy
|
||||
)
|
||||
await refreshDetail(batchId: batchId, silent: true)
|
||||
await MainActor.run {
|
||||
activeProposalID = nil
|
||||
successMessage = "Proposal \(decision)."
|
||||
remediationDraft = nil
|
||||
successMessage = "Proposal \(decision.replacingOccurrences(of: "_", with: " "))."
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -379,6 +639,11 @@ struct ImportsView: View {
|
||||
detail.proposals.filter { $0.status == "approved" }.count
|
||||
}
|
||||
|
||||
private func confidencePercent(_ value: Double) -> Int {
|
||||
let normalized = value <= 1 ? value * 100 : value
|
||||
return max(0, min(100, Int(normalized.rounded())))
|
||||
}
|
||||
|
||||
private func canonicalPreview(_ payload: [String: JSONValue]) -> String {
|
||||
payload
|
||||
.sorted(by: { $0.key < $1.key })
|
||||
@@ -462,6 +727,239 @@ struct ImportsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ImportRemediationSheet: View {
|
||||
let draft: ImportRemediationDraft
|
||||
let duplicatePolicies: [VelocityVocabularyOptionDTO]
|
||||
let onSubmit: (String, String, [String: String], String) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var notes: String
|
||||
@State private var fieldOverrides: [String: String]
|
||||
@State private var duplicatePolicy: String
|
||||
|
||||
init(
|
||||
draft: ImportRemediationDraft,
|
||||
duplicatePolicies: [VelocityVocabularyOptionDTO],
|
||||
onSubmit: @escaping (String, String, [String: String], String) -> Void
|
||||
) {
|
||||
self.draft = draft
|
||||
self.duplicatePolicies = duplicatePolicies
|
||||
self.onSubmit = onSubmit
|
||||
_notes = State(initialValue: "")
|
||||
let canonicalPayload = draft.proposal.payload?.canonicalPayload ?? [:]
|
||||
let initialOverrides: [String: String] = Dictionary<String, String>(
|
||||
uniqueKeysWithValues: draft.fields.map { field in
|
||||
(field, canonicalPayload[field]?.stringValue ?? "")
|
||||
}
|
||||
)
|
||||
_fieldOverrides = State(initialValue: initialOverrides)
|
||||
let initialPolicy = duplicatePolicies.first(where: { $0.value == draft.workbenchRow?.duplicatePolicy })?.value
|
||||
?? duplicatePolicies.first?.value
|
||||
?? ""
|
||||
_duplicatePolicy = State(initialValue: initialPolicy)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(draft.proposal.rowLabel)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(draft.proposal.confidencePercent)% confidence · \(draft.proposal.status.replacingOccurrences(of: "_", with: " ").capitalized)")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if let workbenchRow = draft.workbenchRow {
|
||||
remediationDiagnostics(workbenchRow)
|
||||
duplicatePolicyPicker(workbenchRow)
|
||||
}
|
||||
|
||||
if draft.fields.isEmpty {
|
||||
Text("No canonical fields were returned for this proposal.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Field Corrections")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
ForEach(draft.fields, id: \.self) { field in
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text(field.replacingOccurrences(of: "_", with: " ").uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
TextField(field, text: Binding(
|
||||
get: { fieldOverrides[field] ?? "" },
|
||||
set: { fieldOverrides[field] = $0 }
|
||||
))
|
||||
.textFieldStyle(.plain)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("Review Notes")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 90)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 12).fill(VelocityTheme.surface))
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItemGroup(placement: .confirmationAction) {
|
||||
Button("Needs Info") {
|
||||
submit("needs_more_info")
|
||||
}
|
||||
Button("Approve Corrected") {
|
||||
submit("approved")
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
|
||||
private func remediationDiagnostics(_ row: VelocityImportWorkbenchRowDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Validation and Duplicate Preview")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if row.validation.isEmpty && row.duplicateCandidates.isEmpty && row.fieldDiffs.filter(\.changed).isEmpty {
|
||||
Text("No validation issues, duplicate candidates, or canonical row diffs were returned for this proposal.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
ForEach(row.validation) { issue in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: issue.severity == "error" ? "xmark.octagon" : "exclamationmark.triangle")
|
||||
.foregroundStyle(issue.severity == "error" ? VelocityTheme.danger : VelocityTheme.warning)
|
||||
Text("\(issue.field): \(issue.message)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(row.duplicateCandidates.prefix(3)) { candidate in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(candidate.fullName) · \(candidate.matchReason) match · \(candidate.matchScore)%")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
Text([candidate.primaryPhone, candidate.primaryEmail].compactMap { $0?.trimmedNonEmpty }.joined(separator: " · "))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(row.fieldDiffs.filter(\.changed).prefix(6)) { diff in
|
||||
Text("\(diff.field): \(diff.existing ?? "-") → \(diff.proposed ?? "-")")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func duplicatePolicyPicker(_ row: VelocityImportWorkbenchRowDTO) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Duplicate Merge Policy")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Picker("Duplicate policy", selection: $duplicatePolicy) {
|
||||
ForEach(duplicatePolicyOptions()) { policy in
|
||||
Text(policy.label).tag(policy.value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if let guidance = duplicatePolicyOptions().first(where: { $0.value == duplicatePolicy })?.description?.trimmedNonEmpty {
|
||||
Text(guidance)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
if row.duplicateCandidates.isEmpty {
|
||||
Text("No duplicate candidates were returned by the backend for this row.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else if let candidate = row.duplicateCandidates.first {
|
||||
Text("Strongest candidate: \(candidate.fullName) · \(candidate.matchReason) · \(candidate.matchScore)%")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
|
||||
}
|
||||
|
||||
private func duplicatePolicyOptions() -> [VelocityVocabularyOptionDTO] {
|
||||
guard !duplicatePolicies.contains(where: { $0.value == duplicatePolicy }),
|
||||
let current = duplicatePolicy.trimmedNonEmpty
|
||||
else {
|
||||
return duplicatePolicies
|
||||
}
|
||||
return [
|
||||
VelocityVocabularyOptionDTO(
|
||||
value: current,
|
||||
label: current.replacingOccurrences(of: "_", with: " ").capitalized,
|
||||
description: "Current backend value",
|
||||
icon: nil
|
||||
)
|
||||
] + duplicatePolicies
|
||||
}
|
||||
|
||||
private func submit(_ decision: String) {
|
||||
let cleanedOverrides = fieldOverrides.compactMapValues { value in
|
||||
value.trimmedNonEmpty
|
||||
}
|
||||
let defaultNote = decision == "needs_more_info"
|
||||
? "Marked needs more information from iPad Imports remediation."
|
||||
: "Approved with iPad Imports field corrections."
|
||||
guard let selectedPolicy = duplicatePolicy.trimmedNonEmpty else {
|
||||
return
|
||||
}
|
||||
onSubmit(decision, notes.trimmedNonEmpty ?? defaultNote, cleanedOverrides, selectedPolicy)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImportsView()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import CoreLocation
|
||||
import CoreMotion
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - ARSunOverlayView
|
||||
|
||||
@@ -22,6 +23,13 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
|
||||
let config = ARWorldTrackingConfiguration()
|
||||
config.worldAlignment = .gravityAndHeading // north = -Z axis
|
||||
config.planeDetection = [.horizontal, .vertical]
|
||||
if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
|
||||
config.sceneReconstruction = .mesh
|
||||
}
|
||||
if ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) {
|
||||
config.frameSemantics.insert(.sceneDepth)
|
||||
}
|
||||
view.session.run(config)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
@@ -46,7 +54,9 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
// Scene node containers (replaced on each rebuild)
|
||||
private var arcRootNode = SCNNode()
|
||||
private var currentSunNode = SCNNode()
|
||||
private var measurementRootNode = SCNNode()
|
||||
private var isSceneBuilt = false
|
||||
private var pendingMeasurementPoint: SCNVector3?
|
||||
|
||||
// Fallback timer for CoreMotion-only mode
|
||||
private var fallbackTimer: Timer?
|
||||
@@ -61,6 +71,10 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
self.sceneView = sceneView
|
||||
sceneView.scene.rootNode.addChildNode(arcRootNode)
|
||||
sceneView.scene.rootNode.addChildNode(currentSunNode)
|
||||
sceneView.scene.rootNode.addChildNode(measurementRootNode)
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(handleMeasurementTap(_:)))
|
||||
sceneView.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
@@ -107,6 +121,59 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Measurement
|
||||
|
||||
@objc private func handleMeasurementTap(_ recognizer: UITapGestureRecognizer) {
|
||||
guard let sceneView else { return }
|
||||
let point = recognizer.location(in: sceneView)
|
||||
guard let query = sceneView.raycastQuery(
|
||||
from: point,
|
||||
allowing: .estimatedPlane,
|
||||
alignment: .any
|
||||
),
|
||||
let result = sceneView.session.raycast(query).first else { return }
|
||||
let transform = result.worldTransform
|
||||
let worldPoint = SCNVector3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z)
|
||||
addMeasurementPoint(worldPoint)
|
||||
}
|
||||
|
||||
private func addMeasurementPoint(_ point: SCNVector3) {
|
||||
measurementRootNode.addChildNode(makeMeasurementMarker(at: point))
|
||||
|
||||
if let start = pendingMeasurementPoint {
|
||||
let distance = start.distance(to: point)
|
||||
measurementRootNode.addChildNode(makeLineNode(through: [start, point], color: UIColor.white.withAlphaComponent(0.82)))
|
||||
|
||||
let midpoint = SCNVector3(
|
||||
(start.x + point.x) / 2,
|
||||
(start.y + point.y) / 2 + 0.045,
|
||||
(start.z + point.z) / 2
|
||||
)
|
||||
let label = makeTextNode(
|
||||
text: "\(String(format: "%.2f m", Double(distance))) \(String(format: "%.1f ft", Double(distance * 3.28084)))",
|
||||
color: .white,
|
||||
fontSize: 0.052
|
||||
)
|
||||
label.position = midpoint
|
||||
measurementRootNode.addChildNode(label)
|
||||
pendingMeasurementPoint = nil
|
||||
UIImpactFeedbackGenerator(style: .rigid).impactOccurred()
|
||||
} else {
|
||||
pendingMeasurementPoint = point
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMeasurementMarker(at position: SCNVector3) -> SCNNode {
|
||||
let sphere = SCNSphere(radius: 0.018)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.white
|
||||
sphere.firstMaterial?.emission.contents = UIColor.systemBlue.withAlphaComponent(0.65)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let node = SCNNode(geometry: sphere)
|
||||
node.position = position
|
||||
return node
|
||||
}
|
||||
|
||||
// MARK: - Scene Building
|
||||
|
||||
private func buildScene() {
|
||||
@@ -274,3 +341,12 @@ struct ARSunOverlayView: UIViewRepresentable {
|
||||
private extension Double {
|
||||
var radians: Double { self * .pi / 180.0 }
|
||||
}
|
||||
|
||||
private extension SCNVector3 {
|
||||
func distance(to other: SCNVector3) -> Float {
|
||||
let dx = other.x - x
|
||||
let dy = other.y - y
|
||||
let dz = other.z - z
|
||||
return sqrtf(dx * dx + dy * dy + dz * dz)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,7 @@ enum InventoryModeAvailability {
|
||||
}
|
||||
|
||||
static func productionVisibleModes(hasDollhouseAsset: Bool) -> [InventoryStore.Mode] {
|
||||
var modes: [InventoryStore.Mode] = [.sunseeker, .dreamWeaver]
|
||||
if hasDollhouseAsset {
|
||||
modes.append(.dollhouse)
|
||||
}
|
||||
return modes
|
||||
[.sunseeker, .dreamWeaver]
|
||||
}
|
||||
|
||||
static func sanitizedProductionSelection(
|
||||
@@ -28,8 +24,9 @@ enum InventoryModeAvailability {
|
||||
}
|
||||
|
||||
static func modeSummaryText(hasDollhouseAsset: Bool) -> String {
|
||||
productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset)
|
||||
.map(\.rawValue)
|
||||
.joined(separator: " · ")
|
||||
let base = productionVisibleModes(hasDollhouseAsset: hasDollhouseAsset).map(\.rawValue)
|
||||
return hasDollhouseAsset
|
||||
? (base + ["Map-to-Dollhouse"]).joined(separator: " · ")
|
||||
: base.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,252 +1,18 @@
|
||||
import CoreLocation
|
||||
import SceneKit
|
||||
import SwiftUI
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
|
||||
/// A non-AR 3D view for testing the Sunseeker path logic on the iOS Simulator.
|
||||
/// Uses a synthetic camera, fake location, and mock heading instead of ARKit.
|
||||
struct SimulatorSunOverlayView: UIViewRepresentable {
|
||||
struct SimulatorSunOverlayView: View {
|
||||
@Binding var sunNodesReady: Bool
|
||||
|
||||
// Fake location (e.g. San Francisco)
|
||||
private let mockLocation = CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194)
|
||||
private let mockHeading: Double = 0 // North
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(sunNodesReady: $sunNodesReady, mockLocation: mockLocation, mockHeading: mockHeading)
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> SCNView {
|
||||
let view = SCNView(frame: .zero)
|
||||
view.scene = SCNScene()
|
||||
view.allowsCameraControl = true // Swipe around the 3D space
|
||||
view.autoenablesDefaultLighting = true
|
||||
view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)
|
||||
view.isPlaying = true // Force render loop
|
||||
view.showsStatistics = true // Prove it's rendering
|
||||
|
||||
// Setup synthetic camera
|
||||
let cameraNode = SCNNode()
|
||||
cameraNode.camera = SCNCamera()
|
||||
cameraNode.camera?.zFar = 100
|
||||
cameraNode.position = SCNVector3(x: 0, y: 0, z: 0) // Centered
|
||||
view.scene?.rootNode.addChildNode(cameraNode)
|
||||
|
||||
context.coordinator.attach(to: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: SCNView, context: Context) {}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
@Binding private var sunNodesReady: Bool
|
||||
|
||||
private let mockLocation: CLLocationCoordinate2D
|
||||
private let mockHeading: Double
|
||||
|
||||
private var arcRootNode = SCNNode()
|
||||
private var currentSunNode = SCNNode()
|
||||
|
||||
private var updateTimer: Timer?
|
||||
|
||||
init(sunNodesReady: Binding<Bool>, mockLocation: CLLocationCoordinate2D, mockHeading: Double) {
|
||||
_sunNodesReady = sunNodesReady
|
||||
self.mockLocation = mockLocation
|
||||
self.mockHeading = mockHeading
|
||||
super.init()
|
||||
}
|
||||
|
||||
func attach(to view: SCNView) {
|
||||
view.scene?.rootNode.addChildNode(arcRootNode)
|
||||
view.scene?.rootNode.addChildNode(currentSunNode)
|
||||
buildScene()
|
||||
startRealTimeTick()
|
||||
}
|
||||
|
||||
deinit {
|
||||
updateTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func startRealTimeTick() {
|
||||
// Update current sun position every second
|
||||
updateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
let cur = SunMath.calculateSunPosition(date: Date(), coordinate: self.mockLocation)
|
||||
// Need to remove previous child as we are completely replacing it
|
||||
self.currentSunNode.childNodes.forEach { $0.removeFromParentNode() }
|
||||
|
||||
let radius: Float = 1.8
|
||||
let orb = SCNSphere(radius: 0.055)
|
||||
orb.firstMaterial?.diffuse.contents = UIColor.systemOrange
|
||||
orb.firstMaterial?.emission.contents = UIColor.systemYellow
|
||||
orb.firstMaterial?.lightingModel = .constant
|
||||
let orbNode = SCNNode(geometry: orb)
|
||||
orbNode.position = self.worldPosition(for: cur, radius: radius)
|
||||
|
||||
let pulse = CABasicAnimation(keyPath: "scale")
|
||||
pulse.fromValue = SCNVector3(1, 1, 1)
|
||||
pulse.toValue = SCNVector3(1.3, 1.3, 1.3)
|
||||
pulse.duration = 1.2
|
||||
pulse.autoreverses = true
|
||||
pulse.repeatCount = .infinity
|
||||
orbNode.addAnimation(pulse, forKey: "pulse")
|
||||
|
||||
let label = self.makeTextNode(text: "Now", color: .systemYellow, fontSize: 0.05)
|
||||
label.position = SCNVector3(0, 0.09, 0)
|
||||
orbNode.addChildNode(label)
|
||||
|
||||
self.currentSunNode.addChildNode(orbNode)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildScene() {
|
||||
let arc = SunMath.sunPathArc(for: Date(), coordinate: mockLocation)
|
||||
let riseSet = SunMath.sunRiseSet(for: Date(), coordinate: mockLocation)
|
||||
let radius: Float = 1.8
|
||||
var positions: [SCNVector3] = []
|
||||
|
||||
// Hourly blocks
|
||||
for (date, pos) in arc {
|
||||
guard pos.elevation > -5 else { continue }
|
||||
let worldPos = worldPosition(for: pos, radius: radius)
|
||||
positions.append(worldPos)
|
||||
|
||||
let sphere = SCNSphere(radius: 0.018)
|
||||
sphere.firstMaterial?.diffuse.contents = UIColor.systemYellow.withAlphaComponent(0.85)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let markerNode = SCNNode(geometry: sphere)
|
||||
markerNode.position = worldPos
|
||||
arcRootNode.addChildNode(markerNode)
|
||||
|
||||
let calendar = Calendar.current
|
||||
let hour = calendar.component(.hour, from: date)
|
||||
if hour % 2 == 0 {
|
||||
let labelNode = makeTextNode(text: hourLabel(from: date), color: .white, fontSize: 0.04)
|
||||
labelNode.position = SCNVector3(worldPos.x, worldPos.y + 0.06, worldPos.z)
|
||||
arcRootNode.addChildNode(labelNode)
|
||||
}
|
||||
}
|
||||
|
||||
if positions.count >= 2 {
|
||||
let lineNode = makeLineNode(through: positions, color: UIColor.systemYellow.withAlphaComponent(0.55))
|
||||
arcRootNode.addChildNode(lineNode)
|
||||
}
|
||||
|
||||
if let riseDate = riseSet.rise {
|
||||
let risePos = SunMath.calculateSunPosition(date: riseDate, coordinate: mockLocation)
|
||||
let wPos = worldPosition(for: risePos, radius: radius)
|
||||
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemOrange, label: "Sunrise \(hourLabel(from: riseDate))"))
|
||||
}
|
||||
|
||||
if let setDate = riseSet.set {
|
||||
let setPos = SunMath.calculateSunPosition(date: setDate, coordinate: mockLocation)
|
||||
let wPos = worldPosition(for: setPos, radius: radius)
|
||||
arcRootNode.addChildNode(makeSpecialMarker(at: wPos, color: .systemRed, label: "Sunset \(hourLabel(from: setDate))"))
|
||||
}
|
||||
|
||||
// Generate current sun node synchronously for first frame
|
||||
updateTimer?.fire()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.sunNodesReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Math equivalent from SunseekerViewModel
|
||||
|
||||
private func worldPosition(for sun: SunPosition, radius: Float) -> SCNVector3 {
|
||||
let elev = Float(sun.elevation * .pi / 180.0)
|
||||
let az = Float(sun.azimuth * .pi / 180.0)
|
||||
let x = radius * cos(elev) * sin(az)
|
||||
let y = radius * sin(elev)
|
||||
let z = -radius * cos(elev) * cos(az)
|
||||
return SCNVector3(x, y, z)
|
||||
}
|
||||
|
||||
// MARK: SceneKit Factories
|
||||
|
||||
private func makeSpecialMarker(at pos: SCNVector3, color: UIColor, label: String) -> SCNNode {
|
||||
let root = SCNNode()
|
||||
let sphere = SCNSphere(radius: 0.035)
|
||||
sphere.firstMaterial?.diffuse.contents = color
|
||||
sphere.firstMaterial?.emission.contents = color.withAlphaComponent(0.5)
|
||||
sphere.firstMaterial?.lightingModel = .constant
|
||||
let markerNode = SCNNode(geometry: sphere)
|
||||
markerNode.position = pos
|
||||
root.addChildNode(markerNode)
|
||||
|
||||
let labelNode = makeTextNode(text: label, color: .white, fontSize: 0.04)
|
||||
labelNode.position = SCNVector3(pos.x, pos.y + 0.07, pos.z)
|
||||
root.addChildNode(labelNode)
|
||||
return root
|
||||
}
|
||||
|
||||
private func makeTextNode(text: String, color: UIColor, fontSize: CGFloat) -> SCNNode {
|
||||
// SCNText is buggy in Simulator. Render text to a UIImage instead.
|
||||
let font = UIFont.systemFont(ofSize: 40, weight: .bold)
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: color
|
||||
]
|
||||
let size = (text as NSString).size(withAttributes: attributes)
|
||||
|
||||
// Add some padding
|
||||
let paddedSize = CGSize(width: size.width + 10, height: size.height + 10)
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: paddedSize)
|
||||
let image = renderer.image { context in
|
||||
(text as NSString).draw(
|
||||
in: CGRect(x: 5, y: 5, width: size.width, height: size.height),
|
||||
withAttributes: attributes
|
||||
)
|
||||
}
|
||||
|
||||
// Map the image onto an SCNPlane
|
||||
// A 100x50 image becomes a 0.1 x 0.05 meter plane
|
||||
let plane = SCNPlane(width: paddedSize.width / 1000.0, height: paddedSize.height / 1000.0)
|
||||
plane.firstMaterial?.diffuse.contents = image
|
||||
plane.firstMaterial?.isDoubleSided = true
|
||||
plane.firstMaterial?.lightingModel = .constant
|
||||
|
||||
let textNode = SCNNode(geometry: plane)
|
||||
// Statically scale the plane up so it is readable next to the spheres
|
||||
textNode.scale = SCNVector3(1.5, 1.5, 1.5)
|
||||
|
||||
let constraint = SCNBillboardConstraint()
|
||||
constraint.freeAxes = .all
|
||||
textNode.constraints = [constraint]
|
||||
|
||||
return textNode
|
||||
}
|
||||
|
||||
private func makeLineNode(through positions: [SCNVector3], color: UIColor) -> SCNNode {
|
||||
guard positions.count >= 2 else { return SCNNode() }
|
||||
|
||||
let vertices: [SCNVector3] = positions
|
||||
var indices: [Int32] = []
|
||||
for i in 0..<(vertices.count - 1) {
|
||||
indices.append(Int32(i))
|
||||
indices.append(Int32(i + 1))
|
||||
}
|
||||
|
||||
let vertexSource = SCNGeometrySource(vertices: vertices)
|
||||
let element = SCNGeometryElement(
|
||||
indices: indices,
|
||||
primitiveType: .line
|
||||
)
|
||||
let geometry = SCNGeometry(sources: [vertexSource], elements: [element])
|
||||
geometry.firstMaterial?.diffuse.contents = color
|
||||
geometry.firstMaterial?.lightingModel = .constant
|
||||
return SCNNode(geometry: geometry)
|
||||
}
|
||||
|
||||
private func hourLabel(from date: Date) -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.dateFormat = "ha"
|
||||
fmt.amSymbol = "am"
|
||||
fmt.pmSymbol = "pm"
|
||||
return fmt.string(from: date)
|
||||
var body: some View {
|
||||
ContentUnavailableView(
|
||||
"Sunseeker Unavailable",
|
||||
systemImage: "arkit",
|
||||
description: Text("Run on a physical iPad to use live location, heading, and ARKit camera data.")
|
||||
)
|
||||
.onAppear {
|
||||
sunNodesReady = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,15 @@ import Foundation
|
||||
|
||||
enum OracleModeAvailability {
|
||||
static let productionVisibleModes: [OracleMode] = [
|
||||
.pipeline,
|
||||
.deals,
|
||||
.accountTimeline,
|
||||
.calendarTasks,
|
||||
]
|
||||
|
||||
static let hiddenModesUntilBackendSupport: [OracleMode] = [
|
||||
.teamPerformance,
|
||||
.leadMap,
|
||||
]
|
||||
|
||||
static let hiddenModesUntilBackendSupport: [OracleMode] = [
|
||||
]
|
||||
|
||||
static func sanitizedProductionSelection(_ candidate: OracleMode) -> OracleMode {
|
||||
productionVisibleModes.contains(candidate) ? candidate : .pipeline
|
||||
productionVisibleModes.contains(candidate) ? candidate : .accountTimeline
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Combine
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
import Speech
|
||||
import SwiftUI
|
||||
|
||||
enum OracleMode: String, CaseIterable {
|
||||
@@ -27,9 +29,180 @@ enum OracleMode: String, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
struct OracleConciergeSheet: View {
|
||||
@State private var transcript = ""
|
||||
@State private var resultText = ""
|
||||
@State private var errorText: String?
|
||||
@State private var isRecording = false
|
||||
@State private var isQuerying = false
|
||||
@State private var speechAuthorized = false
|
||||
@State private var audioEngine = AVAudioEngine()
|
||||
@State private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
|
||||
@State private var recognitionTask: SFSpeechRecognitionTask?
|
||||
private let recognizer = SFSpeechRecognizer()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Oracle Concierge")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Push to talk. Live query routes to `/api/oracle/query`.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
isRecording ? stopRecordingAndQuery() : startRecording()
|
||||
} label: {
|
||||
Image(systemName: isRecording ? "stop.fill" : "mic.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 54, height: 54)
|
||||
.background(Circle().fill(isRecording ? VelocityTheme.danger : VelocityTheme.accent))
|
||||
.shadow(color: (isRecording ? VelocityTheme.danger : VelocityTheme.accent).opacity(0.45), radius: isRecording ? 18 : 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!speechAuthorized || isQuerying)
|
||||
}
|
||||
|
||||
if !transcript.isEmpty {
|
||||
Text(transcript)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.surface))
|
||||
.transition(.opacity.combined(with: .scale))
|
||||
}
|
||||
|
||||
if isQuerying {
|
||||
ProgressView("Asking Oracle...")
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else if !resultText.isEmpty {
|
||||
Text(resultText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.10)))
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
if let errorText {
|
||||
Text(errorText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
|
||||
OracleView()
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await requestSpeechAuthorization() }
|
||||
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: isRecording)
|
||||
.animation(.interactiveSpring(response: 0.42, dampingFraction: 0.86), value: resultText)
|
||||
}
|
||||
|
||||
private func requestSpeechAuthorization() async {
|
||||
let status = await withCheckedContinuation { continuation in
|
||||
SFSpeechRecognizer.requestAuthorization { continuation.resume(returning: $0) }
|
||||
}
|
||||
await MainActor.run {
|
||||
speechAuthorized = status == .authorized
|
||||
if !speechAuthorized {
|
||||
errorText = "Speech recognition permission is required for voice Oracle."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startRecording() {
|
||||
recognitionTask?.cancel()
|
||||
recognitionTask = nil
|
||||
transcript = ""
|
||||
resultText = ""
|
||||
errorText = nil
|
||||
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.record, mode: .measurement, options: .duckOthers)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
recognitionRequest = request
|
||||
|
||||
let inputNode = audioEngine.inputNode
|
||||
let format = inputNode.outputFormat(forBus: 0)
|
||||
inputNode.removeTap(onBus: 0)
|
||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in
|
||||
request.append(buffer)
|
||||
}
|
||||
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) {
|
||||
isRecording = true
|
||||
}
|
||||
|
||||
recognitionTask = recognizer?.recognitionTask(with: request) { result, error in
|
||||
Task { @MainActor in
|
||||
if let result {
|
||||
transcript = result.bestTranscription.formattedString
|
||||
}
|
||||
if let error {
|
||||
errorText = error.localizedDescription
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
stopRecording()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopRecordingAndQuery() {
|
||||
stopRecording()
|
||||
let prompt = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !prompt.isEmpty else { return }
|
||||
Task { await queryOracle(prompt) }
|
||||
}
|
||||
|
||||
private func stopRecording() {
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
recognitionRequest?.endAudio()
|
||||
recognitionTask?.cancel()
|
||||
recognitionRequest = nil
|
||||
recognitionTask = nil
|
||||
withAnimation(.interactiveSpring(response: 0.35, dampingFraction: 0.82)) {
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func queryOracle(_ prompt: String) async {
|
||||
isQuerying = true
|
||||
errorText = nil
|
||||
do {
|
||||
let response = try await VelocityAPIClient.shared.queryOracle(prompt: prompt)
|
||||
resultText = response.displaySummary
|
||||
} catch {
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
isQuerying = false
|
||||
}
|
||||
}
|
||||
|
||||
struct OracleView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.pipeline)
|
||||
@State private var selectedMode: OracleMode = OracleModeAvailability.sanitizedProductionSelection(.accountTimeline)
|
||||
@State private var selectedClient360: VelocityClient360DTO?
|
||||
@State private var selectedClient360PersonID: String?
|
||||
@State private var isClient360Loading = false
|
||||
@@ -39,7 +212,11 @@ struct OracleView: View {
|
||||
@State private var activeTaskMutationID: String?
|
||||
@State private var activeLeadMutationID: String?
|
||||
@State private var activeOpportunityMutationID: String?
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
@State private var editingOpportunity: VelocityOpportunityDTO?
|
||||
@State private var teamPerformance: VelocityOracleTeamPerformanceDTO?
|
||||
@State private var leadMap: VelocityOracleLeadMapDTO?
|
||||
@State private var isOracleInsightLoading = false
|
||||
@State private var oracleInsightError: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -77,17 +254,39 @@ struct OracleView: View {
|
||||
}
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.task {
|
||||
await store.refresh()
|
||||
await loadOracleInsightData(for: selectedMode)
|
||||
}
|
||||
.refreshable {
|
||||
await store.refresh()
|
||||
await loadOracleInsightData(for: selectedMode)
|
||||
}
|
||||
.onAppear {
|
||||
selectedMode = OracleModeAvailability.sanitizedProductionSelection(selectedMode)
|
||||
}
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
.onChange(of: selectedMode) { _, mode in
|
||||
Task { await loadOracleInsightData(for: mode) }
|
||||
}
|
||||
.sheet(isPresented: client360PresentationBinding) {
|
||||
client360Sheet
|
||||
}
|
||||
.sheet(item: $editingOpportunity) { opportunity in
|
||||
OpportunityEditSheet(
|
||||
opportunity: opportunity,
|
||||
stages: store.crmVocabularies.opportunityStages
|
||||
) { stage, value, probability, expectedCloseDate, nextAction, notes in
|
||||
saveOpportunityEdits(
|
||||
opportunity,
|
||||
stage: stage,
|
||||
value: value,
|
||||
probability: probability,
|
||||
expectedCloseDate: expectedCloseDate,
|
||||
nextAction: nextAction,
|
||||
notes: notes
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -96,7 +295,7 @@ struct OracleView: View {
|
||||
Text("Oracle")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live sales intelligence assembled from canonical CRM, communication events, and calendar data.")
|
||||
Text("Ambient sales intelligence assembled from canonical CRM, communication events, and calendar data.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -148,11 +347,10 @@ struct OracleView: View {
|
||||
timelineCanvas
|
||||
case .calendarTasks:
|
||||
calendarCanvas
|
||||
case .teamPerformance, .leadMap:
|
||||
unavailableCanvas(
|
||||
title: "Oracle mode unavailable",
|
||||
message: "This Oracle mode is intentionally hidden from the production iPad scope until a real backend contract exists."
|
||||
)
|
||||
case .teamPerformance:
|
||||
teamPerformanceCanvas
|
||||
case .leadMap:
|
||||
leadMapCanvas
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,9 +415,15 @@ struct OracleView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
Text("\(lead.displayIntentScore)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
if store.isShowroomModeEnabled {
|
||||
Image(systemName: "eye.slash")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.warning)
|
||||
} else {
|
||||
Text("\(lead.displayIntentScore)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
|
||||
if activeLeadMutationID == lead.leadId {
|
||||
ProgressView()
|
||||
@@ -382,6 +586,9 @@ struct OracleView: View {
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
if store.pendingSyncCalendarEventIDs.contains(event.calendarEventId) || event.calendarEventId.hasPrefix("local-") {
|
||||
pendingSyncBadge
|
||||
}
|
||||
Text(event.status.capitalized)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(color(for: event.status))
|
||||
@@ -424,16 +631,21 @@ struct OracleView: View {
|
||||
Text("\(task.ownerLabel) · \(task.clientPhone ?? "No phone")")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(taskNote(task))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(2)
|
||||
if !store.isShowroomModeEnabled {
|
||||
Text(taskNote(task))
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
if store.pendingSyncTaskIDs.contains(task.reminderId) {
|
||||
pendingSyncBadge
|
||||
}
|
||||
Text(task.priorityLabel)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(taskColor(for: task.priority))
|
||||
@@ -461,7 +673,7 @@ struct OracleView: View {
|
||||
Text("Mobile Oracle Scope")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("This production iPad build shows only live-backed Oracle views. Team Performance and Lead Map stay hidden until the backend exposes real mobile contracts.")
|
||||
Text("This production iPad build shows live-backed Oracle views only. Team Performance and Lead Map now read dedicated mobile Oracle contracts instead of synthetic local projections.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -470,6 +682,13 @@ struct OracleView: View {
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
|
||||
private var pendingSyncBadge: some View {
|
||||
Circle()
|
||||
.fill(VelocityTheme.warning)
|
||||
.frame(width: 8, height: 8)
|
||||
.shadow(color: VelocityTheme.warning.opacity(0.75), radius: 6)
|
||||
}
|
||||
|
||||
private func unavailableCanvas(title: String, message: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
summaryCard(title: title, body: message)
|
||||
@@ -485,18 +704,16 @@ struct OracleView: View {
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(18)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func emptyCard(_ message: String) -> some View {
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
.padding(18)
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private func color(for status: String) -> Color {
|
||||
@@ -533,74 +750,198 @@ struct OracleView: View {
|
||||
return formatter.string(from: start)
|
||||
}
|
||||
|
||||
private var teamPerformanceCanvas: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
productionScopeNote
|
||||
|
||||
summaryCard(
|
||||
title: "Team Performance",
|
||||
body: "Broker performance is read from canonical users, leads, opportunities, reminders, and interaction activity through `/api/oracle/v1/mobile/team-performance`."
|
||||
)
|
||||
|
||||
if isOracleInsightLoading && teamPerformance == nil {
|
||||
progressCard("Loading team performance...")
|
||||
} else if let oracleInsightError {
|
||||
errorBanner(oracleInsightError)
|
||||
} else if let teamPerformance, !teamPerformance.performers.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
metricPill("Members", "\(teamPerformance.summary.teamMembers)")
|
||||
metricPill("Assigned", "\(teamPerformance.summary.assignedLeads)")
|
||||
metricPill("Open Tasks", "\(teamPerformance.summary.openTasks)")
|
||||
metricPill("Pipeline", moneyLabel(teamPerformance.summary.pipelineValue))
|
||||
}
|
||||
|
||||
ForEach(teamPerformance.performers) { performer in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(performer.name)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(performer.email ?? "No email")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
Text("\(Int(performer.conversionRate.rounded()))%")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
metricPill("Leads", "\(performer.assignedLeads)")
|
||||
metricPill("Deals", "\(performer.activeOpportunities)")
|
||||
metricPill("Tasks", "\(performer.openTasks)")
|
||||
metricPill("Won", moneyLabel(performer.closedWonValue))
|
||||
}
|
||||
Text("Last activity \(performer.lastActivityAt ?? "not recorded")")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.glassCard(cornerRadius: 16)
|
||||
}
|
||||
} else {
|
||||
emptyCard("No canonical team performance rows are available for this tenant yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var leadMapCanvas: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
productionScopeNote
|
||||
|
||||
summaryCard(
|
||||
title: "Lead Map",
|
||||
body: "Lead geography is read from the Oracle lead geo rollup when present, with a canonical CRM city rollup fallback when precise coordinates are not stored."
|
||||
)
|
||||
|
||||
if isOracleInsightLoading && leadMap == nil {
|
||||
progressCard("Loading lead map...")
|
||||
} else if let oracleInsightError {
|
||||
errorBanner(oracleInsightError)
|
||||
} else if let leadMap, !leadMap.points.isEmpty {
|
||||
HStack(spacing: 12) {
|
||||
metricPill("Locations", "\(leadMap.summary.locations)")
|
||||
metricPill("Leads", "\(leadMap.summary.leadCount)")
|
||||
metricPill("Hot Leads", "\(leadMap.summary.hotLeadCount)")
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(leadMap.points) { point in
|
||||
HStack(spacing: 12) {
|
||||
Circle()
|
||||
.fill(point.hotLeadCount > 0 ? VelocityTheme.danger : VelocityTheme.accent)
|
||||
.frame(width: max(12, min(34, CGFloat(point.leadCount + 10))), height: max(12, min(34, CGFloat(point.leadCount + 10))))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(point.label)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(store.isShowroomModeEnabled ? "\(point.leadCount) leads · buyer-safe" : "\(point.leadCount) leads · \(point.hotLeadCount) hot · QD \(Int((point.avgQdScore * 100).rounded()))")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
Spacer()
|
||||
if let latitude = point.latitude, let longitude = point.longitude {
|
||||
Text(String(format: "%.3f, %.3f", latitude, longitude))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.glassCard(cornerRadius: 16)
|
||||
} else {
|
||||
emptyCard("No canonical location or city-level CRM lead rollups are available yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func progressCard(_ message: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent))
|
||||
Text(message)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.vertical, 14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func metricPill(_ title: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title.uppercased())
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.tracking(0.8)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func moneyLabel(_ value: Double) -> String {
|
||||
if value >= 1_000_000 {
|
||||
return String(format: "AED %.1fM", value / 1_000_000)
|
||||
}
|
||||
if value >= 1_000 {
|
||||
return String(format: "AED %.0fK", value / 1_000)
|
||||
}
|
||||
return String(format: "AED %.0f", value)
|
||||
}
|
||||
|
||||
private func loadOracleInsightData(for mode: OracleMode, silent: Bool = false) async {
|
||||
guard mode == .teamPerformance || mode == .leadMap else {
|
||||
return
|
||||
}
|
||||
if !silent {
|
||||
await MainActor.run {
|
||||
isOracleInsightLoading = true
|
||||
oracleInsightError = nil
|
||||
}
|
||||
}
|
||||
do {
|
||||
switch mode {
|
||||
case .teamPerformance:
|
||||
let response = try await VelocityAPIClient.shared.fetchOracleTeamPerformance()
|
||||
await MainActor.run { teamPerformance = response }
|
||||
case .leadMap:
|
||||
let response = try await VelocityAPIClient.shared.fetchOracleLeadMap()
|
||||
await MainActor.run { leadMap = response }
|
||||
default:
|
||||
break
|
||||
}
|
||||
await MainActor.run {
|
||||
isOracleInsightLoading = false
|
||||
oracleInsightError = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isOracleInsightLoading = false
|
||||
oracleInsightError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func opportunityActionsMenu(_ opportunity: VelocityOpportunityDTO) -> some View {
|
||||
Menu {
|
||||
Menu("Move Stage") {
|
||||
ForEach(canonicalOpportunityStages.filter { $0 != opportunity.stage.lowercased() }, id: \.self) { stage in
|
||||
Button {
|
||||
mutateOpportunity(
|
||||
opportunity,
|
||||
stage: stage,
|
||||
probability: nil,
|
||||
nextAction: opportunity.nextAction,
|
||||
notes: "Moved from the iPad Oracle deal workspace."
|
||||
)
|
||||
} label: {
|
||||
Text(stageLabel(stage))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Menu("Set Probability") {
|
||||
ForEach([25, 50, 75, 90], id: \.self) { probability in
|
||||
Button {
|
||||
mutateOpportunity(
|
||||
opportunity,
|
||||
stage: nil,
|
||||
probability: probability,
|
||||
nextAction: opportunity.nextAction,
|
||||
notes: "Probability updated from the iPad Oracle deal workspace."
|
||||
)
|
||||
} label: {
|
||||
Text("\(probability)%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
mutateOpportunity(
|
||||
opportunity,
|
||||
stage: nil,
|
||||
probability: nil,
|
||||
nextAction: "Schedule commercial follow-up",
|
||||
notes: "Next action updated from the iPad Oracle deal workspace."
|
||||
)
|
||||
editingOpportunity = opportunity
|
||||
} label: {
|
||||
Label("Set Follow-Up Action", systemImage: "phone.arrow.up.right")
|
||||
}
|
||||
|
||||
Button {
|
||||
mutateOpportunity(
|
||||
opportunity,
|
||||
stage: "closed_won",
|
||||
probability: 100,
|
||||
nextAction: "Complete booking documentation",
|
||||
notes: "Marked closed won from the iPad Oracle deal workspace."
|
||||
)
|
||||
} label: {
|
||||
Label("Close Won", systemImage: "checkmark.seal")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
mutateOpportunity(
|
||||
opportunity,
|
||||
stage: "closed_lost",
|
||||
probability: 0,
|
||||
nextAction: "Capture loss reason",
|
||||
notes: "Marked closed lost from the iPad Oracle deal workspace."
|
||||
)
|
||||
} label: {
|
||||
Label("Close Lost", systemImage: "xmark.seal")
|
||||
Label("Edit Deal", systemImage: "square.and.pencil")
|
||||
}
|
||||
} label: {
|
||||
menuIcon("ellipsis.circle")
|
||||
@@ -669,16 +1010,16 @@ struct OracleView: View {
|
||||
currentStatus: String
|
||||
) -> some View {
|
||||
Menu {
|
||||
ForEach(canonicalLeadStages.filter { $0 != currentStatus.lowercased() }, id: \.self) { status in
|
||||
ForEach(leadStageOptions(currentStatus: currentStatus)) { stage in
|
||||
Button {
|
||||
mutateLeadStage(
|
||||
leadId: leadId,
|
||||
personId: personId,
|
||||
status: status,
|
||||
status: stage.value,
|
||||
notes: "Moved from the iPad Oracle pipeline."
|
||||
)
|
||||
} label: {
|
||||
Text(stageLabel(status))
|
||||
Text(stage.label)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
@@ -773,6 +1114,47 @@ struct OracleView: View {
|
||||
await MainActor.run {
|
||||
activeOpportunityMutationID = nil
|
||||
actionMessage = opportunityActionMessage(stage: stage, probability: probability, nextAction: nextAction)
|
||||
rewardClosedWonIfNeeded(stage)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
activeOpportunityMutationID = nil
|
||||
actionError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func saveOpportunityEdits(
|
||||
_ opportunity: VelocityOpportunityDTO,
|
||||
stage: String?,
|
||||
value: Double?,
|
||||
probability: Int?,
|
||||
expectedCloseDate: String?,
|
||||
nextAction: String?,
|
||||
notes: String?
|
||||
) {
|
||||
actionError = nil
|
||||
actionMessage = nil
|
||||
activeOpportunityMutationID = opportunity.opportunityId
|
||||
|
||||
Task {
|
||||
do {
|
||||
_ = try await store.updateOpportunity(
|
||||
opportunityId: opportunity.opportunityId,
|
||||
stage: stage,
|
||||
value: value,
|
||||
probability: probability,
|
||||
expectedCloseDate: expectedCloseDate,
|
||||
nextAction: nextAction,
|
||||
notes: notes
|
||||
)
|
||||
await refreshClient360IfNeeded(for: opportunity.personId ?? selectedClient360PersonID)
|
||||
await MainActor.run {
|
||||
activeOpportunityMutationID = nil
|
||||
editingOpportunity = nil
|
||||
actionMessage = "Opportunity updated."
|
||||
rewardClosedWonIfNeeded(stage)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
@@ -801,39 +1183,21 @@ struct OracleView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var canonicalLeadStages: [String] {
|
||||
[
|
||||
"new",
|
||||
"contacted",
|
||||
"qualified",
|
||||
"site_visit_scheduled",
|
||||
"site_visited",
|
||||
"negotiation",
|
||||
"booking_initiated",
|
||||
"booked",
|
||||
"lost",
|
||||
"dormant",
|
||||
]
|
||||
}
|
||||
|
||||
private var canonicalOpportunityStages: [String] {
|
||||
[
|
||||
"prospect",
|
||||
"qualified",
|
||||
"proposal",
|
||||
"site_visit",
|
||||
"negotiation",
|
||||
"booking",
|
||||
"agreement",
|
||||
"closed_won",
|
||||
"closed_lost",
|
||||
]
|
||||
private func leadStageOptions(currentStatus: String) -> [VelocityVocabularyOptionDTO] {
|
||||
store.crmVocabularies.leadStages.filter { $0.value != currentStatus.lowercased() }
|
||||
}
|
||||
|
||||
private func stageLabel(_ status: String) -> String {
|
||||
status.replacingOccurrences(of: "_", with: " ").capitalized
|
||||
}
|
||||
|
||||
private func rewardClosedWonIfNeeded(_ stage: String?) {
|
||||
let normalized = stage?.lowercased().replacingOccurrences(of: " ", with: "_") ?? ""
|
||||
guard normalized.contains("closed_won") || normalized == "won" else { return }
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred(intensity: 1.0)
|
||||
AudioServicesPlaySystemSound(1104)
|
||||
}
|
||||
|
||||
private func iso8601Timestamp(_ date: Date) -> String {
|
||||
ISO8601DateFormatter().string(from: date)
|
||||
}
|
||||
@@ -1123,17 +1487,17 @@ struct OracleView: View {
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
if let score = snapshot.primaryQDScore {
|
||||
if !store.isShowroomModeEnabled, let score = snapshot.primaryQDScore {
|
||||
Text("\(score.scoreType.replacingOccurrences(of: "_", with: " ").capitalized) score: \(score.displayScore)")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
}
|
||||
if !snapshot.riskFlags.isEmpty {
|
||||
if !store.isShowroomModeEnabled && !snapshot.riskFlags.isEmpty {
|
||||
Text("Risk flags: \(snapshot.riskFlags.joined(separator: ", "))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
if !snapshot.recommendedNextActions.isEmpty {
|
||||
if !store.isShowroomModeEnabled && !snapshot.recommendedNextActions.isEmpty {
|
||||
Text("Next actions: \(snapshot.recommendedNextActions.joined(separator: " · "))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
@@ -1218,6 +1582,153 @@ struct OracleView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct OpportunityEditSheet: View {
|
||||
let opportunity: VelocityOpportunityDTO
|
||||
let stages: [VelocityVocabularyOptionDTO]
|
||||
let onSave: (String?, Double?, Int?, String?, String?, String?) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var stage: String
|
||||
@State private var valueText: String
|
||||
@State private var probabilityText: String
|
||||
@State private var expectedCloseDate: String
|
||||
@State private var nextAction: String
|
||||
@State private var notes: String
|
||||
@State private var validationMessage: String?
|
||||
|
||||
init(
|
||||
opportunity: VelocityOpportunityDTO,
|
||||
stages: [VelocityVocabularyOptionDTO],
|
||||
onSave: @escaping (String?, Double?, Int?, String?, String?, String?) -> Void
|
||||
) {
|
||||
self.opportunity = opportunity
|
||||
self.stages = stages
|
||||
self.onSave = onSave
|
||||
_stage = State(initialValue: opportunity.stage)
|
||||
_valueText = State(initialValue: opportunity.value.map { String(format: "%.0f", $0) } ?? "")
|
||||
_probabilityText = State(initialValue: opportunity.probabilityPercent.map(String.init) ?? "")
|
||||
_expectedCloseDate = State(initialValue: opportunity.expectedCloseDate ?? "")
|
||||
_nextAction = State(initialValue: opportunity.nextAction ?? "")
|
||||
_notes = State(initialValue: opportunity.notes ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Deal") {
|
||||
Picker("Stage", selection: $stage) {
|
||||
ForEach(stageOptions()) { value in
|
||||
Text(value.label)
|
||||
.tag(value.value)
|
||||
}
|
||||
}
|
||||
TextField("Value", text: $valueText)
|
||||
.keyboardType(.decimalPad)
|
||||
TextField("Probability", text: $probabilityText)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Expected close date", text: $expectedCloseDate)
|
||||
.textInputAutocapitalization(.never)
|
||||
}
|
||||
|
||||
Section("Operator Context") {
|
||||
TextField("Next action", text: $nextAction, axis: .vertical)
|
||||
TextField("Notes", text: $notes, axis: .vertical)
|
||||
}
|
||||
|
||||
if let validationMessage {
|
||||
Section {
|
||||
Text(validationMessage)
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(VelocityTheme.background)
|
||||
.navigationTitle("Edit Deal")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
save()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stageOptions() -> [VelocityVocabularyOptionDTO] {
|
||||
guard !stages.contains(where: { $0.value == stage }) else {
|
||||
return stages
|
||||
}
|
||||
return [
|
||||
VelocityVocabularyOptionDTO(
|
||||
value: stage,
|
||||
label: stage.replacingOccurrences(of: "_", with: " ").capitalized,
|
||||
description: "Current backend value",
|
||||
icon: nil
|
||||
)
|
||||
] + stages
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let value: Double?
|
||||
if let rawValue = valueText.trimmedNonEmpty {
|
||||
guard let parsedValue = Double(rawValue) else {
|
||||
validationMessage = "Enter a valid numeric opportunity value."
|
||||
return
|
||||
}
|
||||
value = parsedValue
|
||||
} else {
|
||||
value = nil
|
||||
}
|
||||
|
||||
let probability: Int?
|
||||
if let rawProbability = probabilityText.trimmedNonEmpty {
|
||||
guard let parsedProbability = Int(rawProbability), (0...100).contains(parsedProbability) else {
|
||||
validationMessage = "Probability must be a whole number from 0 to 100."
|
||||
return
|
||||
}
|
||||
probability = parsedProbability
|
||||
} else {
|
||||
probability = nil
|
||||
}
|
||||
|
||||
if let closeDate = expectedCloseDate.trimmedNonEmpty,
|
||||
!Self.isValidISODate(closeDate) {
|
||||
validationMessage = "Expected close date must be YYYY-MM-DD."
|
||||
return
|
||||
}
|
||||
|
||||
onSave(
|
||||
stage,
|
||||
value,
|
||||
probability,
|
||||
expectedCloseDate.trimmedNonEmpty,
|
||||
nextAction.trimmedNonEmpty,
|
||||
notes.trimmedNonEmpty
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private static func isValidISODate(_ value: String) -> Bool {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: value) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let value = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OracleView()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
enum SentinelScope {
|
||||
static let navigationTitle = "Operator Posture"
|
||||
static let navigationTitle = "Sentinel"
|
||||
static let productFamilyName = "Sentinel"
|
||||
static let availabilityBadge = "Operator posture only"
|
||||
static let availabilityBadge = "Live perception analytics"
|
||||
|
||||
static let disabledAnalyticsCapabilities: [String] = [
|
||||
"visitor counting",
|
||||
@@ -12,6 +12,9 @@ enum SentinelScope {
|
||||
]
|
||||
|
||||
static let liveBackedCapabilities: [String] = [
|
||||
"visitor counting",
|
||||
"sentiment distribution",
|
||||
"journey intelligence",
|
||||
"alert posture",
|
||||
"transcription queue visibility",
|
||||
"upcoming calendar pressure",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct SentinelView: View {
|
||||
@State private var store = AppStore.shared
|
||||
private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect()
|
||||
@State private var analytics: VelocitySentinelLiveAnalyticsDTO?
|
||||
@State private var analyticsError: String?
|
||||
@State private var isAnalyticsLoading = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -15,6 +16,9 @@ struct SentinelView: View {
|
||||
}
|
||||
|
||||
availabilityCard
|
||||
analyticsCards
|
||||
sentimentCard
|
||||
journeyCard
|
||||
postureCards
|
||||
timelineCard
|
||||
}
|
||||
@@ -22,10 +26,13 @@ struct SentinelView: View {
|
||||
}
|
||||
.background(VelocityTheme.background)
|
||||
.scrollContentBackground(.hidden)
|
||||
.task { await store.refresh() }
|
||||
.refreshable { await store.refresh() }
|
||||
.onReceive(refreshTimer) { _ in
|
||||
Task { await store.refresh(silent: true) }
|
||||
.task {
|
||||
await store.refresh()
|
||||
await loadAnalytics()
|
||||
}
|
||||
.refreshable {
|
||||
await store.refresh()
|
||||
await loadAnalytics()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +45,7 @@ struct SentinelView: View {
|
||||
Text(SentinelScope.navigationTitle)
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Truthful live posture for alerts and comms load. \(SentinelScope.productFamilyName) analytics stay disabled on iPad until a real production stream is exposed.")
|
||||
Text("Live showroom perception analytics from the production Sentinel websocket and persisted perception intelligence.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
@@ -53,17 +60,122 @@ struct SentinelView: View {
|
||||
Spacer()
|
||||
statusBadge(
|
||||
label: SentinelScope.availabilityBadge,
|
||||
color: VelocityTheme.warning
|
||||
color: VelocityTheme.success
|
||||
)
|
||||
}
|
||||
|
||||
Text("This iPad build does not synthesize \(SentinelScope.disabledAnalyticsSummary). A dedicated production Sentinel route is still required before those analytics can be shown safely.")
|
||||
Text("This iPad build reads `/api/sentinel/analytics/live`, which summarizes the real `/api/sentinel/ws/perception` stream after biometric packets are persisted by the backend.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
|
||||
Text("Current surface instead reports real \(SentinelScope.liveBackedSummary) from the live mobile-edge backend.")
|
||||
Text("Live-backed capabilities: \(SentinelScope.liveBackedSummary).")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
|
||||
if let analyticsError {
|
||||
Text(analyticsError)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
} else if isAnalyticsLoading {
|
||||
Text("Loading live perception analytics...")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else if let analytics {
|
||||
Text("Stream: \(analytics.liveStreamPath)")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.accent)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var analyticsCards: some View {
|
||||
HStack(spacing: 14) {
|
||||
SentinelCard(
|
||||
title: "Active visitors",
|
||||
value: "\(analytics?.activeSessions ?? 0)",
|
||||
subtitle: "Open Sentinel perception sessions",
|
||||
color: VelocityTheme.accent
|
||||
)
|
||||
SentinelCard(
|
||||
title: "Visitors 24h",
|
||||
value: "\(analytics?.visitorCount24h ?? 0)",
|
||||
subtitle: "Sessions started in the last day",
|
||||
color: VelocityTheme.success
|
||||
)
|
||||
SentinelCard(
|
||||
title: "Avg QD",
|
||||
value: String(format: "%.0f", analytics?.averageQdScore ?? 0),
|
||||
subtitle: "Average finalized session score",
|
||||
color: VelocityTheme.warning
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var sentimentCard: some View {
|
||||
let distribution = analytics?.sentimentDistribution ?? [:]
|
||||
return VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Sentiment Distribution")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
HStack(spacing: 12) {
|
||||
sentimentPill("Positive", distribution["positive"] ?? 0, VelocityTheme.success)
|
||||
sentimentPill("Neutral", distribution["neutral"] ?? 0, VelocityTheme.accent)
|
||||
sentimentPill("Negative", distribution["negative"] ?? 0, VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
}
|
||||
|
||||
private var journeyCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
Text("Showroom Journey")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text("Live feed")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
|
||||
if analytics?.journey.isEmpty ?? true {
|
||||
Text("No perception journey events have been persisted yet.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
} else {
|
||||
ForEach(analytics?.journey.prefix(8) ?? []) { event in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(event.eventType.replacingOccurrences(of: "_", with: " ").capitalized)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", event.engagementScore * 100))
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(event.engagementScore >= 0.7 ? VelocityTheme.success : VelocityTheme.accent)
|
||||
}
|
||||
Text(event.sceneLabel?.trimmedNonEmpty ?? event.sessionRef ?? "No scene label")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text(event.happenedAt ?? "Timestamp pending")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(VelocityTheme.borderSubtle, lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.glassCard(cornerRadius: 18)
|
||||
@@ -174,6 +286,54 @@ struct SentinelView: View {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func sentimentPill(_ label: String, _ value: Int, _ color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(label.uppercased())
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.tracking(1)
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
Text("\(value)")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(VelocityTheme.surface)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(color.opacity(0.22), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func loadAnalytics(silent: Bool = false) async {
|
||||
if !silent {
|
||||
await MainActor.run {
|
||||
isAnalyticsLoading = true
|
||||
analyticsError = nil
|
||||
}
|
||||
}
|
||||
do {
|
||||
let response = try await VelocityAPIClient.shared.fetchSentinelLiveAnalytics()
|
||||
await MainActor.run {
|
||||
analytics = response
|
||||
analyticsError = nil
|
||||
isAnalyticsLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
if let apiError = error as? VelocityAPIError, apiError.statusCode == 404 {
|
||||
analyticsError = "Sentinel analytics is not available on the configured backend yet."
|
||||
} else {
|
||||
analyticsError = error.localizedDescription
|
||||
}
|
||||
isAnalyticsLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SentinelCard: View {
|
||||
@@ -207,3 +367,10 @@ private struct SentinelCard: View {
|
||||
#Preview {
|
||||
SentinelView()
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var trimmedNonEmpty: String? {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ struct SessionConfigurationPanel: View {
|
||||
VStack(spacing: 14) {
|
||||
SessionInputField(
|
||||
label: "Backend endpoint",
|
||||
placeholder: "https://velocity.desineuron.in/api"
|
||||
placeholder: SessionConfigurationDefaults.productionBaseURL
|
||||
) {
|
||||
TextField("", text: $session.draftBaseURL, prompt: Text("https://velocity.desineuron.in/api"))
|
||||
TextField("", text: $session.draftBaseURL, prompt: Text(SessionConfigurationDefaults.productionBaseURL))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SettingsView: View {
|
||||
@State private var store = AppStore.shared
|
||||
@State private var session = SessionStore.shared
|
||||
@State private var ssoProviders: VelocitySSOProvidersDTO?
|
||||
@State private var mdmConfig: VelocityMDMConfigDTO?
|
||||
@State private var tenantUsers: [VelocityAuthUserDTO] = []
|
||||
@State private var identityMessage: String?
|
||||
@State private var identityError: String?
|
||||
@State private var isSwitchingSession = false
|
||||
@State private var isAdvancedConfigurationUnlocked = false
|
||||
@AppStorage("velocity.notifications.clientInsights") private var clientInsightNotifications = true
|
||||
@AppStorage("velocity.notifications.calendar") private var calendarNotifications = true
|
||||
@AppStorage("velocity.notifications.showroom") private var showroomNotifications = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Live runtime configuration")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Settings")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("Profile and notification preferences")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
|
||||
profileSection
|
||||
notificationPreferencesSection
|
||||
|
||||
if isAdvancedConfigurationUnlocked {
|
||||
SettingsSection(title: "Connectivity") {
|
||||
SettingsRow(
|
||||
label: "Backend endpoint",
|
||||
@@ -96,6 +112,81 @@ struct SettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Enterprise Identity") {
|
||||
SettingsRow(
|
||||
label: "SSO providers",
|
||||
value: ssoProviders?.providers.map(\.name).joined(separator: ", ") ?? "Not loaded",
|
||||
icon: "person.badge.key",
|
||||
accentColor: ssoProviders?.enabled == true ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "MDM configuration",
|
||||
value: mdmConfig?.managedConfigurationRequired == true ? "Required · \(mdmConfig?.configurationKeys.count ?? 0) keys" : "Optional · \(mdmConfig?.configurationKeys.count ?? 0) keys",
|
||||
icon: "iphone.badge.gearshape",
|
||||
accentColor: mdmConfig == nil ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Tenant users",
|
||||
value: tenantUsers.isEmpty ? "Not loaded" : "\(tenantUsers.count) available",
|
||||
icon: "person.2.badge.gearshape",
|
||||
accentColor: tenantUsers.isEmpty ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
Button {
|
||||
Task { await requestPasswordRecovery() }
|
||||
} label: {
|
||||
Label("Request Recovery", systemImage: "lock.rotation")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(VelocityTheme.accent)
|
||||
|
||||
Button {
|
||||
Task { await loadEnterpriseIdentity() }
|
||||
} label: {
|
||||
Label("Refresh Identity", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(VelocityTheme.accent)
|
||||
|
||||
Menu {
|
||||
if tenantUsers.isEmpty {
|
||||
Text("No tenant users returned")
|
||||
} else {
|
||||
ForEach(tenantUsers) { user in
|
||||
Button {
|
||||
Task { await switchSession(to: user) }
|
||||
} label: {
|
||||
Label("\(user.displayName) · \(user.role)", systemImage: "person.crop.circle.badge.checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(isSwitchingSession ? "Switching..." : "Switch User", systemImage: "person.2")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(VelocityTheme.accent)
|
||||
.disabled(isSwitchingSession || tenantUsers.isEmpty)
|
||||
}
|
||||
|
||||
if let identityMessage {
|
||||
Text(identityMessage)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.success)
|
||||
}
|
||||
if let identityError {
|
||||
Text(identityError)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(VelocityTheme.danger)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Production Readiness") {
|
||||
SettingsRow(
|
||||
label: "Canonical contacts",
|
||||
@@ -145,19 +236,151 @@ struct SettingsView: View {
|
||||
Text("This build avoids local demo data. Runtime session overrides are stored on-device so investor or operator installs no longer depend on committed build-time credentials.")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text("\(SentinelScope.navigationTitle) remains the truthful iPad label for the current \(SentinelScope.productFamilyName) surface because visitor analytics stay disabled until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed. Dream Weaver can now use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are still enforced and reported truthfully.")
|
||||
Text("\(SentinelScope.navigationTitle) now reads persisted perception analytics from the production Sentinel stream; Communications, Calendar, Dashboard, Oracle, and inventory media are live-backed. Dream Weaver can use a dedicated gateway with an optional per-gateway API key, and backend plus generation route health are enforced and reported truthfully.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 24)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.padding(24)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
ThreeFingerLongPressGate {
|
||||
withAnimation(.interactiveSpring(response: 0.45, dampingFraction: 0.86)) {
|
||||
isAdvancedConfigurationUnlocked = true
|
||||
}
|
||||
}
|
||||
.frame(width: 180, height: 180)
|
||||
.allowsHitTesting(!isAdvancedConfigurationUnlocked)
|
||||
}
|
||||
.scrollIndicators(.visible)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(VelocityTheme.background)
|
||||
.task {
|
||||
if isAdvancedConfigurationUnlocked {
|
||||
await loadEnterpriseIdentity()
|
||||
}
|
||||
}
|
||||
.onChange(of: isAdvancedConfigurationUnlocked) { _, unlocked in
|
||||
guard unlocked else { return }
|
||||
Task { await loadEnterpriseIdentity() }
|
||||
}
|
||||
}
|
||||
|
||||
private var profileSection: some View {
|
||||
SettingsSection(title: "Profile") {
|
||||
SettingsRow(
|
||||
label: "Signed in",
|
||||
value: session.operatorIdentity,
|
||||
icon: "person.crop.circle",
|
||||
accentColor: VelocityTheme.accent
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Session",
|
||||
value: session.authModeDescription,
|
||||
icon: "lock.shield",
|
||||
accentColor: session.isConfigured ? VelocityTheme.success : VelocityTheme.warning
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
SettingsRow(
|
||||
label: "Showroom privacy",
|
||||
value: store.isShowroomModeEnabled ? "Buyer-safe" : "Broker private",
|
||||
icon: store.isShowroomModeEnabled ? "eye.slash" : "eye",
|
||||
accentColor: store.isShowroomModeEnabled ? VelocityTheme.warning : VelocityTheme.success
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var notificationPreferencesSection: some View {
|
||||
SettingsSection(title: "Notifications") {
|
||||
ToggleRow(
|
||||
label: "Client insight alerts",
|
||||
detail: "Private broker recommendations",
|
||||
icon: "sparkles",
|
||||
accentColor: VelocityTheme.accent,
|
||||
isOn: $clientInsightNotifications
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
ToggleRow(
|
||||
label: "Calendar reminders",
|
||||
detail: "Confirmed events and follow-ups",
|
||||
icon: "calendar.badge.clock",
|
||||
accentColor: VelocityTheme.warning,
|
||||
isOn: $calendarNotifications
|
||||
)
|
||||
Divider().background(VelocityTheme.borderSubtle)
|
||||
ToggleRow(
|
||||
label: "Showroom mode changes",
|
||||
detail: "Buyer-safe privacy transitions",
|
||||
icon: "eye.slash",
|
||||
accentColor: VelocityTheme.success,
|
||||
isOn: $showroomNotifications
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadEnterpriseIdentity() async {
|
||||
do {
|
||||
async let providers = VelocityAPIClient.shared.fetchSSOProviders()
|
||||
async let mdm = VelocityAPIClient.shared.fetchMDMConfig()
|
||||
async let users = VelocityAPIClient.shared.fetchAuthUsers()
|
||||
let resolvedProviders = try await providers
|
||||
let resolvedMDM = try await mdm
|
||||
let resolvedUsers = (try? await users) ?? []
|
||||
await MainActor.run {
|
||||
ssoProviders = resolvedProviders
|
||||
mdmConfig = resolvedMDM
|
||||
tenantUsers = resolvedUsers
|
||||
identityError = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { identityError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
|
||||
private func requestPasswordRecovery() async {
|
||||
do {
|
||||
try await VelocityAPIClient.shared.requestPasswordRecovery(email: session.operatorIdentity)
|
||||
await MainActor.run {
|
||||
identityMessage = "Password recovery request recorded for \(session.operatorIdentity)."
|
||||
identityError = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
identityError = error.localizedDescription
|
||||
identityMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func switchSession(to user: VelocityAuthUserDTO) async {
|
||||
await MainActor.run {
|
||||
isSwitchingSession = true
|
||||
identityError = nil
|
||||
identityMessage = nil
|
||||
}
|
||||
do {
|
||||
let result = try await VelocityAPIClient.shared.requestSessionSwitch(userId: user.userId)
|
||||
await store.refresh(silent: true)
|
||||
await MainActor.run {
|
||||
isSwitchingSession = false
|
||||
identityMessage = result.requiresReauthentication
|
||||
? "Session switch approved for \(user.displayName); reauthentication is required."
|
||||
: "Session switched to \(user.displayName)."
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isSwitchingSession = false
|
||||
identityError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,3 +445,76 @@ private struct SettingsRow: View {
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ToggleRow: View {
|
||||
let label: String
|
||||
let detail: String
|
||||
let icon: String
|
||||
let accentColor: Color
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: $isOn) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(accentColor.opacity(0.12))
|
||||
.frame(width: 30, height: 30)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(accentColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(VelocityTheme.foreground)
|
||||
Text(detail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(VelocityTheme.mutedFg)
|
||||
}
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.tint(VelocityTheme.accent)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ThreeFingerLongPressGate: UIViewRepresentable {
|
||||
let onUnlock: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView(frame: .zero)
|
||||
view.backgroundColor = .clear
|
||||
let recognizer = UILongPressGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.didLongPress(_:))
|
||||
)
|
||||
recognizer.minimumPressDuration = 1.15
|
||||
recognizer.numberOfTouchesRequired = 3
|
||||
view.addGestureRecognizer(recognizer)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onUnlock: onUnlock)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject {
|
||||
let onUnlock: () -> Void
|
||||
|
||||
init(onUnlock: @escaping () -> Void) {
|
||||
self.onUnlock = onUnlock
|
||||
}
|
||||
|
||||
@objc func didLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||
guard recognizer.state == .began else { return }
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
onUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,21 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_BEARER_TOKEN</key>
|
||||
<string>$(API_BEARER_TOKEN)</string>
|
||||
<key>API_EMAIL</key>
|
||||
<string>$(API_EMAIL)</string>
|
||||
<key>API_PASSWORD</key>
|
||||
<string>$(API_PASSWORD)</string>
|
||||
<key>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>DREAM_WEAVER_BASE_URL</key>
|
||||
<string>$(DREAM_WEAVER_BASE_URL)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Velocity needs camera access to capture room photos for Dream Weaver.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Velocity uses the microphone for push-to-talk Oracle concierge queries.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Velocity uses your location to calculate the accurate sun path for your property.</string>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Velocity uses Face ID to protect client, broker, and property intelligence when the app resumes.</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>Velocity would like to save your AI-generated room design to your photo library.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Velocity needs access to your photo library to let you pick room photos for Dream Weaver.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Velocity uses speech recognition to transcribe broker Oracle concierge queries on request.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user