feat: Ipad app production readiness, Colony orchestration, Social posting
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user