Files
Project_Velocity/iOS/velocity-ipad/velocity/Features/Settings/SettingsView.swift
sayan eeb684b46c
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s
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
2026-05-03 18:30:38 +05:30

521 lines
22 KiB
Swift

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