#38 Ipad app production readiness, Colony orchestration, Social posting Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local> Reviewed-on: #44
521 lines
22 KiB
Swift
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()
|
|
}
|
|
}
|
|
}
|