From 57144e1bd3f47619c5a6bd1db23a001c1a8c5841 Mon Sep 17 00:00:00 2001 From: sayan Date: Mon, 20 Apr 2026 00:48:01 +0530 Subject: [PATCH] feat/#28 (#29) Co-authored-by: Sayan Datta Reviewed-on: https://git.desineuron.in/sagnik/Project_Velocity/pulls/29 --- .../delivery_log.md | 48 +- android-edge-phone/app/build.gradle.kts | 11 + .../app/src/main/AndroidManifest.xml | 2 + .../edgephone/data/VelocityEdgeBackend.kt | 224 ++ .../edgephone/features/AlertsScreen.kt | 31 +- .../features/CommunicationsScreen.kt | 39 +- .../edgephone/features/LeadSummaryScreen.kt | 36 +- .../edgephone/features/NotesScreen.kt | 96 +- .../edgephone/features/PhoneScaffold.kt | 53 +- .../edgephone/features/SettingsScreen.kt | 24 +- .../features/TranscriptionsScreen.kt | 39 +- android-tablet/app/build.gradle.kts | 11 + .../app/src/main/AndroidManifest.xml | 2 + .../velocity/tablet/MainActivity.kt | 2 +- .../velocity/tablet/data/VelocityBackend.kt | 244 +++ .../tablet/features/DashboardScreen.kt | 46 +- .../tablet/features/FeatureScaffold.kt | 89 +- .../tablet/features/InventoryScreen.kt | 43 +- .../velocity/tablet/features/OracleScreen.kt | 46 +- .../tablet/features/SentinelScreen.kt | 39 +- .../tablet/features/SettingsScreen.kt | 26 +- backend/api/routes_mobile_edge.py | 49 +- iOS/velocity-edge-phone/EdgeRootView.swift | 40 - iOS/velocity-edge-phone/EdgeScaffold.swift | 56 - .../Features/EdgeAlertsView.swift | 11 - .../Features/EdgeCommunicationsView.swift | 11 - .../Features/EdgeLeadSummaryView.swift | 11 - .../Features/EdgeNotesView.swift | 11 - .../Features/EdgeSettingsView.swift | 11 - .../Features/EdgeTranscriptionsView.swift | 11 - iOS/velocity-edge-phone/README.md | 3 - .../VelocityEdgePhoneApp.swift | 10 - iOS/velocity-iphone/Config.xcconfig.example | 5 + iOS/velocity-iphone/Info.plist | 32 + iOS/velocity-iphone/README.md | 42 + .../velocity-iphone.xcodeproj/project.pbxproj | 339 +++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 20 + .../AppIcon.appiconset/Contents.json | 35 + .../Assets.xcassets/Contents.json | 6 + .../Core/Config/EdgeAppConfig.swift | 34 + .../Networking/VelocityEdgeAPIClient.swift | 319 +++ .../Core/Networking/VelocityLiveAPI.swift | 914 ++++++++ .../Core/Security/VelocitySecureStore.swift | 57 + .../Core/State/EdgeAppStore.swift | 109 + .../Core/State/VelocityAppModel.swift | 519 +++++ .../velocity-iphone/Core/UI/EdgeTheme.swift | 495 +++++ .../Core/UI/VelocityDesignSystem.swift | 390 ++++ .../velocity-iphone/EdgeRootView.swift | 1924 +++++++++++++++++ .../Features/Alerts/EdgeAlertsView.swift | 62 + .../EdgeCommunicationsView.swift | 51 + .../LeadSummary/EdgeLeadSummaryView.swift | 54 + .../Features/Notes/EdgeNotesView.swift | 93 + .../Features/Settings/EdgeSettingsView.swift | 51 + .../EdgeTranscriptionsView.swift | 42 + .../velocity-iphone/VelocityIPhoneApp.swift | 14 + iOS/velocity/velocity/App/ContentView.swift | 20 +- .../velocity/Core/Config/AppConfig.swift | 16 +- .../Core/Networking/VelocityAPIClient.swift | 105 + .../velocity/Core/State/AppStore.swift | 367 ++-- .../Features/Dashboard/DashboardView.swift | 557 ++--- .../Features/Inventory/InventoryView.swift | 7 +- .../velocity/Features/Oracle/OracleView.swift | 1137 +++------- .../Features/Sentinel/SentinelView.swift | 539 ++--- .../Features/Settings/SettingsView.swift | 117 +- 65 files changed, 7652 insertions(+), 2202 deletions(-) create mode 100644 android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/data/VelocityEdgeBackend.kt create mode 100644 android-tablet/app/src/main/java/com/desineuron/velocity/tablet/data/VelocityBackend.kt delete mode 100644 iOS/velocity-edge-phone/EdgeRootView.swift delete mode 100644 iOS/velocity-edge-phone/EdgeScaffold.swift delete mode 100644 iOS/velocity-edge-phone/Features/EdgeAlertsView.swift delete mode 100644 iOS/velocity-edge-phone/Features/EdgeCommunicationsView.swift delete mode 100644 iOS/velocity-edge-phone/Features/EdgeLeadSummaryView.swift delete mode 100644 iOS/velocity-edge-phone/Features/EdgeNotesView.swift delete mode 100644 iOS/velocity-edge-phone/Features/EdgeSettingsView.swift delete mode 100644 iOS/velocity-edge-phone/Features/EdgeTranscriptionsView.swift delete mode 100644 iOS/velocity-edge-phone/README.md delete mode 100644 iOS/velocity-edge-phone/VelocityEdgePhoneApp.swift create mode 100644 iOS/velocity-iphone/Config.xcconfig.example create mode 100644 iOS/velocity-iphone/Info.plist create mode 100644 iOS/velocity-iphone/README.md create mode 100644 iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj create mode 100644 iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 iOS/velocity-iphone/velocity-iphone/Assets.xcassets/Contents.json create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/Config/EdgeAppConfig.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityEdgeAPIClient.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityLiveAPI.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/Security/VelocitySecureStore.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/State/EdgeAppStore.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/State/VelocityAppModel.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Core/UI/VelocityDesignSystem.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Features/Alerts/EdgeAlertsView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Features/Communications/EdgeCommunicationsView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Features/LeadSummary/EdgeLeadSummaryView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Features/Notes/EdgeNotesView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Features/Settings/EdgeSettingsView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/Features/Transcriptions/EdgeTranscriptionsView.swift create mode 100644 iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift diff --git a/.Agent Context/Sprint 1/Sayan Multi-Surface and Oracle Delivery Pack/delivery_log.md b/.Agent Context/Sprint 1/Sayan Multi-Surface and Oracle Delivery Pack/delivery_log.md index e537f0cf..83339049 100644 --- a/.Agent Context/Sprint 1/Sayan Multi-Surface and Oracle Delivery Pack/delivery_log.md +++ b/.Agent Context/Sprint 1/Sayan Multi-Surface and Oracle Delivery Pack/delivery_log.md @@ -19,9 +19,11 @@ - Compose app shell - Navigation graph - Dashboard, Inventory, Oracle, Sentinel, and Settings feature stubs -- Added iPhone edge app scaffold under `iOS/velocity-edge-phone/` - - SwiftUI app entry +- Added dedicated iPhone edge app source tree under `iOS/velocity-iphone/` + - SwiftUI app entry and tab shell + - Shared Velocity-styled phone UI tokens and cards - Alerts, Lead Summary, Communications, Notes, Transcriptions, Settings + - Live backend auth, notes, transcript, memory, alerts, and heartbeat wiring - Added Android phone edge scaffold under `android-edge-phone/` - Compose app shell - Alerts, Lead Summary, Communications, Notes, Transcriptions, Settings @@ -57,14 +59,38 @@ - `iOS/velocity/velocity/Features/Calendar/CalendarView.swift` - `iOS/velocity/velocity/Core/Networking/VelocityAPIClient.swift` - `iOS/velocity/velocity/Core/Config/AppConfig.swift` +- Completed a broader iOS production hardening pass: + - `iOS/velocity/velocity/Core/Config/AppConfig.swift` now defaults to `https://api.desineuron.in` instead of a stale instance IP + - `iOS/velocity/velocity/Core/Networking/VelocityAPIClient.swift` now reads live inventory summaries in addition to leads, events, alerts, and calendar + - `iOS/velocity/velocity/Core/State/AppStore.swift` no longer seeds fabricated dashboard, visitor, chat, or oracle state and now hydrates a live shared snapshot + - `iOS/velocity/velocity/Features/Dashboard/DashboardView.swift` now renders live lead, inventory, calendar, and alert posture instead of synthetic KPIs and AI chat + - `iOS/velocity/velocity/Features/Oracle/OracleView.swift` now uses live pipeline, live communication timelines, and live calendar events; unavailable Oracle modes are shown truthfully instead of mock canvases + - `iOS/velocity/velocity/Features/Sentinel/SentinelView.swift` now disables visitor analytics until a real Sentinel feed exists and shows live operator urgency instead of fake biometric metrics + - `iOS/velocity/velocity/Features/Settings/SettingsView.swift` now reflects real backend/auth/runtime state + - `iOS/velocity/velocity/Features/Inventory/InventoryView.swift` no longer renders the simulator-only fake AR sun overlay in the active production path +- Completed the Android mobile hardening pass: + - `android-tablet/` now includes a live backend client, Gradle runtime config fields, internet permission, and live-backed Dashboard, Inventory, Oracle, Sentinel, and Settings surfaces + - `android-edge-phone/` now includes a live mobile-edge client, Gradle runtime config fields, internet permission, and live-backed Alerts, Lead Summary, Communications, Notes, Transcriptions, and Settings surfaces + - Android Notes now writes to the real `POST /api/mobile-edge/notes` route when credentials are configured + - Android Sentinel surfaces are now explicitly truthful about missing production biometric feeds instead of implying live analytics +- Completed the dedicated iPhone edge production source pass: + - `iOS/velocity-iphone/` now supersedes the earlier lightweight edge scaffold and is the single iPhone source of truth + - Added a dedicated standalone Xcode project at `iOS/velocity-iphone/velocity-iphone.xcodeproj` so the phone app can now be opened and tested directly in Xcode + - Added `iOS/velocity-iphone/Assets.xcassets` with app icon and accent color catalog plumbing required by the standalone target + - `iOS/velocity-iphone/Core/Networking/VelocityEdgeAPIClient.swift` now uses live `/api/auth/login`, `/api/leads`, `/api/mobile-edge/alerts`, `/api/mobile-edge/events`, `/api/mobile-edge/memory`, `/api/mobile-edge/notes`, `/api/mobile-edge/transcripts/{eventId}`, and `/api/mobile-edge/session` + - `iOS/velocity-iphone/Core/State/EdgeAppStore.swift` now manages live shared phone state for the edge surface and refreshes note, transcript, and alert context after writes + - `iOS/velocity-iphone/Core/UI/EdgeTheme.swift` now preserves the iPad Velocity styling language while adapting cards and layout to narrow iPhone width + - `iOS/velocity-iphone/Features/Alerts/EdgeAlertsView.swift` now uses an adaptive metric grid suitable for phone resolution instead of a tablet-like fixed row + - iPhone edge screens now auto-refresh live state periodically so the app behaves like an active operator surface instead of a one-shot fetch + - `backend/api/routes_mobile_edge.py` session heartbeat handling was fixed so `iphone_edge` and other mobile surfaces update an active session window instead of creating redundant session rows on every heartbeat + - Removed the superseded `iOS/velocity-edge-phone/` scaffold after confirming nothing useful remained outside the stronger `velocity-iphone` source tree ### MVP limits still in place -- Android projects are scaffolds only; they are not yet wired to shared API clients, auth, or install registration. -- The iPhone edge scaffold is source-first and does not yet include a dedicated `.xcodeproj` target. +- The `iOS/velocity-iphone/` source tree now includes a dedicated standalone `.xcodeproj`, but full `xcodebuild` verification still depends on a machine with full Xcode, simulator runtimes, signing configuration, and live credentials. - The WebOS admin page is mounted into the live Vite shell and can now stage bounded actions against the backend audit trail; auto-execution remains intentionally out of scope. -- The iPad Communications and Calendar views read live backend data, but the broader iPad app still contains other legacy mock-backed modules outside this residual slice. -- The active WebOS runtime path has been hardened away from mock/demo behavior, but deeper non-WebOS workstream items remain out of scope for this pass, especially legacy iPad modules and simulator-only inventory/AR helpers on iOS. +- The iPad production shell no longer uses fabricated dashboard/oracle/sentinel state, but some specialist inventory helpers remain beyond the current source hardening pass; on iPhone, the main remaining gaps are host-side build verification, signing, and supplying final production app-icon artwork rather than feature wiring. +- Android mobile surfaces now read live data, but full device verification still requires Gradle on the host and real credentials in Gradle properties. - Oracle template seed metadata needs correction: `_meta.total_seed_examples` does not match the actual seed example count in `backend/oracle/oracle_template_seed_db.json`. - Sprint-1 documentation artifacts called for in the delivery pack are still missing as committed repo outputs, including the residual audit artifact and contract/package documentation. @@ -75,7 +101,15 @@ - SQL parse sanity check on extension migration - `npx tsc --noEmit` for the WebOS app after live-auth and no-mock hardening changes - `python3 -m py_compile` for backend auth and route modules after role/session hardening +- `swiftc -parse` over the active iPad Swift source set after mobile production hardening +- `swiftc -parse` over the complete `iOS/velocity-iphone/` source tree +- `plutil -lint` for `iOS/velocity-iphone/Info.plist` +- `plutil -lint` for `iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj` +- `python3 -m py_compile backend/api/routes_mobile_edge.py` after fixing surface session heartbeat behavior +- XML validation for both Android manifests +- Kotlin delimiter sanity checks over `android-tablet` and `android-edge-phone` source trees +- Full `xcodebuild` and Gradle assembly were not run on this host because full Xcode and Gradle are not installed here ### Recommended next implementation step - WebOS is now the strongest completed surface. The next implementation step should move fully to iOS and Android completion while preserving the live backend/auth patterns established here. + WebOS and the primary iPad/Android/iPhone source surfaces are now aligned around live backend truthfulness. The next implementation step should be dedicated device/build-host verification across the standalone `iOS/velocity-iphone/velocity-iphone.xcodeproj`, the existing iPad target, and the Android builds with live credentials. diff --git a/android-edge-phone/app/build.gradle.kts b/android-edge-phone/app/build.gradle.kts index c26d0fac..9b81c25f 100644 --- a/android-edge-phone/app/build.gradle.kts +++ b/android-edge-phone/app/build.gradle.kts @@ -4,6 +4,11 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") } +fun Project.gradleStringProperty(name: String, defaultValue: String): String { + val raw = (findProperty(name) as String?) ?: defaultValue + return "\"${raw.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} + android { namespace = "com.desineuron.velocity.edgephone" compileSdk = 35 @@ -16,6 +21,10 @@ android { versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "VELOCITY_BASE_URL", project.gradleStringProperty("VELOCITY_BASE_URL", "https://api.desineuron.in")) + buildConfigField("String", "VELOCITY_API_EMAIL", project.gradleStringProperty("VELOCITY_API_EMAIL", "")) + buildConfigField("String", "VELOCITY_API_PASSWORD", project.gradleStringProperty("VELOCITY_API_PASSWORD", "")) + buildConfigField("String", "VELOCITY_BEARER_TOKEN", project.gradleStringProperty("VELOCITY_BEARER_TOKEN", "")) } buildTypes { @@ -39,6 +48,7 @@ android { buildFeatures { compose = true + buildConfig = true } } @@ -55,6 +65,7 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/android-edge-phone/app/src/main/AndroidManifest.xml b/android-edge-phone/app/src/main/AndroidManifest.xml index b12e8ba2..e0286ea0 100644 --- a/android-edge-phone/app/src/main/AndroidManifest.xml +++ b/android-edge-phone/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + "Bearer token" + BuildConfig.VELOCITY_API_EMAIL.isNotBlank() && BuildConfig.VELOCITY_API_PASSWORD.isNotBlank() -> "Email/password" + else -> "Credentials required" + } + + val isConfigured: Boolean + get() = BuildConfig.VELOCITY_BEARER_TOKEN.isNotBlank() || + (BuildConfig.VELOCITY_API_EMAIL.isNotBlank() && BuildConfig.VELOCITY_API_PASSWORD.isNotBlank()) + + suspend fun fetchLeads(): List = withContext(Dispatchers.IO) { + val root = getJson("/api/leads") + val items = root.optJSONArray("data") ?: JSONArray() + buildList { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + add( + EdgeLead( + id = item.optString("id"), + name = item.optString("name"), + score = item.optInt("score"), + qualification = item.optString("qualification"), + unitInterest = item.optString("unit_interest"), + budget = item.optString("budget"), + ) + ) + } + } + } + + suspend fun fetchAlerts(): EdgeAlertSnapshot = withContext(Dispatchers.IO) { + val root = getJson("/api/mobile-edge/alerts") + EdgeAlertSnapshot( + pendingInsights = root.optInt("pending_insights"), + pendingTranscriptions = root.optInt("pending_transcriptions"), + upcomingCalendarEvents24h = root.optInt("upcoming_calendar_events_24h"), + ) + } + + suspend fun fetchEvents(lead: EdgeLead, limit: Int = 4): List = withContext(Dispatchers.IO) { + val root = getJson("/api/mobile-edge/events?lead_id=${lead.id}&limit=$limit") + val items = root.optJSONArray("events") ?: JSONArray() + buildList { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + add( + EdgeCommunicationEvent( + id = item.optString("event_id"), + leadId = item.optString("lead_id"), + leadName = lead.name, + channel = item.optString("channel"), + summary = item.optString("summary").ifBlank { "No summary available." }, + timestamp = item.optString("timestamp"), + recordingRef = item.optString("recording_ref"), + ) + ) + } + } + } + + suspend fun fetchMemoryFacts(leadId: String): List = withContext(Dispatchers.IO) { + val encodedLeadId = URLEncoder.encode(leadId, Charsets.UTF_8.name()) + val root = getJson("/api/mobile-edge/memory?lead_id=$encodedLeadId&limit=10") + val items = root.optJSONArray("facts") ?: JSONArray() + buildList { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + add( + EdgeMemoryFact( + id = item.optString("fact_id"), + factType = item.optString("fact_type"), + factText = item.optString("fact_text"), + createdAt = item.optString("created_at"), + ) + ) + } + } + } + + suspend fun createNote(leadId: String, noteText: String): Result = withContext(Dispatchers.IO) { + runCatching { + val body = JSONObject() + .put("lead_id", leadId) + .put("note_text", noteText) + .put("fact_type", "custom") + request("/api/mobile-edge/notes", "POST", body.toString(), authenticated = true, token = getToken()) + Unit + } + } + + suspend fun fetchTranscript(eventId: String): Result = withContext(Dispatchers.IO) { + runCatching { + val root = getJson("/api/mobile-edge/transcripts/$eventId") + val job = root.optJSONObject("job") ?: JSONObject() + val segments = root.optJSONArray("segments") ?: JSONArray() + EdgeTranscript( + eventId = eventId, + status = job.optString("status").ifBlank { "unknown" }, + segmentCount = segments.length(), + ) + } + } + + private suspend fun getToken(): String { + BuildConfig.VELOCITY_BEARER_TOKEN.takeIf { it.isNotBlank() }?.let { return it } + if (cachedToken != null) return cachedToken!! + return tokenMutex.withLock { + cachedToken?.let { return@withLock it } + if (!isConfigured) { + throw IllegalStateException("Set VELOCITY_BEARER_TOKEN or VELOCITY_API_EMAIL/VELOCITY_API_PASSWORD in Gradle properties.") + } + val body = JSONObject() + .put("email", BuildConfig.VELOCITY_API_EMAIL) + .put("password", BuildConfig.VELOCITY_API_PASSWORD) + val json = request("/api/auth/login", "POST", body.toString(), authenticated = false) + json.optString("access_token").ifBlank { + throw IllegalStateException("Velocity login did not return an access token.") + }.also { cachedToken = it } + } + } + + private suspend fun getJson(path: String): JSONObject { + val token = getToken() + return request(path, "GET", null, authenticated = true, token = token) + } + + private suspend fun request( + path: String, + method: String, + body: String?, + authenticated: Boolean, + token: String? = null, + ): JSONObject = withContext(Dispatchers.IO) { + val connection = (URL("$baseUrl$path").openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 20_000 + readTimeout = 20_000 + setRequestProperty("Accept", "application/json") + if (body != null) { + doOutput = true + setRequestProperty("Content-Type", "application/json") + } + if (authenticated) { + setRequestProperty("Authorization", "Bearer ${token.orEmpty()}") + } + } + + body?.let { payload -> + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(payload) + } + } + + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() + + if (status !in 200..299) { + val detail = runCatching { JSONObject(text).optString("detail") }.getOrNull().orEmpty() + throw IllegalStateException(detail.ifBlank { "Velocity request failed with HTTP $status." }) + } + + JSONObject(text.ifBlank { "{}" }) + } +} diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/AlertsScreen.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/AlertsScreen.kt index 6fc5c5fd..c46e8557 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/AlertsScreen.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/AlertsScreen.kt @@ -1,14 +1,39 @@ package com.desineuron.velocity.edgephone.features +import androidx.compose.material3.Text import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.edgephone.data.EdgeAlertSnapshot +import com.desineuron.velocity.edgephone.data.VelocityEdgeBackend @Composable fun AlertsScreen(paddingValues: PaddingValues) { + val state by produceState?>(initialValue = null) { + value = runCatching { VelocityEdgeBackend.fetchAlerts() } + } + PhoneScaffold( paddingValues = paddingValues, title = "Alerts", - subtitle = "High-urgency nudges for unread responses, callbacks, and showroom events.", - actionLabel = "Respond to whale-lead unread thread", - ) + subtitle = "High-urgency nudges from the live mobile-edge backend.", + ) { + when (val result = state) { + null -> LoadingEdgeCard("Fetching live alert counts.") + else -> { + val alerts = result.getOrNull() + if (alerts != null) { + EdgeCard(title = "Live alert posture") { + Text("Pending insights: ${alerts.pendingInsights}", color = Color.White) + Text("Pending transcriptions: ${alerts.pendingTranscriptions}", color = Color.White) + Text("Upcoming 24h calendar events: ${alerts.upcomingCalendarEvents24h}", color = Color.White) + } + } else { + ErrorEdgeCard(result.exceptionOrNull()?.message ?: "Unable to fetch alerts.") + } + } + } + } } diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/CommunicationsScreen.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/CommunicationsScreen.kt index a6e74259..0e779db6 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/CommunicationsScreen.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/CommunicationsScreen.kt @@ -1,14 +1,47 @@ package com.desineuron.velocity.edgephone.features import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.edgephone.data.EdgeCommunicationEvent +import com.desineuron.velocity.edgephone.data.VelocityEdgeBackend @Composable fun CommunicationsScreen(paddingValues: PaddingValues) { + val state by produceState>?>(initialValue = null) { + value = runCatching { + val leads = VelocityEdgeBackend.fetchLeads() + leads.sortedByDescending { it.score }.take(3).flatMap { VelocityEdgeBackend.fetchEvents(it, limit = 2) } + } + } + PhoneScaffold( paddingValues = paddingValues, title = "Communications", - subtitle = "Call, WhatsApp, and operator-import touchpoints on a single edge rail.", - actionLabel = "Log a manual note after callback", - ) + subtitle = "Recent live call and messaging events for the current priority leads.", + ) { + when (val result = state) { + null -> LoadingEdgeCard("Fetching live communication events.") + else -> { + val events = result.getOrNull() + if (events != null) { + EdgeCard(title = "Recent threads") { + if (events.isEmpty()) { + Text("No live communication events were returned yet.", color = Color(0xFF94A3B8)) + } else { + events.forEach { event -> + Text("${event.leadName} · ${event.channel}", color = Color.White) + Text(event.summary, color = Color(0xFF94A3B8)) + } + } + } + } else { + ErrorEdgeCard(result.exceptionOrNull()?.message ?: "Unable to fetch communications.") + } + } + } + } } diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/LeadSummaryScreen.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/LeadSummaryScreen.kt index 5abd886f..9be0ac6e 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/LeadSummaryScreen.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/LeadSummaryScreen.kt @@ -1,14 +1,44 @@ package com.desineuron.velocity.edgephone.features import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.edgephone.data.EdgeLead +import com.desineuron.velocity.edgephone.data.VelocityEdgeBackend @Composable fun LeadSummaryScreen(paddingValues: PaddingValues) { + val state by produceState>?>(initialValue = null) { + value = runCatching { VelocityEdgeBackend.fetchLeads() } + } + PhoneScaffold( paddingValues = paddingValues, title = "Lead Summary", - subtitle = "Compact account memory, qualification, and next-best action.", - actionLabel = "Review Mohammed Al-Rashid context", - ) + subtitle = "Compact live account context for the highest-priority leads.", + ) { + when (val result = state) { + null -> LoadingEdgeCard("Fetching live leads.") + else -> { + val leads = result.getOrNull() + if (leads != null) { + EdgeCard(title = "Top leads") { + if (leads.isEmpty()) { + Text("No live leads are visible to this operator scope yet.", color = Color(0xFF94A3B8)) + } else { + leads.sortedByDescending { it.score }.take(5).forEach { lead -> + Text("${lead.name} · ${lead.score}", color = Color.White) + Text("${lead.qualification} · ${lead.unitInterest} · ${lead.budget}", color = Color(0xFF94A3B8)) + } + } + } + } else { + ErrorEdgeCard(result.exceptionOrNull()?.message ?: "Unable to fetch lead summaries.") + } + } + } + } } diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/NotesScreen.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/NotesScreen.kt index 97913fd8..9cb1f4dc 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/NotesScreen.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/NotesScreen.kt @@ -1,14 +1,104 @@ package com.desineuron.velocity.edgephone.features import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.edgephone.data.EdgeLead +import com.desineuron.velocity.edgephone.data.EdgeMemoryFact +import com.desineuron.velocity.edgephone.data.VelocityEdgeBackend +import kotlinx.coroutines.launch @Composable fun NotesScreen(paddingValues: PaddingValues) { + val leadState by produceState>?>(initialValue = null) { + value = runCatching { VelocityEdgeBackend.fetchLeads() } + } + var noteText by remember { mutableStateOf("") } + var submissionMessage by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + PhoneScaffold( paddingValues = paddingValues, title = "Notes", - subtitle = "Fast capture for memory facts, objections, and promised follow-ups.", - actionLabel = "Create note with memory extraction hint", - ) + subtitle = "Read live memory facts and write a real quick note when credentials are configured.", + ) { + when (val result = leadState) { + null -> LoadingEdgeCard("Fetching live leads for note capture.") + else -> { + val leads = result.getOrNull() + if (leads == null) { + ErrorEdgeCard(result.exceptionOrNull()?.message ?: "Unable to prepare note capture.") + } else { + val lead = leads.sortedByDescending { it.score }.firstOrNull() + if (lead == null) { + EdgeCard(title = "No lead selected") { + Text("No live leads are available yet, so note capture cannot be targeted.", color = Color(0xFF94A3B8)) + } + } else { + val factsState by produceState>?>(initialValue = null, key1 = lead.id) { + value = runCatching { VelocityEdgeBackend.fetchMemoryFacts(lead.id) } + } + EdgeCard(title = "Lead memory · ${lead.name}") { + when (val factsResult = factsState) { + null -> Text("Loading memory facts…", color = Color(0xFF94A3B8)) + else -> { + val facts = factsResult.getOrNull() + if (facts != null) { + if (facts.isEmpty()) { + Text("No persisted memory facts yet.", color = Color(0xFF94A3B8)) + } else { + facts.forEach { fact -> + Text("${fact.factType}: ${fact.factText}", color = Color.White) + } + } + } else { + Text(factsResult.exceptionOrNull()?.message ?: "Unable to fetch memory facts.", color = Color(0xFFFCA5A5)) + } + } + } + } + EdgeCard(title = "Create quick note") { + OutlinedTextField( + value = noteText, + onValueChange = { noteText = it }, + label = { Text("Operator note") }, + ) + Button( + onClick = { + scope.launch { + submissionMessage = VelocityEdgeBackend.createNote(lead.id, noteText.trim()) + .fold( + onSuccess = { + noteText = "" + "Quick note saved to live mobile-edge memory." + }, + onFailure = { it.message ?: "Unable to save quick note." } + ) + } + }, + enabled = noteText.isNotBlank() && VelocityEdgeBackend.isConfigured, + ) { + Text("Save note") + } + submissionMessage?.let { message -> + Text( + message, + color = if (message.contains("saved", ignoreCase = true)) Color(0xFF34D399) else Color(0xFFFCA5A5), + ) + } + } + } + } + } + } + } } diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/PhoneScaffold.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/PhoneScaffold.kt index 8ba71729..eb602573 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/PhoneScaffold.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/PhoneScaffold.kt @@ -3,11 +3,13 @@ package com.desineuron.velocity.edgephone.features import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,7 +22,7 @@ fun PhoneScaffold( paddingValues: PaddingValues, title: String, subtitle: String, - actionLabel: String, + content: @Composable ColumnScope.() -> Unit, ) { Column( modifier = Modifier @@ -32,21 +34,38 @@ fun PhoneScaffold( ) { Text(title, style = MaterialTheme.typography.headlineSmall, color = Color.White) Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF0B1220), RoundedCornerShape(24.dp)) - .padding(18.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text("Edge action", style = MaterialTheme.typography.labelLarge, color = Color(0xFF38BDF8)) - Text(actionLabel, style = MaterialTheme.typography.titleMedium, color = Color.White) - Text( - "This narrow surface is ready for backend hookup to `/api/mobile-edge` once auth and install registration are connected.", - style = MaterialTheme.typography.bodySmall, - color = Color(0xFF94A3B8), - ) - } + content() + } +} + +@Composable +fun EdgeCard( + title: String, + body: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF0B1220), RoundedCornerShape(24.dp)) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, style = MaterialTheme.typography.labelLarge, color = Color(0xFF38BDF8)) + body() + } +} + +@Composable +fun LoadingEdgeCard(message: String) { + EdgeCard(title = "Loading") { + CircularProgressIndicator(color = Color(0xFF38BDF8)) + Text(message, style = MaterialTheme.typography.bodySmall, color = Color(0xFF94A3B8)) + } +} + +@Composable +fun ErrorEdgeCard(message: String) { + EdgeCard(title = "Live backend error") { + Text(message, style = MaterialTheme.typography.bodySmall, color = Color(0xFFFCA5A5)) } } diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/SettingsScreen.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/SettingsScreen.kt index 7e31b6a2..a71c8a45 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/SettingsScreen.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/SettingsScreen.kt @@ -1,14 +1,32 @@ package com.desineuron.velocity.edgephone.features import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.edgephone.data.VelocityEdgeBackend @Composable fun SettingsScreen(paddingValues: PaddingValues) { PhoneScaffold( paddingValues = paddingValues, title = "Settings", - subtitle = "Install registration, operator identity, and API connection state.", - actionLabel = "Verify surface heartbeat and app version", - ) + subtitle = "Runtime configuration for the production edge phone surface.", + ) { + EdgeCard(title = "Connectivity") { + Text("Backend: ${VelocityEdgeBackend.baseUrl}", color = Color.White) + Text("Auth mode: ${VelocityEdgeBackend.authMode}", color = Color(0xFF94A3B8)) + Text( + if (VelocityEdgeBackend.isConfigured) "Live credentials configured." + else "Credentials missing. Configure Gradle properties before field use.", + color = if (VelocityEdgeBackend.isConfigured) Color(0xFF34D399) else Color(0xFFF59E0B), + ) + } + EdgeCard(title = "Production notes") { + Text( + "This phone build reads live mobile-edge routes and does not seed fake alerts, leads, or transcript states.", + color = Color(0xFF94A3B8), + ) + } + } } diff --git a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/TranscriptionsScreen.kt b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/TranscriptionsScreen.kt index 7b9a1444..200d2d21 100644 --- a/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/TranscriptionsScreen.kt +++ b/android-edge-phone/app/src/main/java/com/desineuron/velocity/edgephone/features/TranscriptionsScreen.kt @@ -1,14 +1,47 @@ package com.desineuron.velocity.edgephone.features import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.edgephone.data.EdgeTranscript +import com.desineuron.velocity.edgephone.data.VelocityEdgeBackend @Composable fun TranscriptionsScreen(paddingValues: PaddingValues) { + val state by produceState?>(initialValue = null) { + value = runCatching { + val lead = VelocityEdgeBackend.fetchLeads().sortedByDescending { it.score }.firstOrNull() + val event = lead?.let { VelocityEdgeBackend.fetchEvents(it, limit = 5).firstOrNull { item -> item.recordingRef.isNotBlank() } } + event?.let { VelocityEdgeBackend.fetchTranscript(it.id).getOrThrow() } + } + } + PhoneScaffold( paddingValues = paddingValues, title = "Transcriptions", - subtitle = "Imported voice artifacts and segment-level summaries for the field operator.", - actionLabel = "Review pending recording import", - ) + subtitle = "Transcript job status for the latest recording-backed communication event.", + ) { + when (val result = state) { + null -> LoadingEdgeCard("Fetching transcript status.") + else -> { + if (result.isSuccess) { + val transcript = result.getOrNull() + EdgeCard(title = "Transcript pipeline") { + if (transcript == null) { + Text("No live recording-backed event is available yet for transcript review.", color = Color(0xFF94A3B8)) + } else { + Text("Event: ${transcript.eventId}", color = Color.White) + Text("Job status: ${transcript.status}", color = Color.White) + Text("Segments: ${transcript.segmentCount}", color = Color(0xFF94A3B8)) + } + } + } else { + ErrorEdgeCard(result.exceptionOrNull()?.message ?: "Unable to fetch transcript status.") + } + } + } + } } diff --git a/android-tablet/app/build.gradle.kts b/android-tablet/app/build.gradle.kts index 7a07845f..9d3bdcfa 100644 --- a/android-tablet/app/build.gradle.kts +++ b/android-tablet/app/build.gradle.kts @@ -4,6 +4,11 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") } +fun Project.gradleStringProperty(name: String, defaultValue: String): String { + val raw = (findProperty(name) as String?) ?: defaultValue + return "\"${raw.replace("\\", "\\\\").replace("\"", "\\\"")}\"" +} + android { namespace = "com.desineuron.velocity.tablet" compileSdk = 35 @@ -16,6 +21,10 @@ android { versionName = "1.0.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField("String", "VELOCITY_BASE_URL", project.gradleStringProperty("VELOCITY_BASE_URL", "https://api.desineuron.in")) + buildConfigField("String", "VELOCITY_API_EMAIL", project.gradleStringProperty("VELOCITY_API_EMAIL", "")) + buildConfigField("String", "VELOCITY_API_PASSWORD", project.gradleStringProperty("VELOCITY_API_PASSWORD", "")) + buildConfigField("String", "VELOCITY_BEARER_TOKEN", project.gradleStringProperty("VELOCITY_BEARER_TOKEN", "")) vectorDrawables { useSupportLibrary = true } @@ -42,6 +51,7 @@ android { buildFeatures { compose = true + buildConfig = true } } @@ -58,6 +68,7 @@ dependencies { implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-tooling-preview") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") debugImplementation("androidx.compose.ui:ui-tooling") } diff --git a/android-tablet/app/src/main/AndroidManifest.xml b/android-tablet/app/src/main/AndroidManifest.xml index fb171bca..5ae09b4a 100644 --- a/android-tablet/app/src/main/AndroidManifest.xml +++ b/android-tablet/app/src/main/AndroidManifest.xml @@ -1,5 +1,7 @@ + + , + val properties: List, + val calendarEvents: List, + val alerts: VelocityAlertSnapshot, +) + +object VelocityBackend { + private val tokenMutex = Mutex() + private var cachedToken: String? = null + + val baseUrl: String = BuildConfig.VELOCITY_BASE_URL.trimEnd('/') + val authMode: String + get() = when { + BuildConfig.VELOCITY_BEARER_TOKEN.isNotBlank() -> "Bearer token" + BuildConfig.VELOCITY_API_EMAIL.isNotBlank() && BuildConfig.VELOCITY_API_PASSWORD.isNotBlank() -> "Email/password" + else -> "Credentials required" + } + + val isConfigured: Boolean + get() = BuildConfig.VELOCITY_BEARER_TOKEN.isNotBlank() || + (BuildConfig.VELOCITY_API_EMAIL.isNotBlank() && BuildConfig.VELOCITY_API_PASSWORD.isNotBlank()) + + suspend fun fetchDashboardSnapshot(): TabletDashboardSnapshot = withContext(Dispatchers.IO) { + TabletDashboardSnapshot( + leads = fetchLeads(), + properties = fetchProperties(), + calendarEvents = fetchCalendarEvents(), + alerts = fetchAlerts(), + ) + } + + suspend fun fetchLeads(): List = withContext(Dispatchers.IO) { + val root = getJson("/api/leads") + val items = root.optJSONArray("data") ?: JSONArray() + buildList { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + add( + VelocityLead( + id = item.optString("id"), + name = item.optString("name"), + score = item.optInt("score"), + qualification = item.optString("qualification"), + kanbanStatus = item.optString("kanban_status"), + unitInterest = item.optString("unit_interest"), + budget = item.optString("budget"), + ) + ) + } + } + } + + suspend fun fetchProperties(): List = withContext(Dispatchers.IO) { + val root = getJson("/api/inventory/properties?limit=25") + val items = root.optJSONArray("properties") ?: JSONArray() + buildList { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + val location = item.optJSONObject("location") + val city = location?.optString("city").orEmpty() + val district = location?.optString("district").orEmpty() + val locationSummary = listOf(district, city).filter { it.isNotBlank() }.joinToString(", ").ifBlank { "Location pending" } + add( + VelocityProperty( + id = item.optString("property_id"), + projectName = item.optString("project_name"), + developerName = item.optString("developer_name"), + propertyType = item.optString("property_type"), + status = item.optString("status"), + locationSummary = locationSummary, + ) + ) + } + } + } + + suspend fun fetchCalendarEvents(): List = withContext(Dispatchers.IO) { + val root = getJson("/api/mobile-edge/calendar?limit=20") + val items = root.optJSONArray("events") ?: JSONArray() + buildList { + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + add( + VelocityCalendarEvent( + id = item.optString("calendar_event_id"), + title = item.optString("title"), + status = item.optString("status"), + location = item.optString("location").ifBlank { "No location" }, + startAt = item.optString("start_at"), + ) + ) + } + } + } + + suspend fun fetchAlerts(): VelocityAlertSnapshot = withContext(Dispatchers.IO) { + val root = getJson("/api/mobile-edge/alerts") + VelocityAlertSnapshot( + pendingInsights = root.optInt("pending_insights"), + pendingTranscriptions = root.optInt("pending_transcriptions"), + upcomingCalendarEvents24h = root.optInt("upcoming_calendar_events_24h"), + ) + } + + suspend fun fetchLeadEvents(leads: List, limitPerLead: Int = 2): List = withContext(Dispatchers.IO) { + val focus = leads.sortedByDescending { it.score }.take(4) + buildList { + focus.forEach { lead -> + val root = getJson("/api/mobile-edge/events?lead_id=${lead.id}&limit=$limitPerLead") + val items = root.optJSONArray("events") ?: JSONArray() + for (index in 0 until items.length()) { + val item = items.getJSONObject(index) + add( + VelocityEvent( + id = item.optString("event_id"), + leadName = lead.name, + channel = item.optString("channel"), + summary = item.optString("summary").ifBlank { "No summary available." }, + timestamp = item.optString("timestamp"), + ) + ) + } + } + } + } + + private suspend fun getToken(): String { + BuildConfig.VELOCITY_BEARER_TOKEN.takeIf { it.isNotBlank() }?.let { return it } + if (cachedToken != null) return cachedToken!! + return tokenMutex.withLock { + cachedToken?.let { return@withLock it } + if (!isConfigured) { + throw IllegalStateException("Set VELOCITY_BEARER_TOKEN or VELOCITY_API_EMAIL/VELOCITY_API_PASSWORD in Gradle properties.") + } + val body = JSONObject() + .put("email", BuildConfig.VELOCITY_API_EMAIL) + .put("password", BuildConfig.VELOCITY_API_PASSWORD) + val json = request("/api/auth/login", "POST", body.toString(), authenticated = false) + json.optString("access_token").ifBlank { + throw IllegalStateException("Velocity login did not return an access token.") + }.also { cachedToken = it } + } + } + + private suspend fun getJson(path: String): JSONObject { + val token = getToken() + return request(path, "GET", null, authenticated = true, token = token) + } + + private suspend fun request( + path: String, + method: String, + body: String?, + authenticated: Boolean, + token: String? = null, + ): JSONObject = withContext(Dispatchers.IO) { + val connection = (URL("$baseUrl$path").openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 20_000 + readTimeout = 20_000 + setRequestProperty("Accept", "application/json") + if (body != null) { + doOutput = true + setRequestProperty("Content-Type", "application/json") + } + if (authenticated) { + setRequestProperty("Authorization", "Bearer ${token.orEmpty()}") + } + } + + body?.let { payload -> + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(payload) + } + } + + val status = connection.responseCode + val stream = if (status in 200..299) connection.inputStream else connection.errorStream + val text = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty() + + if (status !in 200..299) { + val detail = runCatching { JSONObject(text).optString("detail") }.getOrNull().orEmpty() + throw IllegalStateException(detail.ifBlank { "Velocity request failed with HTTP $status." }) + } + + JSONObject(text.ifBlank { "{}" }) + } +} diff --git a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/DashboardScreen.kt b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/DashboardScreen.kt index 491addcd..aaf36f88 100644 --- a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/DashboardScreen.kt +++ b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/DashboardScreen.kt @@ -1,12 +1,50 @@ package com.desineuron.velocity.tablet.features import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import com.desineuron.velocity.tablet.data.TabletDashboardSnapshot +import com.desineuron.velocity.tablet.data.VelocityBackend @Composable fun DashboardScreen() { - FeatureScaffold( + val state by produceState?>(initialValue = null) { + value = runCatching { VelocityBackend.fetchDashboardSnapshot() } + } + + SurfaceScaffold( title = "Dashboard", - subtitle = "Sales, sentiment, and operational posture for the field team.", - chips = listOf("Visitors live", "Revenue outlook", "Queue health"), - ) + subtitle = "Live tablet posture for leads, inventory, and operator scheduling.", + ) { + when (val result = state) { + null -> LoadingCard("Fetching live leads, alerts, properties, and calendar records.") + else -> { + val snapshot = result.getOrNull() + if (snapshot != null) { + MetricRow( + Triple("Leads", snapshot.leads.size.toString(), androidx.compose.ui.graphics.Color(0xFF38BDF8)), + Triple("Whales", snapshot.leads.count { it.score >= 90 || it.qualification.equals("whale", true) }.toString(), androidx.compose.ui.graphics.Color(0xFF34D399)), + Triple("Inventory", snapshot.properties.size.toString(), androidx.compose.ui.graphics.Color(0xFFF59E0B)), + ) + DetailCard("Lead focus") { + if (snapshot.leads.isEmpty()) { + androidx.compose.material3.Text("No live leads are visible to this operator scope yet.", color = androidx.compose.ui.graphics.Color(0xFF94A3B8)) + } else { + snapshot.leads.sortedByDescending { it.score }.take(5).forEach { lead -> + androidx.compose.material3.Text("${lead.name} · ${lead.kanbanStatus} · ${lead.score}", color = androidx.compose.ui.graphics.Color.White) + androidx.compose.material3.Text("${lead.unitInterest} · ${lead.budget}", color = androidx.compose.ui.graphics.Color(0xFF94A3B8)) + } + } + } + DetailCard("Operator urgency") { + androidx.compose.material3.Text("Pending insights: ${snapshot.alerts.pendingInsights}", color = androidx.compose.ui.graphics.Color.White) + androidx.compose.material3.Text("Pending transcriptions: ${snapshot.alerts.pendingTranscriptions}", color = androidx.compose.ui.graphics.Color.White) + androidx.compose.material3.Text("Upcoming 24h calendar events: ${snapshot.alerts.upcomingCalendarEvents24h}", color = androidx.compose.ui.graphics.Color.White) + } + } else { + ErrorCard(result.exceptionOrNull()?.message ?: "Unable to reach the Velocity backend.") + } + } + } + } } diff --git a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/FeatureScaffold.kt b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/FeatureScaffold.kt index 06efa675..d26466cd 100644 --- a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/FeatureScaffold.kt +++ b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/FeatureScaffold.kt @@ -2,28 +2,30 @@ package com.desineuron.velocity.tablet.features import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Composable -fun FeatureScaffold( +fun SurfaceScaffold( title: String, subtitle: String, - chips: List, + content: @Composable ColumnScope.() -> Unit, ) { Column( modifier = Modifier - .fillMaxSize() .background(Color(0xFF05070B)) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(18.dp), @@ -32,33 +34,70 @@ fun FeatureScaffold( Text(title, style = MaterialTheme.typography.headlineMedium, color = Color.White) Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8)) } + content() + } +} - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - chips.forEach { chip -> - Text( - text = chip, - color = Color.White, - style = MaterialTheme.typography.labelMedium, +@Composable +fun MetricRow(vararg items: Triple) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + items.forEach { (title, value, color) -> + Column( + modifier = Modifier + .weight(1f) + .background(Color(0xFF0B1220), RoundedCornerShape(24.dp)) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title.uppercase(), style = MaterialTheme.typography.labelSmall, color = Color(0xFF94A3B8)) + Text(value, style = MaterialTheme.typography.headlineSmall, color = Color.White) + Box( modifier = Modifier - .background(Color(0xFF111827), RoundedCornerShape(14.dp)) - .padding(horizontal = 14.dp, vertical = 10.dp), + .background(color, RoundedCornerShape(8.dp)) + .padding(horizontal = 24.dp, vertical = 2.dp) ) } } + } +} - Column( - modifier = Modifier - .fillMaxWidth() - .background(Color(0xFF0B1220), RoundedCornerShape(28.dp)) - .padding(22.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text("Scaffold state", style = MaterialTheme.typography.titleMedium, color = Color.White) - Text( - "This screen is wired into the tablet navigation graph and is ready for the shared contract package once the API clients are connected.", - style = MaterialTheme.typography.bodyMedium, - color = Color(0xFF94A3B8), - ) +@Composable +fun DetailCard( + title: String, + body: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF0B1220), RoundedCornerShape(24.dp)) + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium, color = Color.White) + body() + } +} + +@Composable +fun LoadingCard(message: String) { + DetailCard(title = "Loading") { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(color = Color(0xFF38BDF8)) + Text(message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8)) } } } + +@Composable +fun ErrorCard(message: String) { + DetailCard(title = "Live backend error") { + Text(message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFFCA5A5)) + } +} + +@Composable +fun EmptyCard(message: String) { + DetailCard(title = "No live data") { + Text(message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8)) + } +} diff --git a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/InventoryScreen.kt b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/InventoryScreen.kt index a2af1c3f..42a1f729 100644 --- a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/InventoryScreen.kt +++ b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/InventoryScreen.kt @@ -1,12 +1,47 @@ package com.desineuron.velocity.tablet.features +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.tablet.data.VelocityBackend +import com.desineuron.velocity.tablet.data.VelocityProperty @Composable fun InventoryScreen() { - FeatureScaffold( + val state by produceState>?>(initialValue = null) { + value = runCatching { VelocityBackend.fetchProperties() } + } + + SurfaceScaffold( title = "Inventory", - subtitle = "Property catalog, media assets, and ingest lifecycle visibility.", - chips = listOf("Import batches", "Listings", "Validation state"), - ) + subtitle = "Live property catalog pulled from the inventory pipeline.", + ) { + when (val result = state) { + null -> LoadingCard("Fetching live inventory properties.") + else -> { + val properties = result.getOrNull() + if (properties != null) { + MetricRow( + Triple("Properties", properties.size.toString(), Color(0xFF38BDF8)), + Triple("Active", properties.count { it.status.equals("active", true) }.toString(), Color(0xFF34D399)), + Triple("Draft", properties.count { it.status.equals("draft", true) }.toString(), Color(0xFFF59E0B)), + ) + if (properties.isEmpty()) { + EmptyCard("No inventory properties have been ingested for this tenant yet.") + } else { + DetailCard("Property list") { + properties.take(8).forEach { property -> + Text("${property.projectName} · ${property.propertyType}", color = Color.White) + Text("${property.developerName} · ${property.locationSummary}", color = Color(0xFF94A3B8)) + } + } + } + } else { + ErrorCard(result.exceptionOrNull()?.message ?: "Unable to fetch inventory properties.") + } + } + } + } } diff --git a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/OracleScreen.kt b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/OracleScreen.kt index ca1f3c18..203f9be3 100644 --- a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/OracleScreen.kt +++ b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/OracleScreen.kt @@ -1,12 +1,50 @@ package com.desineuron.velocity.tablet.features +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.tablet.data.VelocityBackend +import com.desineuron.velocity.tablet.data.VelocityLead @Composable fun OracleScreen() { - FeatureScaffold( + val state by produceState>?>(initialValue = null) { + value = runCatching { VelocityBackend.fetchLeads() } + } + + SurfaceScaffold( title = "Oracle", - subtitle = "Template-guided intelligence views for pipeline and scheduling.", - chips = listOf("Pipeline", "Lead map", "Calendar tasks"), - ) + subtitle = "Pipeline intelligence rendered only from live CRM state.", + ) { + when (val result = state) { + null -> LoadingCard("Fetching live pipeline rows.") + else -> { + val leads = result.getOrNull() + if (leads != null) { + val grouped = leads.groupBy { it.kanbanStatus.ifBlank { "unclassified" } } + MetricRow( + Triple("Pipeline", leads.size.toString(), Color(0xFF38BDF8)), + Triple("Whales", leads.count { it.qualification.equals("whale", true) }.toString(), Color(0xFF34D399)), + Triple("Stages", grouped.keys.size.toString(), Color(0xFFA78BFA)), + ) + if (leads.isEmpty()) { + EmptyCard("No live leads are available for Oracle on this surface.") + } else { + grouped.toSortedMap().forEach { (stage, items) -> + DetailCard(stage.replace('_', ' ').replaceFirstChar { it.uppercase() }) { + items.sortedByDescending { it.score }.take(5).forEach { lead -> + Text("${lead.name} · ${lead.score}", color = Color.White) + Text("${lead.qualification} · ${lead.unitInterest} · ${lead.budget}", color = Color(0xFF94A3B8)) + } + } + } + } + } else { + ErrorCard(result.exceptionOrNull()?.message ?: "Unable to fetch Oracle pipeline data.") + } + } + } + } } diff --git a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SentinelScreen.kt b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SentinelScreen.kt index 2b89c54d..12729c73 100644 --- a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SentinelScreen.kt +++ b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SentinelScreen.kt @@ -1,12 +1,43 @@ package com.desineuron.velocity.tablet.features +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.tablet.data.VelocityAlertSnapshot +import com.desineuron.velocity.tablet.data.VelocityBackend @Composable fun SentinelScreen() { - FeatureScaffold( + val state by produceState?>(initialValue = null) { + value = runCatching { VelocityBackend.fetchAlerts() } + } + + SurfaceScaffold( title = "Sentinel", - subtitle = "Biometric and sentiment awareness stream for visitor sessions.", - chips = listOf("Live session", "Journey river", "QD overlays"), - ) + subtitle = "Truthful live urgency view with visitor analytics intentionally disabled until a real feed exists.", + ) { + when (val result = state) { + null -> LoadingCard("Fetching live alert posture.") + else -> { + val alerts = result.getOrNull() + if (alerts != null) { + MetricRow( + Triple("Insights", alerts.pendingInsights.toString(), Color(0xFFFB7185)), + Triple("Transcripts", alerts.pendingTranscriptions.toString(), Color(0xFFF59E0B)), + Triple("24h calendar", alerts.upcomingCalendarEvents24h.toString(), Color(0xFF34D399)), + ) + DetailCard("Sentinel availability") { + Text( + "This Android tablet build does not generate synthetic visitor analytics. A production Sentinel stream is still required before biometrics or sentiment metrics can be shown safely.", + color = Color(0xFF94A3B8), + ) + } + } else { + ErrorCard(result.exceptionOrNull()?.message ?: "Unable to fetch Sentinel alert posture.") + } + } + } + } } diff --git a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SettingsScreen.kt b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SettingsScreen.kt index bd2b4d94..c2f0cdad 100644 --- a/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SettingsScreen.kt +++ b/android-tablet/app/src/main/java/com/desineuron/velocity/tablet/features/SettingsScreen.kt @@ -1,12 +1,30 @@ package com.desineuron.velocity.tablet.features +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.desineuron.velocity.tablet.data.VelocityBackend @Composable fun SettingsScreen() { - FeatureScaffold( + SurfaceScaffold( title = "Settings", - subtitle = "Surface registration, connection state, and operator preferences.", - chips = listOf("Install info", "API endpoint", "Operator profile"), - ) + subtitle = "Runtime configuration for the production tablet surface.", + ) { + DetailCard("Connectivity") { + Text("Backend: ${VelocityBackend.baseUrl}", color = Color.White) + Text("Auth mode: ${VelocityBackend.authMode}", color = Color(0xFF94A3B8)) + Text( + if (VelocityBackend.isConfigured) "Live credentials configured." + else "Credentials missing. Configure Gradle properties before production use.", + color = if (VelocityBackend.isConfigured) Color(0xFF34D399) else Color(0xFFF59E0B), + ) + } + DetailCard("Production notes") { + Text( + "This tablet build only renders live backend data. Surfaces without production routes stay intentionally unavailable instead of falling back to placeholders.", + color = Color(0xFF94A3B8), + ) + } + } } diff --git a/backend/api/routes_mobile_edge.py b/backend/api/routes_mobile_edge.py index 10022c16..1e0c95a7 100644 --- a/backend/api/routes_mobile_edge.py +++ b/backend/api/routes_mobile_edge.py @@ -610,26 +610,49 @@ async def session_heartbeat( pool = _pool(request) import json async with pool.acquire() as conn: - row = await conn.fetchrow( + existing_session_id = await conn.fetchval( """ - INSERT INTO surface_sessions (tenant_id, user_id, surface_type, app_version, metadata) - VALUES ($1, $2, $3, $4, $5::jsonb) - ON CONFLICT DO NOTHING - RETURNING session_id + SELECT session_id + FROM surface_sessions + WHERE tenant_id=$1 AND user_id=$2 AND surface_type=$3 + AND ended_at IS NULL + AND last_active_at > NOW() - INTERVAL '30 minutes' + ORDER BY last_active_at DESC + LIMIT 1 """, - user.role, user.user_id, body.surface_type, body.app_version, - json.dumps(body.metadata), + user.role, user.user_id, body.surface_type, ) - # Update last_active + screen_sequence for existing session (within 30 min) - if body.screen and row is None: + + if existing_session_id: await conn.execute( """ UPDATE surface_sessions SET last_active_at=NOW(), - screen_sequence = array_append(screen_sequence, $1) - WHERE tenant_id=$2 AND user_id=$3 AND surface_type=$4 - AND last_active_at > NOW() - INTERVAL '30 minutes' + app_version=$1, + metadata=$2::jsonb, + screen_sequence = CASE + WHEN $3::text IS NULL THEN screen_sequence + ELSE array_append(screen_sequence, $3::text) + END + WHERE session_id=$4 """, - body.screen, user.role, user.user_id, body.surface_type, + body.app_version, json.dumps(body.metadata), body.screen, existing_session_id, + ) + else: + await conn.execute( + """ + INSERT INTO surface_sessions ( + tenant_id, user_id, surface_type, app_version, metadata, screen_sequence + ) + VALUES ( + $1, $2, $3, $4, $5::jsonb, + CASE + WHEN $6::text IS NULL THEN '{}'::text[] + ELSE ARRAY[$6::text] + END + ) + """, + user.role, user.user_id, body.surface_type, body.app_version, + json.dumps(body.metadata), body.screen, ) return {"status": "ok", "timestamp": _now()} diff --git a/iOS/velocity-edge-phone/EdgeRootView.swift b/iOS/velocity-edge-phone/EdgeRootView.swift deleted file mode 100644 index ae23fd5f..00000000 --- a/iOS/velocity-edge-phone/EdgeRootView.swift +++ /dev/null @@ -1,40 +0,0 @@ -import SwiftUI - -enum EdgeSection: String, CaseIterable, Identifiable { - case alerts = "Alerts" - case leadSummary = "Lead Summary" - case communications = "Communications" - case notes = "Notes" - case transcriptions = "Transcriptions" - case settings = "Settings" - - var id: String { rawValue } -} - -struct EdgeRootView: View { - @State private var selectedSection: EdgeSection = .alerts - - var body: some View { - TabView(selection: $selectedSection) { - EdgeAlertsView() - .tabItem { Label("Alerts", systemImage: "bell.badge") } - .tag(EdgeSection.alerts) - EdgeLeadSummaryView() - .tabItem { Label("Lead", systemImage: "person.text.rectangle") } - .tag(EdgeSection.leadSummary) - EdgeCommunicationsView() - .tabItem { Label("Comms", systemImage: "phone.connection") } - .tag(EdgeSection.communications) - EdgeNotesView() - .tabItem { Label("Notes", systemImage: "square.and.pencil") } - .tag(EdgeSection.notes) - EdgeTranscriptionsView() - .tabItem { Label("Transcripts", systemImage: "waveform.badge.magnifyingglass") } - .tag(EdgeSection.transcriptions) - EdgeSettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") } - .tag(EdgeSection.settings) - } - .tint(Color(red: 0.22, green: 0.60, blue: 0.98)) - } -} diff --git a/iOS/velocity-edge-phone/EdgeScaffold.swift b/iOS/velocity-edge-phone/EdgeScaffold.swift deleted file mode 100644 index d97a4a43..00000000 --- a/iOS/velocity-edge-phone/EdgeScaffold.swift +++ /dev/null @@ -1,56 +0,0 @@ -import SwiftUI - -struct EdgeScaffold: View { - let title: String - let subtitle: String - let actionLabel: String - - var body: some View { - ZStack { - LinearGradient( - colors: [ - Color(red: 0.03, green: 0.05, blue: 0.08), - Color.black, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .ignoresSafeArea() - - VStack(alignment: .leading, spacing: 18) { - Text(title) - .font(.system(size: 30, weight: .bold)) - .foregroundStyle(.white) - Text(subtitle) - .font(.system(size: 14)) - .foregroundStyle(Color.white.opacity(0.7)) - - VStack(alignment: .leading, spacing: 8) { - Text("EDGE ACTION") - .font(.system(size: 10, weight: .semibold)) - .tracking(1.4) - .foregroundStyle(Color(red: 0.22, green: 0.60, blue: 0.98)) - Text(actionLabel) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(.white) - Text("This narrow surface is ready for `/api/mobile-edge` hookup once auth, installs, and heartbeat registration are connected.") - .font(.system(size: 13)) - .foregroundStyle(Color.white.opacity(0.72)) - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22) - .fill(Color.white.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 22) - .stroke(Color.white.opacity(0.08), lineWidth: 1) - ) - ) - - Spacer() - } - .padding(24) - } - } -} diff --git a/iOS/velocity-edge-phone/Features/EdgeAlertsView.swift b/iOS/velocity-edge-phone/Features/EdgeAlertsView.swift deleted file mode 100644 index ccdadcd9..00000000 --- a/iOS/velocity-edge-phone/Features/EdgeAlertsView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct EdgeAlertsView: View { - var body: some View { - EdgeScaffold( - title: "Alerts", - subtitle: "Unread lead responses, callback urgency, and showroom event nudges for field operators.", - actionLabel: "Respond to unread whale-lead thread" - ) - } -} diff --git a/iOS/velocity-edge-phone/Features/EdgeCommunicationsView.swift b/iOS/velocity-edge-phone/Features/EdgeCommunicationsView.swift deleted file mode 100644 index d819e45d..00000000 --- a/iOS/velocity-edge-phone/Features/EdgeCommunicationsView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct EdgeCommunicationsView: View { - var body: some View { - EdgeScaffold( - title: "Communications", - subtitle: "Calls, WhatsApp touchpoints, and imported operator activity in one surface.", - actionLabel: "Log a manual communication note" - ) - } -} diff --git a/iOS/velocity-edge-phone/Features/EdgeLeadSummaryView.swift b/iOS/velocity-edge-phone/Features/EdgeLeadSummaryView.swift deleted file mode 100644 index f39179c3..00000000 --- a/iOS/velocity-edge-phone/Features/EdgeLeadSummaryView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct EdgeLeadSummaryView: View { - var body: some View { - EdgeScaffold( - title: "Lead Summary", - subtitle: "Compact account memory, qualification signals, and next-best action.", - actionLabel: "Review Mohammed Al-Rashid context" - ) - } -} diff --git a/iOS/velocity-edge-phone/Features/EdgeNotesView.swift b/iOS/velocity-edge-phone/Features/EdgeNotesView.swift deleted file mode 100644 index 8f90a246..00000000 --- a/iOS/velocity-edge-phone/Features/EdgeNotesView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct EdgeNotesView: View { - var body: some View { - EdgeScaffold( - title: "Notes", - subtitle: "Quick capture for memory facts, objections, and promised follow-ups.", - actionLabel: "Save a note with memory extraction hints" - ) - } -} diff --git a/iOS/velocity-edge-phone/Features/EdgeSettingsView.swift b/iOS/velocity-edge-phone/Features/EdgeSettingsView.swift deleted file mode 100644 index d89fb241..00000000 --- a/iOS/velocity-edge-phone/Features/EdgeSettingsView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct EdgeSettingsView: View { - var body: some View { - EdgeScaffold( - title: "Settings", - subtitle: "Install registration, operator identity, and backend connection state.", - actionLabel: "Verify surface heartbeat and app version" - ) - } -} diff --git a/iOS/velocity-edge-phone/Features/EdgeTranscriptionsView.swift b/iOS/velocity-edge-phone/Features/EdgeTranscriptionsView.swift deleted file mode 100644 index bc31a018..00000000 --- a/iOS/velocity-edge-phone/Features/EdgeTranscriptionsView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct EdgeTranscriptionsView: View { - var body: some View { - EdgeScaffold( - title: "Transcriptions", - subtitle: "Imported voice artifacts and transcript summaries for field follow-up.", - actionLabel: "Review pending recording import" - ) - } -} diff --git a/iOS/velocity-edge-phone/README.md b/iOS/velocity-edge-phone/README.md deleted file mode 100644 index fdac3d43..00000000 --- a/iOS/velocity-edge-phone/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Velocity Edge Phone - -SwiftUI scaffold for the narrow phone companion surface. This folder is intentionally source-first so it can be dropped into a new Xcode target without carrying repo-wide project changes during MVP. diff --git a/iOS/velocity-edge-phone/VelocityEdgePhoneApp.swift b/iOS/velocity-edge-phone/VelocityEdgePhoneApp.swift deleted file mode 100644 index dec399ad..00000000 --- a/iOS/velocity-edge-phone/VelocityEdgePhoneApp.swift +++ /dev/null @@ -1,10 +0,0 @@ -import SwiftUI - -@main -struct VelocityEdgePhoneApp: App { - var body: some Scene { - WindowGroup { - EdgeRootView() - } - } -} diff --git a/iOS/velocity-iphone/Config.xcconfig.example b/iOS/velocity-iphone/Config.xcconfig.example new file mode 100644 index 00000000..4c0f0881 --- /dev/null +++ b/iOS/velocity-iphone/Config.xcconfig.example @@ -0,0 +1,5 @@ +BASE_URL = https://api.desineuron.in +API_EMAIL = +API_PASSWORD = +API_BEARER_TOKEN = +APP_VERSION = 1.0.0 diff --git a/iOS/velocity-iphone/Info.plist b/iOS/velocity-iphone/Info.plist new file mode 100644 index 00000000..4328d4ce --- /dev/null +++ b/iOS/velocity-iphone/Info.plist @@ -0,0 +1,32 @@ + + + + + API_BEARER_TOKEN + $(API_BEARER_TOKEN) + API_EMAIL + $(API_EMAIL) + API_PASSWORD + $(API_PASSWORD) + APP_VERSION + $(APP_VERSION) + BASE_URL + $(BASE_URL) + NSCameraUsageDescription + Velocity needs camera access to capture live room imagery for Dream Weaver. + NSPhotoLibraryUsageDescription + Velocity needs photo library access so you can select room images for Dream Weaver. + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + diff --git a/iOS/velocity-iphone/README.md b/iOS/velocity-iphone/README.md new file mode 100644 index 00000000..de986055 --- /dev/null +++ b/iOS/velocity-iphone/README.md @@ -0,0 +1,42 @@ +# Velocity iPhone App + +Dedicated iPhone source tree for the Velocity edge-phone surface. + +Goals: +- preserve the visual language of the iPad Velocity app +- match the production feature scope of `android-edge-phone` +- use live backend data only +- register `iphone_edge` surface heartbeats against `/api/mobile-edge/session` + +Contents: +- `VelocityIPhoneApp.swift` app entry +- `EdgeRootView.swift` tab shell +- `Core/` shared config, networking, state, and styling +- `Features/` Alerts, Lead Summary, Communications, Notes, Transcriptions, Settings + +Configuration: +1. Open `velocity-iphone.xcodeproj` in Xcode. +2. If you want explicit per-build config values, copy `Config.xcconfig.example` to a local `Config.xcconfig` and attach it to the target build configurations. +3. Fill in either: + - `API_BEARER_TOKEN` + - or `API_EMAIL` and `API_PASSWORD` +4. Keep `BASE_URL` pointed at the live Velocity backend unless you intentionally override it. + +Notes: +- This source tree is intended to supersede the earlier lightweight `velocity-edge-phone` scaffold. +- The backend routes already exist in `backend/api/routes_mobile_edge.py`; this app consumes them directly. + +Xcode test flow: +1. Open `iOS/velocity-iphone/velocity-iphone.xcodeproj`. +2. Select the `velocity-iphone` scheme and an iPhone simulator such as `iPhone 16 Pro`. +3. In `Signing & Capabilities`, choose your Apple development team and let Xcode resolve the bundle signing settings. +4. In `Build Settings`, confirm `Info.plist File` points to `velocity-iphone/Info.plist`. +5. If you are using live credentials through build settings, set `BASE_URL`, plus either `API_BEARER_TOKEN` or `API_EMAIL` and `API_PASSWORD`. +6. Press `Cmd+B` to confirm the app builds. +7. Press `Cmd+R` to launch the simulator. +8. Verify the bottom-tab shell renders Alerts, Lead Summary, Communications, Notes, Transcriptions, and Settings. +9. In Settings, confirm the app reports the expected auth mode and live backend base URL. +10. Trigger a manual refresh or wait for auto-refresh, then verify Alerts and Lead Summary populate from the live backend. +11. Create a note and confirm it appears without mock fallback behavior. +12. Open Transcriptions and verify empty states are truthful when no live transcript exists. +13. Review backend logs or database state to confirm `/api/mobile-edge/session` heartbeats are updating the active `iphone_edge` session window. diff --git a/iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj b/iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj new file mode 100644 index 00000000..3cf8c325 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj @@ -0,0 +1,339 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + B31C10012F58D9C300A74A49 /* velocity-iphone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "velocity-iphone.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + B31C10032F58D9C300A74A49 /* velocity-iphone */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "velocity-iphone"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + B31C10062F58D9C300A74A49 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + B31C10072F58D9C300A74A49 = { + isa = PBXGroup; + children = ( + B31C10032F58D9C300A74A49 /* velocity-iphone */, + B31C10082F58D9C300A74A49 /* Products */, + ); + sourceTree = ""; + }; + B31C10082F58D9C300A74A49 /* Products */ = { + isa = PBXGroup; + children = ( + B31C10012F58D9C300A74A49 /* velocity-iphone.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + B31C10052F58D9C300A74A49 /* velocity-iphone */ = { + isa = PBXNativeTarget; + buildConfigurationList = B31C10112F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity-iphone" */; + buildPhases = ( + B31C10092F58D9C300A74A49 /* Sources */, + B31C10062F58D9C300A74A49 /* Frameworks */, + B31C100A2F58D9C300A74A49 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + B31C10032F58D9C300A74A49 /* velocity-iphone */, + ); + name = "velocity-iphone"; + packageProductDependencies = ( + ); + productName = "velocity-iphone"; + productReference = B31C10012F58D9C300A74A49 /* velocity-iphone.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + B31C100B2F58D9C300A74A49 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + B31C10052F58D9C300A74A49 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = B31C100C2F58D9C300A74A49 /* Build configuration list for PBXProject "velocity-iphone" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = B31C10072F58D9C300A74A49; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = B31C10082F58D9C300A74A49 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + B31C10052F58D9C300A74A49 /* velocity-iphone */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + B31C100A2F58D9C300A74A49 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + B31C10092F58D9C300A74A49 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + B31C100D2F58D9C400A74A49 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + B31C100E2F58D9C400A74A49 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + B31C100F2F58D9C400A74A49 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L29922NHD9; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Velocity Iphone App"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.desineuron.velocity.iphone; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + B31C10102F58D9C400A74A49 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L29922NHD9; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Velocity Iphone App"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = in.desineuron.velocity.iphone; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + B31C100C2F58D9C300A74A49 /* Build configuration list for PBXProject "velocity-iphone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B31C100D2F58D9C400A74A49 /* Debug */, + B31C100E2F58D9C400A74A49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + B31C10112F58D9C400A74A49 /* Build configuration list for PBXNativeTarget "velocity-iphone" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + B31C100F2F58D9C400A74A49 /* Debug */, + B31C10102F58D9C400A74A49 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = B31C100B2F58D9C300A74A49 /* Project object */; +} diff --git a/iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..3e45c0ac --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFF", + "green" : "0x7F", + "red" : "0x3F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..23058801 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/Contents.json b/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/Config/EdgeAppConfig.swift b/iOS/velocity-iphone/velocity-iphone/Core/Config/EdgeAppConfig.swift new file mode 100644 index 00000000..ffe3e558 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/Config/EdgeAppConfig.swift @@ -0,0 +1,34 @@ +import Foundation + +enum EdgeAppConfig { + private static func value(for key: String) -> String? { + guard let raw = Bundle.main.infoDictionary?[key] as? String else { + return nil + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || trimmed == "$(\(key))" { + return nil + } + return trimmed + } + + static let baseURL: String = value(for: "BASE_URL") ?? "https://api.desineuron.in" + static let apiEmail: String? = value(for: "API_EMAIL") + static let apiPassword: String? = value(for: "API_PASSWORD") + static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN") + static let appVersion: String = value(for: "APP_VERSION") ?? "1.0.0" + + static var authModeDescription: String { + if apiBearerToken != nil { + return "Bearer token" + } + if apiEmail != nil && apiPassword != nil { + return "Email/password" + } + return "Credentials required" + } + + static var isConfigured: Bool { + apiBearerToken != nil || (apiEmail != nil && apiPassword != nil) + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityEdgeAPIClient.swift b/iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityEdgeAPIClient.swift new file mode 100644 index 00000000..0ba1091c --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityEdgeAPIClient.swift @@ -0,0 +1,319 @@ +import Foundation + +struct EdgeLeadDTO: Decodable, Identifiable { + let id: String + let name: String + let score: Int + let qualification: String + let unitInterest: String + let budget: String + + enum CodingKeys: String, CodingKey { + case id + case name + case score + case qualification + case unitInterest = "unit_interest" + case budget + } +} + +struct EdgeAlertSnapshotDTO: Decodable { + let pendingInsights: Int + let pendingTranscriptions: Int + let upcomingCalendarEvents24h: Int + let generatedAt: String + + enum CodingKeys: String, CodingKey { + case pendingInsights = "pending_insights" + case pendingTranscriptions = "pending_transcriptions" + case upcomingCalendarEvents24h = "upcoming_calendar_events_24h" + case generatedAt = "generated_at" + } +} + +struct EdgeCommunicationEventDTO: Decodable, Identifiable { + let eventId: String + let leadId: String + let channel: String + let summary: String? + let timestamp: String + let recordingRef: String? + + var id: String { eventId } + + enum CodingKeys: String, CodingKey { + case eventId = "event_id" + case leadId = "lead_id" + case channel + case summary + case timestamp + case recordingRef = "recording_ref" + } +} + +struct EdgeMemoryFactDTO: Decodable, Identifiable { + let factId: String + let factType: String + let factText: String + let createdAt: String + + var id: String { factId } + + enum CodingKeys: String, CodingKey { + case factId = "fact_id" + case factType = "fact_type" + case factText = "fact_text" + case createdAt = "created_at" + } +} + +struct EdgeTranscriptDTO { + let eventId: String + let status: String + let segmentCount: Int +} + +enum VelocityEdgeAPIError: LocalizedError { + case notConfigured(String) + case invalidResponse + case api(String) + + var errorDescription: String? { + switch self { + case .notConfigured(let message): + return message + case .invalidResponse: + return "Velocity edge backend returned an invalid response." + case .api(let message): + return message + } + } +} + +actor VelocityEdgeAPIClient { + static let shared = VelocityEdgeAPIClient() + + private struct LoginBody: Encodable { + let email: String + let password: String + } + + private struct LoginResponse: Decodable { + let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } + } + + private struct LeadsEnvelope: Decodable { + let data: [EdgeLeadDTO] + } + + private struct EventsEnvelope: Decodable { + let events: [EdgeCommunicationEventDTO] + } + + private struct FactsEnvelope: Decodable { + let facts: [EdgeMemoryFactDTO] + } + + private struct NoteResponse: Decodable { + let factId: String? + + enum CodingKeys: String, CodingKey { + case factId = "fact_id" + } + } + + private struct TranscriptEnvelope: Decodable { + struct Job: Decodable { + let status: String + } + + let job: Job + let segments: [TranscriptSegment] + } + + private struct TranscriptSegment: Decodable { + let segmentId: String + + enum CodingKeys: String, CodingKey { + case segmentId = "segment_id" + } + } + + private struct HeartbeatBody: Encodable { + let surfaceType: String + let appVersion: String + let screen: String? + let metadata: [String: String] + + enum CodingKeys: String, CodingKey { + case surfaceType = "surface_type" + case appVersion = "app_version" + case screen + case metadata + } + } + + private let decoder = JSONDecoder() + private var cachedToken: String? + + func fetchLeads() async throws -> [EdgeLeadDTO] { + let request = try await authorizedRequest(path: "/api/leads") + let response: LeadsEnvelope = try await perform(request) + return response.data + } + + func fetchAlerts() async throws -> EdgeAlertSnapshotDTO { + let request = try await authorizedRequest(path: "/api/mobile-edge/alerts") + return try await perform(request) + } + + func fetchEvents(for leadId: String, limit: Int = 4) async throws -> [EdgeCommunicationEventDTO] { + let request = try await authorizedRequest( + path: "/api/mobile-edge/events", + queryItems: [ + URLQueryItem(name: "lead_id", value: leadId), + URLQueryItem(name: "limit", value: String(limit)), + ] + ) + let response: EventsEnvelope = try await perform(request) + return response.events + } + + func fetchMemoryFacts(for leadId: String, limit: Int = 10) async throws -> [EdgeMemoryFactDTO] { + let request = try await authorizedRequest( + path: "/api/mobile-edge/memory", + queryItems: [ + URLQueryItem(name: "lead_id", value: leadId), + URLQueryItem(name: "limit", value: String(limit)), + ] + ) + let response: FactsEnvelope = try await perform(request) + return response.facts + } + + func createNote(leadId: String, noteText: String) async throws { + let payload = [ + "lead_id": leadId, + "note_text": noteText, + "fact_type": "custom", + ] + var request = try await authorizedRequest(path: "/api/mobile-edge/notes") + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + let _: NoteResponse = try await perform(request) + } + + func fetchTranscript(for eventId: String) async throws -> EdgeTranscriptDTO { + let request = try await authorizedRequest(path: "/api/mobile-edge/transcripts/\(eventId)") + let response: TranscriptEnvelope = try await perform(request) + return EdgeTranscriptDTO(eventId: eventId, status: response.job.status, segmentCount: response.segments.count) + } + + func registerHeartbeat(screen: String?) async throws { + var request = try await authorizedRequest(path: "/api/mobile-edge/session") + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode( + HeartbeatBody( + surfaceType: "iphone_edge", + appVersion: EdgeAppConfig.appVersion, + screen: screen, + metadata: [ + "client": "velocity-iphone", + "platform": "ios", + ] + ) + ) + let _: EmptyResponse = try await perform(request) + } + + private func authorizedRequest(path: String, queryItems: [URLQueryItem] = []) async throws -> URLRequest { + guard let url = buildURL(path: path, queryItems: queryItems) else { + throw VelocityEdgeAPIError.notConfigured("Velocity backend base URL is invalid.") + } + let token = try await getToken() + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 30 + return request + } + + private func buildURL(path: String, queryItems: [URLQueryItem]) -> URL? { + guard var components = URLComponents(string: EdgeAppConfig.baseURL) else { + return nil + } + components.path = path + if !queryItems.isEmpty { + components.queryItems = queryItems + } + return components.url + } + + private func getToken() async throws -> String { + if let token = EdgeAppConfig.apiBearerToken { + return token + } + if let token = cachedToken { + return token + } + guard let email = EdgeAppConfig.apiEmail, let password = EdgeAppConfig.apiPassword else { + throw VelocityEdgeAPIError.notConfigured( + "Set API_BEARER_TOKEN or API_EMAIL/API_PASSWORD in the iPhone app configuration." + ) + } + + guard let loginURL = buildURL(path: "/api/auth/login", queryItems: []) else { + throw VelocityEdgeAPIError.notConfigured("Velocity backend base URL is invalid.") + } + + var request = URLRequest(url: loginURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONEncoder().encode(LoginBody(email: email, password: password)) + request.timeoutInterval = 30 + + let response: LoginResponse = try await perform(request) + cachedToken = response.accessToken + return response.accessToken + } + + private func perform(_ request: URLRequest) async throws -> T { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw VelocityEdgeAPIError.invalidResponse + } + guard 200..<300 ~= http.statusCode else { + if let apiError = try? decoder.decode(APIErrorPayload.self, from: data), let detail = apiError.detail { + throw VelocityEdgeAPIError.api(detail) + } + throw VelocityEdgeAPIError.api("Velocity edge request failed with HTTP \(http.statusCode).") + } + do { + return try decoder.decode(T.self, from: data) + } catch { + throw VelocityEdgeAPIError.invalidResponse + } + } +} + +private struct EmptyResponse: Decodable {} + +private struct APIErrorPayload: Decodable { + let detail: String? +} + +private let edgeDateFormatter = ISO8601DateFormatter() + +extension EdgeCommunicationEventDTO { + var timestampDate: Date? { + edgeDateFormatter.date(from: timestamp) + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityLiveAPI.swift b/iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityLiveAPI.swift new file mode 100644 index 00000000..8f0930eb --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/Networking/VelocityLiveAPI.swift @@ -0,0 +1,914 @@ +import Foundation +import UIKit + +struct VelocityAuthProfile: Decodable, Sendable { + let userId: String + let role: String + let fullName: String? + let email: String? + let avatarURL: String? + + enum CodingKeys: String, CodingKey { + case userId = "user_id" + case role + case fullName = "full_name" + case email + case avatarURL = "avatar_url" + } +} + +struct VelocityLoginResponse: Decodable, Sendable { + let accessToken: String + let tokenType: String + let expiresIn: Int + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case expiresIn = "expires_in" + } +} + +struct VelocityLead: Decodable, Identifiable, Sendable { + let id: String + let name: String + let email: String? + let phone: String? + let source: String? + let notes: String? + let qualification: String + let score: Int + let kanbanStatus: String + let stage: String + let budget: String + let unitInterest: String + + enum CodingKeys: String, CodingKey { + case id, name, email, phone, source, notes, qualification, score, stage, budget + case kanbanStatus = "kanban_status" + case unitInterest = "unit_interest" + } +} + +struct VelocityLeadEnvelope: Decodable, Sendable { + let data: [VelocityLead] +} + +struct VelocityAlertSnapshot: Decodable, Sendable { + let pendingInsights: Int + let upcomingCalendarEvents24h: Int + let pendingTranscriptions: Int + let generatedAt: String + + enum CodingKeys: String, CodingKey { + case pendingInsights = "pending_insights" + case upcomingCalendarEvents24h = "upcoming_calendar_events_24h" + case pendingTranscriptions = "pending_transcriptions" + case generatedAt = "generated_at" + } +} + +struct VelocityCommunicationEvent: Decodable, Identifiable, Sendable { + let eventId: String + let leadId: String + let channel: String + let direction: String? + let summary: String? + let timestamp: String + let recordingRef: String? + + var id: String { eventId } + + enum CodingKeys: String, CodingKey { + case eventId = "event_id" + case leadId = "lead_id" + case channel, direction, summary, timestamp + case recordingRef = "recording_ref" + } +} + +struct VelocityCommunicationEnvelope: Decodable, Sendable { + let events: [VelocityCommunicationEvent] +} + +struct VelocityMemoryFact: Decodable, Identifiable, Sendable { + let factId: String + let factType: String + let factText: String + let createdAt: String + + var id: String { factId } + + enum CodingKeys: String, CodingKey { + case factId = "fact_id" + case factType = "fact_type" + case factText = "fact_text" + case createdAt = "created_at" + } +} + +struct VelocityMemoryEnvelope: Decodable, Sendable { + let facts: [VelocityMemoryFact] +} + +struct VelocityTranscriptJob: Decodable, Sendable { + let status: String + let provider: String? + let speakerCount: Int? + let wordCount: Int? + let language: String? + + enum CodingKeys: String, CodingKey { + case status, provider, language + case speakerCount = "speaker_count" + case wordCount = "word_count" + } +} + +struct VelocityTranscriptSegment: Decodable, Identifiable, Sendable { + let segmentId: String + let speakerLabel: String? + let startMs: Int? + let endMs: Int? + let text: String + + var id: String { segmentId } + + enum CodingKeys: String, CodingKey { + case segmentId = "segment_id" + case speakerLabel = "speaker_label" + case startMs = "start_ms" + case endMs = "end_ms" + case text + } +} + +struct VelocityTranscriptEnvelope: Decodable, Sendable { + let job: VelocityTranscriptJob + let segments: [VelocityTranscriptSegment] +} + +struct VelocityCalendarEvent: Decodable, Identifiable, Sendable { + let calendarEventId: String + let leadId: String? + let title: String + let description: String? + let startAt: String + let endAt: String + let allDay: Bool + let status: String + let reminderMinutes: [Int] + let location: String? + + var id: String { calendarEventId } + + enum CodingKeys: String, CodingKey { + case calendarEventId = "calendar_event_id" + case leadId = "lead_id" + case title, description, status, location + case startAt = "start_at" + case endAt = "end_at" + case allDay = "all_day" + case reminderMinutes = "reminder_minutes" + } +} + +struct VelocityCalendarEnvelope: Decodable, Sendable { + let events: [VelocityCalendarEvent] +} + +struct VelocityInsight: Decodable, Identifiable, Sendable { + let recommendationId: String + let leadId: String + let recommendationType: String + let summary: String + let suggestedAction: String? + let targetSystem: String? + let status: String + let confidence: Double? + let createdAt: String + + var id: String { recommendationId } + + enum CodingKeys: String, CodingKey { + case recommendationId = "recommendation_id" + case leadId = "lead_id" + case recommendationType = "recommendation_type" + case summary + case suggestedAction = "suggested_action" + case targetSystem = "target_system" + case status, confidence + case createdAt = "created_at" + } +} + +struct VelocityInsightEnvelope: Decodable, Sendable { + let insights: [VelocityInsight] +} + +struct VelocityCampaign: Decodable, Identifiable, Sendable { + let id: String + let name: String + let platform: String + let status: String + let budget: Double + let spent: Double + let impressions: Int + let clicks: Int + let conversions: Int + let objective: String? +} + +struct VelocityCampaignEnvelope: Decodable, Sendable { + let data: [VelocityCampaign] +} + +struct VelocityCatalystInsightEnvelope: Decodable, Sendable { + let data: [VelocityCatalystInsight] +} + +struct VelocityCatalystInsight: Decodable, Identifiable, Sendable { + let id = UUID() + let campaignId: String? + let platform: String? + let spend: Double? + let impressions: Int? + let clicks: Int? + let conversions: Int? + + enum CodingKeys: String, CodingKey { + case campaignId = "campaign_id" + case platform, spend, impressions, clicks, conversions + } +} + +struct VelocityInventoryProperty: Decodable, Identifiable, Sendable { + let propertyId: String + let projectName: String + let developerName: String + let propertyType: String + let status: String + let location: [String: JSONValue] + let priceBands: [JSONValue] + let unitMix: [JSONValue] + + var id: String { propertyId } + + enum CodingKeys: String, CodingKey { + case propertyId = "property_id" + case projectName = "project_name" + case developerName = "developer_name" + case propertyType = "property_type" + case status, location + case priceBands = "price_bands" + case unitMix = "unit_mix" + } +} + +struct VelocityInventoryListEnvelope: Decodable, Sendable { + let properties: [VelocityInventoryProperty] +} + +struct VelocityInventoryMedia: Decodable, Identifiable, Sendable { + let mediaAssetId: String + let mediaType: String + let url: String + let thumbnailURL: String? + + var id: String { mediaAssetId } + + enum CodingKeys: String, CodingKey { + case mediaAssetId = "media_asset_id" + case mediaType = "media_type" + case url + case thumbnailURL = "thumbnail_url" + } +} + +struct VelocityInventoryMediaEnvelope: Decodable, Sendable { + let media: [VelocityInventoryMedia] +} + +struct VelocityCRMContact: Decodable, Identifiable, Sendable { + let personId: String + let fullName: String + let primaryEmail: String? + let primaryPhone: String? + let buyerType: String? + let leadStatus: String? + let intentScore: Double? + let interactionCount: Int? + let pendingTasks: Int? + + var id: String { personId } + + enum CodingKeys: String, CodingKey { + case personId = "person_id" + case fullName = "full_name" + case primaryEmail = "primary_email" + case primaryPhone = "primary_phone" + case buyerType = "buyer_type" + case leadStatus = "lead_status" + case intentScore = "intent_score" + case interactionCount = "interaction_count" + case pendingTasks = "pending_tasks" + } +} + +struct VelocityCRMContactsEnvelope: Decodable, Sendable { + struct Payload: Decodable, Sendable { + let contacts: [VelocityCRMContact] + } + let data: Payload +} + +struct VelocityCRMOpportunity: Decodable, Identifiable, Sendable { + let opportunityId: String + let stage: String + let value: Double? + let nextAction: String? + let clientName: String + let projectName: String? + + var id: String { opportunityId } + + enum CodingKeys: String, CodingKey { + case opportunityId = "opportunity_id" + case stage, value + case nextAction = "next_action" + case clientName = "client_name" + case projectName = "project_name" + } +} + +struct VelocityCRMOpportunitiesEnvelope: Decodable, Sendable { + let data: [VelocityCRMOpportunity] +} + +struct VelocityCRMTask: Decodable, Identifiable, Sendable { + let reminderId: String + let title: String + let notes: String? + let dueAt: String? + let status: String + let priority: String? + let clientName: String? + + var id: String { reminderId } + + enum CodingKeys: String, CodingKey { + case reminderId = "reminder_id" + case title, notes, status, priority + case dueAt = "due_at" + case clientName = "client_name" + } +} + +struct VelocityCRMTasksEnvelope: Decodable, Sendable { + let data: [VelocityCRMTask] +} + +struct VelocityCRMKanbanColumn: Decodable, Identifiable, Sendable { + let status: String + let label: String + let count: Int + + var id: String { status } +} + +struct VelocityCRMKanbanEnvelope: Decodable, Sendable { + let data: [VelocityCRMKanbanColumn] +} + +struct VelocityAdminHealth: Decodable, Sendable { + struct Database: Decodable, Sendable { + let connected: Bool + let latencyMs: Double + + enum CodingKeys: String, CodingKey { + case connected + case latencyMs = "latency_ms" + } + } + + struct Queues: Decodable, Sendable { + let pendingTranscriptions: Int + let pendingSyntheticJobs: Int + let pendingAdminActions: Int + let pendingInventoryBatches: Int + + enum CodingKeys: String, CodingKey { + case pendingTranscriptions = "pending_transcriptions" + case pendingSyntheticJobs = "pending_synthetic_jobs" + case pendingAdminActions = "pending_admin_actions" + case pendingInventoryBatches = "pending_inventory_batches" + } + } + + let status: String + let timestamp: String + let database: Database + let queues: Queues +} + +struct VelocityMarketingVideo: Decodable, Identifiable, Sendable { + let id: String + let title: String + let propertyName: String + let unitNumber: String + let type: String + let videoURL: String + + enum CodingKeys: String, CodingKey { + case id, title, type + case propertyName = "property_name" + case unitNumber = "unit_number" + case videoURL = "video_url" + } +} + +struct VelocityMarketingVideosEnvelope: Decodable, Sendable { + let videos: [VelocityMarketingVideo] +} + +struct VelocityClient360: Decodable, Sendable { + let data: JSONValue +} + +struct VelocityEmptyResponse: Decodable, Sendable {} + +enum JSONValue: Decodable, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + var stringValue: String? { + switch self { + case .string(let value): return value + case .number(let value): return value == floor(value) ? String(Int(value)) : String(value) + case .bool(let value): return value ? "True" : "False" + default: return nil + } + } +} + +enum VelocityAPIError: LocalizedError { + case invalidURL + case unauthorized + case invalidResponse + case api(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Velocity backend base URL is invalid." + case .unauthorized: + return "Your session has expired. Please sign in again." + case .invalidResponse: + return "Velocity returned an invalid response." + case .api(let message): + return message + } + } +} + +actor VelocityLiveAPI { + static let shared = VelocityLiveAPI() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + return decoder + }() + + private func buildURL(path: String, queryItems: [URLQueryItem] = []) throws -> URL { + guard var components = URLComponents(string: EdgeAppConfig.baseURL) else { + throw VelocityAPIError.invalidURL + } + components.path = path + if queryItems.isEmpty == false { + components.queryItems = queryItems + } + guard let url = components.url else { + throw VelocityAPIError.invalidURL + } + return url + } + + private func request(_ type: T.Type, path: String, method: String = "GET", token: String? = nil, queryItems: [URLQueryItem] = [], body: Data? = nil, contentType: String = "application/json") async throws -> T { + let url = try buildURL(path: path, queryItems: queryItems) + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = 45 + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let token, token.isEmpty == false { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.httpBody = body + request.setValue(contentType, forHTTPHeaderField: "Content-Type") + } + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw VelocityAPIError.invalidResponse + } + if http.statusCode == 401 { + throw VelocityAPIError.unauthorized + } + guard (200..<300).contains(http.statusCode) else { + let detail = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["detail"] as? String + throw VelocityAPIError.api(detail ?? "Request failed with HTTP \(http.statusCode).") + } + return try decoder.decode(T.self, from: data) + } + + func login(email: String, password: String) async throws -> VelocityLoginResponse { + let body = try JSONSerialization.data(withJSONObject: ["email": email, "password": password]) + return try await request(VelocityLoginResponse.self, path: "/api/auth/login", method: "POST", body: body) + } + + func me(token: String) async throws -> VelocityAuthProfile { + try await request(VelocityAuthProfile.self, path: "/api/auth/me", token: token) + } + + func fetchLeads(token: String) async throws -> [VelocityLead] { + let envelope = try await request(VelocityLeadEnvelope.self, path: "/api/leads", token: token) + return envelope.data + } + + func fetchAlerts(token: String) async throws -> VelocityAlertSnapshot { + try await request(VelocityAlertSnapshot.self, path: "/api/mobile-edge/alerts", token: token) + } + + func fetchEvents(token: String, leadId: String, limit: Int = 8) async throws -> [VelocityCommunicationEvent] { + let envelope = try await request( + VelocityCommunicationEnvelope.self, + path: "/api/mobile-edge/events", + token: token, + queryItems: [ + URLQueryItem(name: "lead_id", value: leadId), + URLQueryItem(name: "limit", value: String(limit)), + ] + ) + return envelope.events + } + + func fetchMemoryFacts(token: String, leadId: String, limit: Int = 12) async throws -> [VelocityMemoryFact] { + let envelope = try await request( + VelocityMemoryEnvelope.self, + path: "/api/mobile-edge/memory", + token: token, + queryItems: [ + URLQueryItem(name: "lead_id", value: leadId), + URLQueryItem(name: "limit", value: String(limit)), + ] + ) + return envelope.facts + } + + func createNote(token: String, leadId: String, text: String) async throws { + let body = try JSONSerialization.data(withJSONObject: [ + "lead_id": leadId, + "note_text": text, + "fact_type": "custom", + ]) + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/notes", method: "POST", token: token, body: body) + } + + func fetchTranscript(token: String, eventId: String) async throws -> VelocityTranscriptEnvelope { + try await request(VelocityTranscriptEnvelope.self, path: "/api/mobile-edge/transcripts/\(eventId)", token: token) + } + + func fetchCalendar(token: String, limit: Int = 12) async throws -> [VelocityCalendarEvent] { + let envelope = try await request( + VelocityCalendarEnvelope.self, + path: "/api/mobile-edge/calendar", + token: token, + queryItems: [URLQueryItem(name: "limit", value: String(limit))] + ) + return envelope.events + } + + func createCalendarEvent(token: String, leadId: String?, title: String, description: String?, startAt: Date, durationMinutes: Int) async throws { + let endAt = startAt.addingTimeInterval(TimeInterval(durationMinutes * 60)) + let formatter = ISO8601DateFormatter() + var payload: [String: Any] = [ + "title": title, + "start_at": formatter.string(from: startAt), + "end_at": formatter.string(from: endAt), + "all_day": false, + "reminder_minutes": [15], + "metadata": [:], + ] + if let leadId { + payload["lead_id"] = leadId + } + if let description, description.isEmpty == false { + payload["description"] = description + } + let body = try JSONSerialization.data(withJSONObject: payload) + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/calendar", method: "POST", token: token, body: body) + } + + func updateCalendarEvent(token: String, eventId: String, title: String, startAt: Date, durationMinutes: Int) async throws { + let formatter = ISO8601DateFormatter() + let body = try JSONSerialization.data(withJSONObject: [ + "title": title, + "start_at": formatter.string(from: startAt), + "end_at": formatter.string(from: startAt.addingTimeInterval(TimeInterval(durationMinutes * 60))), + ]) + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/calendar/\(eventId)", method: "PATCH", token: token, body: body) + } + + func cancelCalendarEvent(token: String, eventId: String) async throws { + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/calendar/\(eventId)", method: "DELETE", token: token) + } + + func fetchInsights(token: String, leadId: String) async throws -> [VelocityInsight] { + let envelope = try await request(VelocityInsightEnvelope.self, path: "/api/mobile-edge/insights/\(leadId)", token: token) + return envelope.insights + } + + func actInsight(token: String, recommendationId: String, action: String) async throws { + let body = try JSONSerialization.data(withJSONObject: ["action": action]) + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/insights/\(recommendationId)/act", method: "POST", token: token, body: body) + } + + func registerHeartbeat(token: String, module: String, screen: String?) async throws { + let body = try JSONSerialization.data(withJSONObject: [ + "surface_type": "iphone_edge", + "app_version": EdgeAppConfig.appVersion, + "screen": screen ?? module, + "metadata": [ + "client": "velocity-iphone", + "platform": "ios", + "module": module, + ], + ]) + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/mobile-edge/session", method: "POST", token: token, body: body) + } + + func fetchCampaigns(token: String) async throws -> [VelocityCampaign] { + let envelope = try await request(VelocityCampaignEnvelope.self, path: "/api/catalyst/campaigns", token: token) + return envelope.data + } + + func fetchCatalystInsights(token: String) async throws -> [VelocityCatalystInsight] { + let envelope = try await request(VelocityCatalystInsightEnvelope.self, path: "/api/catalyst/insights/realtime", token: token) + return envelope.data + } + + func fetchProperties(token: String, limit: Int = 16) async throws -> [VelocityInventoryProperty] { + let envelope = try await request( + VelocityInventoryListEnvelope.self, + path: "/api/inventory/properties", + token: token, + queryItems: [ + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "offset", value: "0"), + ] + ) + return envelope.properties + } + + func fetchPropertyMedia(token: String, propertyId: String) async throws -> [VelocityInventoryMedia] { + let envelope = try await request(VelocityInventoryMediaEnvelope.self, path: "/api/inventory/properties/\(propertyId)/media", token: token) + return envelope.media + } + + func fetchCRMContacts(token: String) async throws -> [VelocityCRMContact] { + let envelope = try await request(VelocityCRMContactsEnvelope.self, path: "/api/crm/contacts", token: token) + return envelope.data.contacts + } + + func fetchClient360(token: String, personId: String) async throws -> JSONValue { + let dossier = try await request(VelocityClient360.self, path: "/api/crm/client-360/\(personId)", token: token) + return dossier.data + } + + func fetchOpportunities(token: String) async throws -> [VelocityCRMOpportunity] { + let envelope = try await request(VelocityCRMOpportunitiesEnvelope.self, path: "/api/crm/opportunities", token: token) + return envelope.data + } + + func fetchTasks(token: String) async throws -> [VelocityCRMTask] { + let envelope = try await request(VelocityCRMTasksEnvelope.self, path: "/api/crm/tasks", token: token) + return envelope.data + } + + func createTask(token: String, personId: String, title: String, notes: String?) async throws { + var payload: [String: Any] = [ + "person_id": personId, + "title": title, + "reminder_type": "follow_up", + "priority": "high", + ] + if let notes, notes.isEmpty == false { + payload["notes"] = notes + } + let body = try JSONSerialization.data(withJSONObject: payload) + let _: VelocityEmptyResponse = try await request(VelocityEmptyResponse.self, path: "/api/crm/tasks", method: "POST", token: token, body: body) + } + + func fetchKanban(token: String) async throws -> [VelocityCRMKanbanColumn] { + let envelope = try await request(VelocityCRMKanbanEnvelope.self, path: "/api/crm/kanban", token: token) + return envelope.data + } + + func fetchAdminHealth(token: String) async throws -> VelocityAdminHealth { + try await request(VelocityAdminHealth.self, path: "/api/admin-surface/health", token: token) + } + + func fetchMarketingVideos(token: String) async throws -> [VelocityMarketingVideo] { + let envelope = try await request(VelocityMarketingVideosEnvelope.self, path: "/api/videos/marketing", token: token) + return envelope.videos + } +} + +struct DreamWeaverJob: Decodable, Sendable { + let jobId: String + let status: String + + enum CodingKeys: String, CodingKey { + case jobId = "job_id" + case status + } +} + +struct DreamWeaverStatus: Decodable, Sendable { + let status: String + let ready: Bool + let error: String? +} + +struct DreamWeaverHealth: Decodable, Sendable { + let status: String +} + +enum DreamWeaverRuntimeError: LocalizedError { + case invalidImage + case timeout + case api(String) + + var errorDescription: String? { + switch self { + case .invalidImage: + return "The Dream Weaver gateway returned unreadable image data." + case .timeout: + return "Dream Weaver is taking longer than expected. Please try again." + case .api(let message): + return message + } + } +} + +actor DreamWeaverClient { + static let shared = DreamWeaverClient() + + func checkHealth() async -> Bool { + do { + let health: DreamWeaverHealth = try await rawRequest(DreamWeaverHealth.self, path: "/health") + return health.status == "ok" || health.status == "healthy" + } catch { + return false + } + } + + func generate(source: UIImage, roomType: String, keywords: String) async throws -> UIImage { + let normalised = source.fixedOrientation() + guard let imageData = normalised.resizedSquare(to: 1024).jpegData(compressionQuality: 0.86) else { + throw DreamWeaverRuntimeError.api("Failed to encode the captured image.") + } + + let boundary = "Boundary-\(UUID().uuidString)" + var request = URLRequest(url: try buildURL(path: "/dream-weaver")) + request.httpMethod = "POST" + request.timeoutInterval = 180 + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = buildMultipart(imageData: imageData, roomType: roomType, keywords: keywords, boundary: boundary) + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let detail = String(data: data, encoding: .utf8) ?? "Submission failed." + throw DreamWeaverRuntimeError.api(detail) + } + + let job = try JSONDecoder().decode(DreamWeaverJob.self, from: data) + let resultURL = try await pollUntilReady(jobId: job.jobId) + let (imageDataResult, _) = try await URLSession.shared.data(from: resultURL) + guard let image = UIImage(data: imageDataResult) else { + throw DreamWeaverRuntimeError.invalidImage + } + return image + } + + private func pollUntilReady(jobId: String, attempts: Int = 150) async throws -> URL { + for _ in 0..(_ type: T.Type, path: String) async throws -> T { + var request = URLRequest(url: try buildURL(path: path)) + request.timeoutInterval = 45 + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw DreamWeaverRuntimeError.api("Dream Weaver gateway is unavailable.") + } + return try JSONDecoder().decode(T.self, from: data) + } + + private func buildURL(path: String) throws -> URL { + guard var components = URLComponents(string: EdgeAppConfig.baseURL) else { + throw VelocityAPIError.invalidURL + } + components.path = path + guard let url = components.url else { + throw VelocityAPIError.invalidURL + } + return url + } + + private func buildMultipart(imageData: Data, roomType: String, keywords: String, boundary: String) -> Data { + var body = Data() + let crlf = "\r\n" + + body.append("--\(boundary)\(crlf)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"image\"; filename=\"room.jpg\"\(crlf)".data(using: .utf8)!) + body.append("Content-Type: image/jpeg\(crlf)\(crlf)".data(using: .utf8)!) + body.append(imageData) + body.append(crlf.data(using: .utf8)!) + + body.append("--\(boundary)\(crlf)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"room_type\"\(crlf)\(crlf)".data(using: .utf8)!) + body.append(roomType.data(using: .utf8)!) + body.append(crlf.data(using: .utf8)!) + + let trimmedKeywords = keywords.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedKeywords.isEmpty == false { + body.append("--\(boundary)\(crlf)".data(using: .utf8)!) + body.append("Content-Disposition: form-data; name=\"keywords\"\(crlf)\(crlf)".data(using: .utf8)!) + body.append(trimmedKeywords.data(using: .utf8)!) + body.append(crlf.data(using: .utf8)!) + } + + body.append("--\(boundary)--\(crlf)".data(using: .utf8)!) + return body + } +} + +private extension UIImage { + func fixedOrientation() -> UIImage { + guard imageOrientation != .up else { return self } + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + draw(in: CGRect(origin: .zero, size: size)) + } + } + + func resizedSquare(to side: CGFloat) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: CGSize(width: side, height: side)) + return renderer.image { _ in + let aspect = size.width / size.height + let rect: CGRect + if aspect > 1 { + let width = side * aspect + rect = CGRect(x: (side - width) / 2, y: 0, width: width, height: side) + } else { + let height = side / aspect + rect = CGRect(x: 0, y: (side - height) / 2, width: side, height: height) + } + draw(in: rect) + } + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/Security/VelocitySecureStore.swift b/iOS/velocity-iphone/velocity-iphone/Core/Security/VelocitySecureStore.swift new file mode 100644 index 00000000..a60a31dc --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/Security/VelocitySecureStore.swift @@ -0,0 +1,57 @@ +import Foundation +import Security + +enum VelocitySecureStore { + private static let service = "in.desineuron.velocity.iphone" + private static let tokenAccount = "velocity_access_token" + + static func readToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, + let data = item as? Data, + let token = String(data: data, encoding: .utf8), + token.isEmpty == false else { + return nil + } + return token + } + + static func writeToken(_ token: String) { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount, + ] + + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var insert = query + insert.merge(attributes) { _, new in new } + SecItemAdd(insert as CFDictionary, nil) + } + } + + static func deleteToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: tokenAccount, + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/State/EdgeAppStore.swift b/iOS/velocity-iphone/velocity-iphone/Core/State/EdgeAppStore.swift new file mode 100644 index 00000000..6c36cd69 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/State/EdgeAppStore.swift @@ -0,0 +1,109 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class EdgeAppStore { + static let shared = EdgeAppStore() + + private init() {} + + var leads: [EdgeLeadDTO] = [] + var alerts: EdgeAlertSnapshotDTO? + var events: [EdgeCommunicationEventDTO] = [] + var memoryFacts: [EdgeMemoryFactDTO] = [] + var transcript: EdgeTranscriptDTO? + var isLoading = false + var errorMessage: String? + var lastSyncAt: Date? + var lastHeartbeatAt: Date? + var noteStatusMessage: String? + + var selectedLead: EdgeLeadDTO? { + leads.sorted(by: { $0.score > $1.score }).first + } + + var authDescription: String { + EdgeAppConfig.authModeDescription + } + + func refresh(screen: String? = nil, silent: Bool = false) async { + if !silent { + isLoading = true + } + + do { + async let leadsTask = VelocityEdgeAPIClient.shared.fetchLeads() + async let alertsTask = VelocityEdgeAPIClient.shared.fetchAlerts() + let fetchedLeads = try await leadsTask + let fetchedAlerts = try await alertsTask + + leads = fetchedLeads + alerts = fetchedAlerts + + if let lead = selectedLead { + events = try await VelocityEdgeAPIClient.shared.fetchEvents(for: lead.id, limit: 6) + memoryFacts = try await VelocityEdgeAPIClient.shared.fetchMemoryFacts(for: lead.id, limit: 10) + if let event = events.first(where: { ($0.recordingRef ?? "").isEmpty == false }) { + transcript = try await VelocityEdgeAPIClient.shared.fetchTranscript(for: event.id) + } else { + transcript = nil + } + } else { + events = [] + memoryFacts = [] + transcript = nil + } + + if EdgeAppConfig.isConfigured { + try await VelocityEdgeAPIClient.shared.registerHeartbeat(screen: screen) + lastHeartbeatAt = Date() + } + + errorMessage = nil + lastSyncAt = Date() + isLoading = false + } catch { + if !silent { + events = [] + memoryFacts = [] + transcript = nil + } + errorMessage = error.localizedDescription + isLoading = false + } + } + + func createNote(_ text: String) async { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + noteStatusMessage = "Note text cannot be empty." + return + } + + guard let lead = selectedLead else { + noteStatusMessage = "No live lead is available for note capture." + return + } + + do { + try await VelocityEdgeAPIClient.shared.createNote(leadId: lead.id, noteText: trimmed) + noteStatusMessage = "Quick note saved to live mobile-edge memory." + memoryFacts = try await VelocityEdgeAPIClient.shared.fetchMemoryFacts(for: lead.id, limit: 10) + alerts = try? await VelocityEdgeAPIClient.shared.fetchAlerts() + lastSyncAt = Date() + } catch { + noteStatusMessage = error.localizedDescription + } + } +} + +extension Date { + var edgeRelativeShort: String { + let delta = Int(Date().timeIntervalSince(self)) + if delta < 60 { return "now" } + if delta < 3600 { return "\(delta / 60)m ago" } + if delta < 86400 { return "\(delta / 3600)h ago" } + return "\(delta / 86400)d ago" + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/State/VelocityAppModel.swift b/iOS/velocity-iphone/velocity-iphone/Core/State/VelocityAppModel.swift new file mode 100644 index 00000000..1f13d630 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/State/VelocityAppModel.swift @@ -0,0 +1,519 @@ +import Foundation +import Observation +import SwiftUI + +enum VelocityRootState { + case booting + case signedOut + case signedIn +} + +enum VelocityModule: String, CaseIterable, Identifiable { + case home = "Home" + case command = "Command" + case sentinel = "Sentinel" + case inventory = "Inventory" + case catalyst = "Catalyst" + + var id: String { rawValue } + + // Unselected (outline / lighter weight) — Apple tab bar convention + var systemImage: String { + switch self { + case .home: return "house" + case .command: return "bolt.horizontal.circle" + case .sentinel: return "eye" + case .inventory: return "building.2" + case .catalyst: return "megaphone" + } + } + + // Selected (filled) — Apple tab bar convention + var selectedSystemImage: String { + switch self { + case .home: return "house.fill" + case .command: return "bolt.horizontal.circle.fill" + case .sentinel: return "eye.fill" + case .inventory: return "building.2.fill" + case .catalyst: return "megaphone.fill" + } + } +} + +@MainActor +@Observable +final class VelocitySessionStore { + var rootState: VelocityRootState = .booting + var profile: VelocityAuthProfile? + var token: String? + var errorMessage: String? + var selectedModule: VelocityModule = .home + var showingSettings = false + var lastHeartbeatAt: Date? + var lastScreenName = "home" + + var displayName: String { + if let fullName = profile?.fullName?.trimmingCharacters(in: .whitespacesAndNewlines), fullName.isEmpty == false { + return fullName + } + return profile?.email ?? "Velocity Operator" + } + + var roleLabel: String { + (profile?.role ?? "OPERATOR") + .lowercased() + .split(separator: "_") + .map { $0.capitalized } + .joined(separator: " ") + } + + func bootstrap() async { + if let secureToken = VelocitySecureStore.readToken() { + await authenticate(with: secureToken) + return + } + if let token = EdgeAppConfig.apiBearerToken { + await authenticate(with: token) + return + } + rootState = .signedOut + } + + func login(email: String, password: String) async { + errorMessage = nil + do { + let response = try await VelocityLiveAPI.shared.login(email: email, password: password) + VelocitySecureStore.writeToken(response.accessToken) + await authenticate(with: response.accessToken) + } catch { + rootState = .signedOut + errorMessage = error.localizedDescription + } + } + + func authenticate(with token: String) async { + do { + let profile = try await VelocityLiveAPI.shared.me(token: token) + self.token = token + self.profile = profile + self.rootState = .signedIn + self.errorMessage = nil + VelocitySecureStore.writeToken(token) + } catch { + VelocitySecureStore.deleteToken() + self.token = nil + self.profile = nil + self.rootState = .signedOut + self.errorMessage = error.localizedDescription + } + } + + func logout() { + VelocitySecureStore.deleteToken() + token = nil + profile = nil + rootState = .signedOut + selectedModule = .home + showingSettings = false + } + + func sendHeartbeat(module: VelocityModule, screen: String) async { + guard let token else { return } + do { + try await VelocityLiveAPI.shared.registerHeartbeat(token: token, module: module.rawValue.lowercased(), screen: screen) + lastHeartbeatAt = Date() + lastScreenName = screen + } catch { + // Heartbeat failures are non-fatal background events; suppress to avoid + // polluting session.errorMessage and surfacing spurious errors in the UI. + } + } +} + +@MainActor +@Observable +final class VelocityHomeStore { + var alerts: VelocityAlertSnapshot? + var leads: [VelocityLead] = [] + var events: [VelocityCommunicationEvent] = [] + var calendar: [VelocityCalendarEvent] = [] + var insights: [VelocityInsight] = [] + var adminHealth: VelocityAdminHealth? + var isLoading = false + var errorMessage: String? + + func refresh(token: String) async { + isLoading = true + defer { isLoading = false } + do { + async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token) + async let leads = VelocityLiveAPI.shared.fetchLeads(token: token) + async let calendar = VelocityLiveAPI.shared.fetchCalendar(token: token, limit: 6) + + let leadRows = try await leads + let primaryLead = leadRows.sorted(by: { $0.score > $1.score }).first + + self.alerts = try await alerts + self.leads = leadRows + self.calendar = try await calendar + self.adminHealth = try? await VelocityLiveAPI.shared.fetchAdminHealth(token: token) + + if let primaryLead { + async let events = VelocityLiveAPI.shared.fetchEvents(token: token, leadId: primaryLead.id, limit: 4) + async let insights = VelocityLiveAPI.shared.fetchInsights(token: token, leadId: primaryLead.id) + self.events = try await events + self.insights = try await insights + } else { + self.events = [] + self.insights = [] + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +@Observable +final class VelocityCommandStore { + enum Section: String, CaseIterable, Identifiable { + case oracle = "Oracle" + case crm = "CRM" + case alerts = "Alerts" + case communications = "Communications" + case notes = "Notes" + case calendar = "Calendar" + case transcriptions = "Transcriptions" + case insights = "Insights" + + var id: String { rawValue } + } + + var selectedSection: Section = .oracle + var leads: [VelocityLead] = [] + var contacts: [VelocityCRMContact] = [] + var opportunities: [VelocityCRMOpportunity] = [] + var tasks: [VelocityCRMTask] = [] + var kanban: [VelocityCRMKanbanColumn] = [] + var alerts: VelocityAlertSnapshot? + var events: [VelocityCommunicationEvent] = [] + var memoryFacts: [VelocityMemoryFact] = [] + var calendar: [VelocityCalendarEvent] = [] + var insights: [VelocityInsight] = [] + var transcript: VelocityTranscriptEnvelope? + var client360: JSONValue? + var noteDraft = "" + var taskDraft = "" + var calendarTitleDraft = "" + var isLoading = false + var errorMessage: String? + var actionMessage: String? + + var primaryLead: VelocityLead? { + leads.sorted(by: { $0.score > $1.score }).first + } + + var primaryContact: VelocityCRMContact? { + contacts.first + } + + func refresh(token: String) async { + isLoading = true + defer { isLoading = false } + do { + async let leads = VelocityLiveAPI.shared.fetchLeads(token: token) + async let contacts = VelocityLiveAPI.shared.fetchCRMContacts(token: token) + async let opportunities = VelocityLiveAPI.shared.fetchOpportunities(token: token) + async let tasks = VelocityLiveAPI.shared.fetchTasks(token: token) + async let kanban = VelocityLiveAPI.shared.fetchKanban(token: token) + async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token) + async let calendar = VelocityLiveAPI.shared.fetchCalendar(token: token, limit: 10) + + let loadedLeads = try await leads + self.leads = loadedLeads + self.contacts = try await contacts + self.opportunities = try await opportunities + self.tasks = try await tasks + self.kanban = try await kanban + self.alerts = try await alerts + self.calendar = try await calendar + + if let lead = loadedLeads.sorted(by: { $0.score > $1.score }).first { + async let events = VelocityLiveAPI.shared.fetchEvents(token: token, leadId: lead.id) + async let memory = VelocityLiveAPI.shared.fetchMemoryFacts(token: token, leadId: lead.id) + async let insights = VelocityLiveAPI.shared.fetchInsights(token: token, leadId: lead.id) + self.events = try await events + self.memoryFacts = try await memory + self.insights = try await insights + + if let transcriptEvent = self.events.first(where: { ($0.recordingRef ?? "").isEmpty == false }) { + self.transcript = try? await VelocityLiveAPI.shared.fetchTranscript(token: token, eventId: transcriptEvent.eventId) + } else { + self.transcript = nil + } + } else { + self.events = [] + self.memoryFacts = [] + self.insights = [] + self.transcript = nil + } + + if let personId = self.contacts.first?.personId { + self.client360 = try? await VelocityLiveAPI.shared.fetchClient360(token: token, personId: personId) + } else { + self.client360 = nil + } + + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func createNote(token: String) async { + let trimmed = noteDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false, let lead = primaryLead else { return } + do { + try await VelocityLiveAPI.shared.createNote(token: token, leadId: lead.id, text: trimmed) + noteDraft = "" + actionMessage = "Note saved to live operator memory." + await refresh(token: token) + } catch { + actionMessage = error.localizedDescription + } + } + + func createFollowUp(token: String) async { + let trimmed = taskDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false, let personId = primaryContact?.personId else { return } + do { + try await VelocityLiveAPI.shared.createTask(token: token, personId: personId, title: trimmed, notes: nil) + taskDraft = "" + actionMessage = "Follow-up task created." + await refresh(token: token) + } catch { + actionMessage = error.localizedDescription + } + } + + func createCalendarEvent(token: String) async { + let trimmed = calendarTitleDraft.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty == false else { return } + do { + try await VelocityLiveAPI.shared.createCalendarEvent(token: token, leadId: primaryLead?.id, title: trimmed, description: "Created from Velocity iPhone", startAt: Date().addingTimeInterval(3600), durationMinutes: 30) + calendarTitleDraft = "" + actionMessage = "Calendar event created." + await refresh(token: token) + } catch { + actionMessage = error.localizedDescription + } + } + + func act(on insight: VelocityInsight, action: String, token: String) async { + do { + try await VelocityLiveAPI.shared.actInsight(token: token, recommendationId: insight.recommendationId, action: action) + actionMessage = "Insight marked as \(action)." + await refresh(token: token) + } catch { + actionMessage = error.localizedDescription + } + } +} + +@MainActor +@Observable +final class VelocitySentinelStore { + enum Section: String, CaseIterable, Identifiable { + case overview = "Overview" + case live = "Live Session" + + var id: String { rawValue } + } + + var selectedSection: Section = .overview + var alerts: VelocityAlertSnapshot? + var leads: [VelocityLead] = [] + var videos: [VelocityMarketingVideo] = [] + var adminHealth: VelocityAdminHealth? + var isLoading = false + var errorMessage: String? + + func refresh(token: String) async { + isLoading = true + defer { isLoading = false } + do { + async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token) + async let leads = VelocityLiveAPI.shared.fetchLeads(token: token) + async let videos = VelocityLiveAPI.shared.fetchMarketingVideos(token: token) + self.alerts = try await alerts + self.leads = try await leads + self.videos = try await videos + self.adminHealth = try? await VelocityLiveAPI.shared.fetchAdminHealth(token: token) + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +@Observable +final class VelocityInventoryStore { + enum Section: String, CaseIterable, Identifiable { + case portfolio = "Portfolio" + case units = "Units" + case dreamWeaver = "Dream Weaver" + case sunseeker = "Sunseeker" + + var id: String { rawValue } + } + + var selectedSection: Section = .portfolio + var properties: [VelocityInventoryProperty] = [] + var selectedPropertyID: String? + var media: [VelocityInventoryMedia] = [] + var isLoading = false + var errorMessage: String? + var sourceImage: UIImage? + var generatedImage: UIImage? + var roomType = "living_room" + var keywords = "" + var dreamWeaverOnline: Bool? + var dreamWeaverBusy = false + var dreamWeaverMessage: String? + + var selectedProperty: VelocityInventoryProperty? { + properties.first(where: { $0.propertyId == selectedPropertyID }) ?? properties.first + } + + func refresh(token: String) async { + isLoading = true + defer { isLoading = false } + do { + let properties = try await VelocityLiveAPI.shared.fetchProperties(token: token) + self.properties = properties + if selectedPropertyID == nil { + selectedPropertyID = properties.first?.propertyId + } + if let propertyId = selectedPropertyID { + self.media = try await VelocityLiveAPI.shared.fetchPropertyMedia(token: token, propertyId: propertyId) + } else { + self.media = [] + } + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } + + func refreshDreamWeaverHealth() async { + dreamWeaverOnline = await DreamWeaverClient.shared.checkHealth() + } + + func generateDreamWeaver() async { + guard let sourceImage else { + dreamWeaverMessage = "Capture or select a room photo first." + return + } + dreamWeaverBusy = true + dreamWeaverMessage = nil + defer { dreamWeaverBusy = false } + do { + generatedImage = try await DreamWeaverClient.shared.generate(source: sourceImage, roomType: roomType, keywords: keywords) + dreamWeaverMessage = "Dream Weaver returned a live render." + } catch { + dreamWeaverMessage = error.localizedDescription + } + } +} + +@MainActor +@Observable +final class VelocityCatalystStore { + enum Section: String, CaseIterable, Identifiable { + case studio = "Studio" + case command = "Campaign Command" + case roi = "Intelligence & ROI" + case warRoom = "War Room" + case marketing = "Marketing" + + var id: String { rawValue } + } + + var selectedSection: Section = .studio + var campaigns: [VelocityCampaign] = [] + var insights: [VelocityCatalystInsight] = [] + var isLoading = false + var errorMessage: String? + + func refresh(token: String) async { + isLoading = true + defer { isLoading = false } + do { + self.campaigns = try await VelocityLiveAPI.shared.fetchCampaigns(token: token) + self.insights = (try? await VelocityLiveAPI.shared.fetchCatalystInsights(token: token)) ?? [] + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +@Observable +final class VelocitySettingsStore { + var adminHealth: VelocityAdminHealth? + var alerts: VelocityAlertSnapshot? + var isLoading = false + var errorMessage: String? + + func refresh(token: String) async { + isLoading = true + defer { isLoading = false } + do { + async let alerts = VelocityLiveAPI.shared.fetchAlerts(token: token) + self.adminHealth = try? await VelocityLiveAPI.shared.fetchAdminHealth(token: token) + self.alerts = try await alerts + errorMessage = nil + } catch { + errorMessage = error.localizedDescription + } + } +} + +@MainActor +@Observable +final class VelocityAppModel { + let session = VelocitySessionStore() + let home = VelocityHomeStore() + let command = VelocityCommandStore() + let sentinel = VelocitySentinelStore() + let inventory = VelocityInventoryStore() + let catalyst = VelocityCatalystStore() + let settings = VelocitySettingsStore() + + var bootstrapped = false + + func bootstrap() async { + guard bootstrapped == false else { return } + bootstrapped = true + await session.bootstrap() + if session.rootState == .signedIn { + await refreshCurrentModule(forceAll: true) + } + } + + func refreshCurrentModule(forceAll: Bool = false) async { + guard let token = session.token else { return } + if forceAll || session.selectedModule == .home { await home.refresh(token: token) } + if forceAll || session.selectedModule == .command { await command.refresh(token: token) } + if forceAll || session.selectedModule == .sentinel { await sentinel.refresh(token: token) } + if forceAll || session.selectedModule == .inventory { + await inventory.refresh(token: token) + await inventory.refreshDreamWeaverHealth() + } + if forceAll || session.selectedModule == .catalyst { await catalyst.refresh(token: token) } + if forceAll || session.showingSettings { await settings.refresh(token: token) } + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift b/iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift new file mode 100644 index 00000000..48ded558 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift @@ -0,0 +1,495 @@ +import SwiftUI +import Combine + +// MARK: – Colour & Gradient Tokens + +enum EdgeTheme { + // Base surfaces – deep navy/indigo dark mode + static let background = Color(red: 0.012, green: 0.016, blue: 0.040) + static let background2 = Color(red: 0.030, green: 0.040, blue: 0.090) + static let surface = Color(red: 0.068, green: 0.082, blue: 0.155) + static let surface2 = Color(red: 0.102, green: 0.122, blue: 0.210) + static let surface3 = Color(red: 0.130, green: 0.155, blue: 0.252) + + // Text + static let foreground = Color(red: 0.955, green: 0.968, blue: 0.995) + static let mutedFg = Color(red: 0.620, green: 0.668, blue: 0.800) + static let subtleFg = Color(red: 0.400, green: 0.455, blue: 0.590) + + // Accent palette + static let accent = Color(red: 0.278, green: 0.588, blue: 1.000) // electric blue + static let accentSecondary = Color(red: 0.220, green: 0.878, blue: 0.780) // teal-mint + static let accentWarm = Color(red: 1.000, green: 0.545, blue: 0.337) // coral + static let accentPurple = Color(red: 0.682, green: 0.459, blue: 1.000) // violet + + static let accentSubtle = accent.opacity(0.18) + + // Semantic + static let success = Color(red: 0.318, green: 0.855, blue: 0.580) + static let warning = Color(red: 1.000, green: 0.780, blue: 0.318) + static let danger = Color(red: 1.000, green: 0.400, blue: 0.420) + + // Borders + static let borderSubtle = Color.white.opacity(0.08) + static let borderAccent = accent.opacity(0.28) + static let borderGlass = Color.white.opacity(0.12) + + // Gradients + static let shellGradient = LinearGradient( + colors: [background2, background], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let heroGradient = LinearGradient( + colors: [accent, accentPurple.opacity(0.85)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let heroGradientWarm = LinearGradient( + colors: [accentWarm, Color(red: 1.0, green: 0.35, blue: 0.60)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let cardGradient = LinearGradient( + colors: [surface2.opacity(0.94), surface.opacity(0.96)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + static let glassGradient = LinearGradient( + colors: [Color.white.opacity(0.10), Color.white.opacity(0.04)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) +} + +// MARK: – Ambient Background +// +// Apple design: background is quiet — it lives behind content, not on top of it. +// Slow-drifting orbs at low opacity establish depth without fighting text. + +struct EdgeAmbientBackground: View { + var body: some View { + ZStack { + // Solid base — never pure black, always a deep navy/indigo + EdgeTheme.background.ignoresSafeArea() + + // Very slow ambient glow — imperceptible in isolation, felt at scale + TimelineView(.animation(minimumInterval: 1 / 24)) { tl in + let t = tl.date.timeIntervalSinceReferenceDate + Canvas { context, size in + // Orb 1 — blue, top-right + let o1 = CGPoint( + x: size.width * 0.80 + CGFloat(sin(t * 0.14)) * 40, + y: size.height * 0.18 + CGFloat(cos(t * 0.11)) * 30 + ) + context.drawLayer { ctx in + ctx.addFilter(.blur(radius: 80)) + ctx.fill( + Path(ellipseIn: CGRect(x: o1.x - 130, y: o1.y - 130, width: 260, height: 260)), + with: .color(EdgeTheme.accent.opacity(0.20)) + ) + } + + // Orb 2 — violet, top-left + let o2 = CGPoint( + x: size.width * 0.14 + CGFloat(cos(t * 0.09)) * 36, + y: size.height * 0.22 + CGFloat(sin(t * 0.12)) * 28 + ) + context.drawLayer { ctx in + ctx.addFilter(.blur(radius: 70)) + ctx.fill( + Path(ellipseIn: CGRect(x: o2.x - 110, y: o2.y - 110, width: 220, height: 220)), + with: .color(EdgeTheme.accentPurple.opacity(0.14)) + ) + } + + // Orb 3 — teal, bottom + let o3 = CGPoint( + x: size.width * 0.45 + CGFloat(sin(t * 0.07)) * 50, + y: size.height * 0.82 + CGFloat(cos(t * 0.10)) * 40 + ) + context.drawLayer { ctx in + ctx.addFilter(.blur(radius: 90)) + ctx.fill( + Path(ellipseIn: CGRect(x: o3.x - 140, y: o3.y - 140, width: 280, height: 280)), + with: .color(EdgeTheme.accentSecondary.opacity(0.10)) + ) + } + } + } + } + } +} + +// MARK: – Shell Header (legacy — VelocityModuleScreen now uses NavigationStack) + +struct EdgeShellHeader: View { + let title: String + let subtitle: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(.largeTitle, design: .rounded, weight: .bold)) + .foregroundStyle(EdgeTheme.foreground) + if !subtitle.isEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } +} + +// MARK: – EdgeShell (used by old edge views) + +struct EdgeShell: View { + let title: String + let subtitle: String + @ViewBuilder let content: Content + + var body: some View { + ZStack { + EdgeAmbientBackground() + .ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + EdgeShellHeader(title: title, subtitle: subtitle) + content + } + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 34) + } + .scrollIndicators(.hidden) + } + } +} + +// MARK: – Glass Card (primary card style — mirrors VelocityGlassCard for legacy callers) + +struct EdgeCard: View { + let title: String + let tint: Color + @ViewBuilder let content: Content + + init(title: String, tint: Color = EdgeTheme.accent, @ViewBuilder content: () -> Content) { + self.title = title + self.tint = tint + self.content = content() + } + + var body: some View { + VelocityGlassCard(title: title, tint: tint) { content } + } +} + +// MARK: – Hero Card + +struct EdgeHeroCard: View { + let title: String + let subtitle: String + let icon: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.18)) + .blur(radius: 0.5) + Image(systemName: icon) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: 56, height: 56) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + Text(subtitle) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.80)) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + + content + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(EdgeTheme.heroGradient) + .overlay( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1) + ) + .shadow(color: EdgeTheme.accent.opacity(0.28), radius: 28, y: 14) + ) + } +} + +// MARK: – Metric Card + +struct EdgeMetricCard: View { + let label: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 6) { + Circle() + .fill(tint) + .frame(width: 8, height: 8) + .shadow(color: tint.opacity(0.7), radius: 3) + Text(label.uppercased()) + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .tracking(1.3) + .foregroundStyle(EdgeTheme.mutedFg) + } + Text(value) + .font(.system(size: 30, weight: .heavy, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + .lineLimit(1) + .minimumScaleFactor(0.7) + + Capsule(style: .continuous) + .fill(tint.opacity(0.15)) + .frame(height: 5) + .overlay(alignment: .leading) { + Capsule(style: .continuous) + .fill(tint) + .frame(width: 44, height: 5) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.white.opacity(0.055)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.white.opacity(0.09), lineWidth: 1) + ) + ) + } +} + +// MARK: – Status Pill + +struct EdgeStatusPill: View { + let label: String + let color: Color + + var body: some View { + Text(label) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(color) + .padding(.horizontal, 11) + .padding(.vertical, 7) + .background( + Capsule(style: .continuous) + .fill(color.opacity(0.14)) + .overlay( + Capsule(style: .continuous) + .stroke(color.opacity(0.28), lineWidth: 1) + ) + ) + } +} + +// MARK: – Key-Value Row +// +// Matches iOS Settings row — label in secondary on left, value on right. + +struct EdgeKeyValueRow: View { + let label: String + let value: String + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(label) + .font(.system(.subheadline, design: .rounded, weight: .regular)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Text(value) + .font(.system(.subheadline, design: .rounded, weight: .medium)) + .foregroundStyle(EdgeTheme.foreground) + .multilineTextAlignment(.trailing) + } + .padding(.vertical, 6) + } +} + +// MARK: – Timeline Row +// +// Used inside VelocityGlassCard rows — no timeline connector, Apple Notes style. + +struct EdgeTimelineRow: View { + let title: String + let subtitle: String + let trailing: String + let tint: Color + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Tint dot — compact visual anchor, no vertical connector + Circle() + .fill(tint) + .frame(width: 8, height: 8) + .shadow(color: tint.opacity(0.55), radius: 3) + .padding(.top, 5) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(.subheadline, design: .rounded, weight: .semibold)) + .foregroundStyle(EdgeTheme.foreground) + .lineLimit(1) + Text(subtitle) + .font(.system(.caption, design: .rounded, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + Text(trailing) + .font(.system(.caption2, design: .rounded, weight: .medium)) + .foregroundStyle(Color(uiColor: .tertiaryLabel)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 5) + } +} + +// MARK: – Primary Button Style + +struct EdgePrimaryButtonStyle: ButtonStyle { + let enabled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.body, design: .rounded, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 15) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + enabled + ? AnyShapeStyle(LinearGradient( + colors: [EdgeTheme.accent, EdgeTheme.accentPurple], + startPoint: .leading, endPoint: .trailing + )) + : AnyShapeStyle(Color.white.opacity(0.07)) + ) + .shadow(color: enabled ? EdgeTheme.accent.opacity(0.32) : .clear, radius: 12, y: 5) + ) + .foregroundStyle(.white) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .opacity(configuration.isPressed ? 0.88 : 1) + .animation(.spring(response: 0.22, dampingFraction: 0.72), value: configuration.isPressed) + } +} + +// MARK: – Utility Cards + +struct EdgeLoadingCard: View { + let message: String + + var body: some View { + HStack(spacing: 14) { + ProgressView().tint(EdgeTheme.accent) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(EdgeTheme.borderGlass, lineWidth: 0.5) + ) + ) + } +} + +struct EdgeErrorCard: View { + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(EdgeTheme.danger) + .font(.system(size: 14, weight: .semibold)) + Text(message) + .font(.subheadline) + .foregroundStyle(EdgeTheme.danger.opacity(0.90)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(EdgeTheme.danger.opacity(0.09)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(EdgeTheme.danger.opacity(0.18), lineWidth: 0.5) + ) + ) + } +} + +struct EdgeEmptyCard: View { + let title: String + let message: String + + var body: some View { + VStack(spacing: 8) { + Text(title) + .font(.system(.subheadline, design: .rounded, weight: .semibold)) + .foregroundStyle(EdgeTheme.foreground) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 20) + } +} + +// MARK: – Auto-Refresh Modifier (legacy EdgeAppStore surface) + +private struct EdgeLiveRefreshModifier: ViewModifier { + @State private var refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect() + + let store: EdgeAppStore + let screen: String + + func body(content: Content) -> some View { + content.onReceive(refreshTimer) { _ in + Task { await store.refresh(screen: screen, silent: true) } + } + } +} + +extension View { + func edgeAutoRefresh(store: EdgeAppStore, screen: String) -> some View { + modifier(EdgeLiveRefreshModifier(store: store, screen: screen)) + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Core/UI/VelocityDesignSystem.swift b/iOS/velocity-iphone/velocity-iphone/Core/UI/VelocityDesignSystem.swift new file mode 100644 index 00000000..4c6dc901 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Core/UI/VelocityDesignSystem.swift @@ -0,0 +1,390 @@ +import SwiftUI + +// MARK: – Module Screen Shell +// +// Apple design principle: NavigationStack owns the title. The large title +// collapses to inline as the user scrolls — exactly like Settings, Health, +// and App Store. The ambient background sits underneath but never competes +// with content. + +struct VelocityModuleScreen: View { + let title: String + let subtitle: String + let content: Content + + init(title: String, subtitle: String, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.content = content() + } + + var body: some View { + NavigationStack { + ZStack { + EdgeAmbientBackground().ignoresSafeArea() + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Module subtitle — sits just below the large nav title + if !subtitle.isEmpty { + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + // No extra padding — NavigationStack large-title area handles vertical rhythm + } + content + } + // 16pt is Apple's canonical content margin (readableContentGuide) + .padding(.horizontal, 16) + // navigationBarTitleDisplayMode(.large) adds ~96pt gap already; + // we add 4pt to breathe between the title and the first card. + .padding(.top, 4) + // Tab bar (49pt) + home indicator (~34pt) + 17pt breathing = 100pt + .padding(.bottom, 100) + } + .scrollIndicators(.hidden) + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.large) + // Transparent nav bar so the ambient BG shows through + .toolbarBackground(.clear, for: .navigationBar) + .toolbarColorScheme(.dark, for: .navigationBar) + } + } +} + +// MARK: – Glass Card +// +// Apple design: cards use grouped inset list semantics — a rounded rect +// with a subtle material fill, a small section header above content, and a 1pt +// separator between rows. Shadow is restrained (depth, not decoration). + +struct VelocityGlassCard: View { + let title: String + let tint: Color + let content: Content + + init(title: String, tint: Color = EdgeTheme.accent, @ViewBuilder content: () -> Content) { + self.title = title + self.tint = tint + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Section label — Apple uses all-caps caption above grouped content + HStack(spacing: 5) { + Circle() + .fill(tint) + .frame(width: 5, height: 5) + .shadow(color: tint.opacity(0.6), radius: 2) + Text(title.uppercased()) + .font(.system(.caption2, design: .rounded, weight: .bold)) + .tracking(1.0) + .foregroundStyle(tint.opacity(0.90)) + } + // Apple's section header sits 6pt above the card surface + .padding(.horizontal, 4) + .padding(.bottom, 6) + + // Card body — 16pt H mirrors Apple's grouped inset cell horizontal inset + VStack(alignment: .leading, spacing: 0) { + content + } + .padding(.horizontal, 16) + // 12pt V — tighter than before; matches Apple's compact list row feel + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(EdgeTheme.surface.opacity(0.50)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(EdgeTheme.borderGlass, lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.18), radius: 10, y: 3) + ) + } + } +} + +// MARK: – Hero Card +// +// Full-bleed gradient card used for the primary module CTA or status. +// Apple uses these sparingly — one per screen, max. + +struct VelocityHeroCard: View { + let title: String + let subtitle: String + let systemImage: String + let content: Content + + init(title: String, subtitle: String, systemImage: String, @ViewBuilder content: () -> Content) { + self.title = title + self.subtitle = subtitle + self.systemImage = systemImage + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(0.20)) + Image(systemName: systemImage) + .font(.system(size: 20, weight: .semibold, design: .default)) + .foregroundStyle(.white) + .symbolRenderingMode(.hierarchical) + } + .frame(width: 52, height: 52) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(.headline, design: .rounded, weight: .bold)) + .foregroundStyle(.white) + Text(subtitle) + .font(.system(.footnote, design: .rounded, weight: .regular)) + .foregroundStyle(.white.opacity(0.78)) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + + content + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(LinearGradient( + colors: [EdgeTheme.accent, EdgeTheme.accentPurple.opacity(0.90)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(.white.opacity(0.16), lineWidth: 0.5) + ) + .shadow(color: EdgeTheme.accent.opacity(0.28), radius: 20, y: 8) + ) + } +} + +// MARK: – Metric Tile +// +// Inspired by Apple Health's summary widgets — label above, value large, +// tint accent bar at bottom. Uses SF Pro Rounded for numbers. + +struct VelocityMetricTile: View { + let label: String + let value: String + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Label row + HStack(spacing: 5) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(tint) + .shadow(color: tint.opacity(0.6), radius: 3) + Text(label) + .font(.system(.caption2, design: .rounded, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + // Value + Text(value) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.60) + + // Accent indicator bar + Capsule() + .fill(tint.opacity(0.14)) + .frame(height: 3) + .overlay(alignment: .leading) { + Capsule() + .fill(tint) + .frame(width: 40, height: 3) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(tint.opacity(0.055)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(tint.opacity(0.14), lineWidth: 0.5) + ) + ) + } +} + +// MARK: – Section Picker +// +// Apple-style segmented tabs as an inline horizontal scroll of chips. +// Selected chip has a solid tint fill; unselected is ghost. + +struct VelocitySectionPicker: View +where Selection.AllCases: RandomAccessCollection { + let sections: [Selection] + @Binding var selection: Selection + let title: (Selection) -> String + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(sections, id: \.id) { section in + let selected = selection == section + Button { + let impact = UIImpactFeedbackGenerator(style: .light) + impact.impactOccurred() + withAnimation(.spring(response: 0.26, dampingFraction: 0.76)) { + selection = section + } + } label: { + Text(title(section)) + // Use callout — slightly smaller than subheadline, avoids chips feeling bulky + .font(.system(.callout, design: .rounded, weight: selected ? .semibold : .regular)) + .foregroundStyle(selected ? .white : Color.secondary) + // 14pt H / 7pt V — Apple's standard chip sizing (lock screen widgets etc.) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background( + Capsule(style: .continuous) + .fill(selected ? EdgeTheme.accent : Color.white.opacity(0.07)) + .shadow(color: selected ? EdgeTheme.accent.opacity(0.28) : .clear, radius: 6, y: 2) + ) + } + .buttonStyle(.plain) + } + } + // 1pt H padding prevents chip shadows from clipping at scroll edge + .padding(.horizontal, 1) + .padding(.vertical, 1) + } + } +} + +// MARK: – Shell Button Style + +struct VelocityShellButtonStyle: ButtonStyle { + let tint: Color + let prominent: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(.subheadline, design: .rounded, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + prominent + ? AnyShapeStyle(tint.opacity(configuration.isPressed ? 0.70 : 0.90)) + : AnyShapeStyle(Color.white.opacity(configuration.isPressed ? 0.09 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke( + prominent ? tint.opacity(0.20) : EdgeTheme.borderSubtle, + lineWidth: 0.5 + ) + ) + .shadow(color: prominent ? tint.opacity(0.22) : .clear, radius: 8, y: 4) + ) + .foregroundStyle(prominent ? Color.white : EdgeTheme.foreground) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.spring(response: 0.20, dampingFraction: 0.72), value: configuration.isPressed) + } +} + +// MARK: – Bottom Navigation +// +// Apple tab bar aesthetic: pure SF Symbol icon + small label, displayed +// over an ultra-thin material bar that blurs the content behind it. +// No custom box around selected — Apple uses pure color differentiation. + +struct VelocityBottomNavigation: View { + @Binding var selection: VelocityModule + let onSettings: () -> Void + + var body: some View { + VStack(spacing: 0) { + Spacer() + + // Tab bar surface + HStack(alignment: .bottom, spacing: 0) { + ForEach(VelocityModule.allCases) { module in + let selected = selection == module + Button { + let impact = UIImpactFeedbackGenerator(style: .light) + impact.impactOccurred() + withAnimation(.spring(response: 0.28, dampingFraction: 0.76)) { + selection = module + } + } label: { + VStack(spacing: 4) { + Image(systemName: selected ? module.selectedSystemImage : module.systemImage) + .font(.system(size: 22, weight: selected ? .semibold : .regular)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(selected ? EdgeTheme.accent : Color(uiColor: .secondaryLabel)) + .scaleEffect(selected ? 1.08 : 1.0) + .animation(.spring(response: 0.26, dampingFraction: 0.70), value: selected) + Text(module.rawValue) + .font(.system(size: 10, weight: selected ? .semibold : .regular, design: .rounded)) + .foregroundStyle(selected ? EdgeTheme.accent : Color(uiColor: .secondaryLabel)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + } + + // Settings button — distinct from module tabs + Button(action: onSettings) { + VStack(spacing: 4) { + Image(systemName: "person.crop.circle") + .font(.system(size: 22, weight: .regular)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(Color(uiColor: .secondaryLabel)) + Text("Profile") + .font(.system(size: 10, weight: .regular, design: .rounded)) + .foregroundStyle(Color(uiColor: .secondaryLabel)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 8) + .background( + Rectangle() + .fill(.ultraThinMaterial) + .overlay( + Rectangle() + .fill(Color.black.opacity(0.40)) + ) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.white.opacity(0.08)) + .frame(height: 0.5) // hairline top divider — Apple tab bar signature + } + .ignoresSafeArea(edges: .bottom) + ) + } + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift b/iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift new file mode 100644 index 00000000..437a693a --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift @@ -0,0 +1,1924 @@ +import SwiftUI +import UIKit + +// MARK: – Shared Input Field (CRED / Uber Underline Style) + +struct VelocityUnderlineField: View { + let label: String + let placeholder: String + @Binding var text: String + var isSecure: Bool = false + @FocusState private var focused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(label.uppercased()) + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .tracking(1.8) + .foregroundStyle(focused ? EdgeTheme.accent : EdgeTheme.subtleFg) + .animation(.easeOut(duration: 0.16), value: focused) + + Group { + if isSecure { + SecureField("", text: $text, prompt: + Text(placeholder).foregroundStyle(EdgeTheme.subtleFg.opacity(0.50)) + ) + } else { + TextField("", text: $text, prompt: + Text(placeholder).foregroundStyle(EdgeTheme.subtleFg.opacity(0.50)) + ) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + } + } + .autocorrectionDisabled() + .font(.system(size: 18, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + .focused($focused) + + // Underline indicator + ZStack(alignment: .leading) { + Rectangle() + .fill(Color.white.opacity(0.09)) + .frame(height: 1) + Rectangle() + .fill( + focused ? EdgeTheme.accent : + (!text.isEmpty ? EdgeTheme.accentSecondary : .clear) + ) + .frame(height: focused ? 2 : 1) + .animation(.spring(response: 0.28, dampingFraction: 0.76), value: focused) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { focused = true } + } +} + +// MARK: – Root + +struct EdgeRootView: View { + @Environment(VelocityAppModel.self) private var app + @State private var email = EdgeAppConfig.apiEmail ?? "" + @State private var password = EdgeAppConfig.apiPassword ?? "" + @State private var isSigningIn = false + @State private var heroAppeared = false + + var body: some View { + Group { + switch app.session.rootState { + case .booting: bootView + case .signedOut: loginView + case .signedIn: authenticatedShell + } + } + .task { await app.bootstrap() } + } + + // MARK: Boot + + private var bootView: some View { + ZStack { + Color(red: 0.012, green: 0.014, blue: 0.030).ignoresSafeArea() + + Circle() + .fill(EdgeTheme.accent.opacity(0.18)) + .frame(width: 260, height: 260) + .blur(radius: 60) + + VStack(spacing: 28) { + ZStack { + Circle() + .fill(EdgeTheme.accent.opacity(0.16)) + .frame(width: 120, height: 120) + .blur(radius: 22) + + RoundedRectangle(cornerRadius: 30, style: .continuous) + .fill(LinearGradient( + colors: [EdgeTheme.accent, EdgeTheme.accentPurple], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) + .overlay( + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(Color.white.opacity(0.20), lineWidth: 1) + ) + .shadow(color: EdgeTheme.accent.opacity(0.45), radius: 28, y: 10) + .frame(width: 88, height: 88) + + Image(systemName: "bolt.fill") + .font(.system(size: 32, weight: .heavy)) + .foregroundStyle(.white) + } + + VStack(spacing: 8) { + Text("Velocity") + .font(.system(size: 34, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + Text("Live Control Surface") + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundStyle(Color.white.opacity(0.40)) + } + + HStack(spacing: 8) { + ProgressView() + .tint(EdgeTheme.accent) + .scaleEffect(0.85) + Text("Bootstrapping…") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.subtleFg) + } + } + } + } + + // MARK: Login + // + // Apple Maps / Wallet pattern: + // • One ScrollView owns the full page — no gesture math, no offset tricks. + // • Hero sits at the top with minHeight = 44 % of physical screen. + // • Auth sheet sits directly below with minHeight = full physical screen. + // • Scrolling up naturally slides the hero off-screen and the sheet + // expands to fill the entire display — the OS handles all physics. + // • The drag handle is a visual affordance communicating this affordance. + + private var loginView: some View { + // Use UIScreen for physical screen height so sizes are device-accurate. + let screenH = UIScreen.main.bounds.height + + return ZStack { + // ── Full-bleed background ───────────────────────────────────── + Color(red: 0.010, green: 0.012, blue: 0.026).ignoresSafeArea() + + // Ambient glow orbs — purely decorative + Circle() + .fill(EdgeTheme.accent.opacity(0.24)) + .blur(radius: 90) + .frame(width: 380) + .offset(x: 55, y: -screenH * 0.24) + .ignoresSafeArea() + .allowsHitTesting(false) + + Circle() + .fill(EdgeTheme.accentPurple.opacity(0.18)) + .blur(radius: 80) + .frame(width: 280) + .offset(x: -110, y: -screenH * 0.33) + .ignoresSafeArea() + .allowsHitTesting(false) + + // ── The single scroll axis that drives expansion ─────────────── + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + + // ── HERO ────────────────────────────────────────────── + // Always at least 44 % of screen so it dominates at rest. + // As the user scrolls, it glides off the top. + VStack(spacing: 20) { + Spacer(minLength: 0) + + // Icon tile + ZStack { + Circle() + .fill(EdgeTheme.accent.opacity(0.16)) + .frame(width: 120) + .blur(radius: 22) + RoundedRectangle(cornerRadius: 26, style: .continuous) + .fill(LinearGradient( + colors: [EdgeTheme.accent, EdgeTheme.accentPurple], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .overlay( + RoundedRectangle(cornerRadius: 26, style: .continuous) + .stroke(Color.white.opacity(0.22), lineWidth: 1) + ) + .shadow(color: EdgeTheme.accent.opacity(0.52), radius: 26, y: 10) + .frame(width: 78, height: 78) + Image(systemName: "bolt.fill") + .font(.system(size: 28, weight: .heavy)) + .foregroundStyle(.white) + } + .opacity(heroAppeared ? 1 : 0) + .scaleEffect(heroAppeared ? 1 : 0.68) + .animation(.spring(response: 0.50, dampingFraction: 0.70).delay(0.05), value: heroAppeared) + + // Wordmark + VStack(spacing: 6) { + Text("Velocity") + .font(.system(size: 40, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(-1.0) + Text("Your live command surface.") + .font(.system(size: 15, weight: .regular, design: .rounded)) + .foregroundStyle(.white.opacity(0.44)) + } + .opacity(heroAppeared ? 1 : 0) + .offset(y: heroAppeared ? 0 : 12) + .animation(.spring(response: 0.52, dampingFraction: 0.78).delay(0.12), value: heroAppeared) + + // Live badge + HStack(spacing: 6) { + Circle() + .fill(EdgeTheme.success) + .frame(width: 7, height: 7) + .shadow(color: EdgeTheme.success.opacity(0.8), radius: 5) + Text("Backend live · v\(EdgeAppConfig.appVersion)") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.36)) + } + .opacity(heroAppeared ? 1 : 0) + .animation(.easeOut(duration: 0.38).delay(0.22), value: heroAppeared) + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + // The hero occupies exactly 44 % of the physical screen height. + // Scroll offset of heroH = sheet has fully expanded. + .frame(height: screenH * 0.44) + + // ── AUTH SHEET ───────────────────────────────────────── + // minHeight = full physical screen ensures the dark surface + // fills the viewport completely when fully scrolled. + // The RoundedRectangle's bottom arc is always off-screen. + VStack(spacing: 0) { + + // Drag handle — Apple Maps pattern + Capsule() + .fill(.white.opacity(0.22)) + .frame(width: 36, height: 5) + .padding(.top, 14) + .padding(.bottom, 10) + + // Form content + VStack(alignment: .leading, spacing: 30) { + + // Header + VStack(alignment: .leading, spacing: 6) { + Text("Sign in") + .font(.system(size: 28, weight: .heavy, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + .tracking(-0.6) + Text("Enter your operator credentials to continue.") + .font(.system(size: 14, weight: .regular, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + .lineSpacing(2) + } + + // Underline input fields (CRED / Uber style) + VStack(spacing: 26) { + VelocityUnderlineField( + label: "Email", + placeholder: "operator@desineuron.in", + text: $email + ) + VelocityUnderlineField( + label: "Password", + placeholder: "Live backend password", + text: $password, + isSecure: true + ) + } + + // Primary CTA button + Button { + Task { + isSigningIn = true + await app.session.login(email: email, password: password) + if app.session.rootState == .signedIn { + await app.refreshCurrentModule(forceAll: true) + } + isSigningIn = false + } + } label: { + ZStack { + if isSigningIn { + HStack(spacing: 10) { + ProgressView().tint(.white).scaleEffect(0.90) + Text("Authenticating…") + .font(.system(size: 16, weight: .bold, design: .rounded)) + } + } else { + Text("Continue") + .font(.system(size: 17, weight: .bold, design: .rounded)) + } + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 56) + .background { + let off = email.trimmingCharacters(in: .whitespaces).isEmpty + || password.isEmpty || isSigningIn + if off { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .fill(.white.opacity(0.07)) + } else { + RoundedRectangle(cornerRadius: 17, style: .continuous) + .fill(LinearGradient( + colors: [EdgeTheme.accent, EdgeTheme.accentPurple], + startPoint: .leading, endPoint: .trailing + )) + .shadow(color: EdgeTheme.accent.opacity(0.42), radius: 20, y: 8) + } + } + } + .disabled( + email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || password.isEmpty || isSigningIn + ) + .buttonStyle(_PressScaleStyle()) + + // Inline error + if let msg = app.session.errorMessage { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(EdgeTheme.danger) + Text(msg) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.danger.opacity(0.88)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill(EdgeTheme.danger.opacity(0.09)) + .overlay( + RoundedRectangle(cornerRadius: 13, style: .continuous) + .stroke(EdgeTheme.danger.opacity(0.16), lineWidth: 1) + ) + ) + } + + // Backend endpoint footer + HStack(spacing: 5) { + Circle() + .fill(EdgeTheme.subtleFg.opacity(0.4)) + .frame(width: 4, height: 4) + Text(EdgeAppConfig.baseURL) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(EdgeTheme.subtleFg.opacity(0.60)) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 28) + .padding(.bottom, 60) // home-indicator breathing room + + Spacer(minLength: 0) // keeps content top-aligned in minHeight zone + } + // KEY: minHeight ≥ full screen guarantees dark surface fills + // the viewport when fully scrolled. Content sits at the top. + .frame(maxWidth: .infinity, minHeight: screenH) + .background( + ZStack(alignment: .top) { + // Rounded top surface — bottom arc is always off-screen + RoundedRectangle(cornerRadius: 34, style: .continuous) + .fill(Color(red: 0.060, green: 0.068, blue: 0.112)) + // Subtle top-edge shimmer + RoundedRectangle(cornerRadius: 34, style: .continuous) + .stroke( + LinearGradient( + colors: [.white.opacity(0.14), .white.opacity(0.02)], + startPoint: .top, endPoint: .bottom + ), + lineWidth: 1 + ) + } + // Extend background past the home indicator + .ignoresSafeArea(edges: .bottom) + ) + // Entrance animation — rises from below on first appear + .opacity(heroAppeared ? 1 : 0) + .offset(y: heroAppeared ? 0 : 36) + .animation(.spring(response: 0.55, dampingFraction: 0.82).delay(0.16), value: heroAppeared) + } + } + // ScrollView itself fills the full screen edge-to-edge + .ignoresSafeArea(edges: .top) + // Allow bounce to feel natural (default is true, explicit for clarity) + .scrollBounceBehavior(.basedOnSize) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.06) { heroAppeared = true } + } + .onDisappear { heroAppeared = false } + } + + // MARK: Authenticated shell + + private var authenticatedShell: some View { + ZStack { + switch app.session.selectedModule { + case .home: HomeModuleView() + case .command: CommandModuleView() + case .sentinel: SentinelModuleView() + case .inventory: InventoryModuleView() + case .catalyst: CatalystModuleView() + } + + VelocityBottomNavigation(selection: Binding( + get: { app.session.selectedModule }, + set: { m in + app.session.selectedModule = m + Task { await app.refreshCurrentModule() } + } + )) { + app.session.showingSettings = true + } + } + .sheet(isPresented: Binding( + get: { app.session.showingSettings }, + set: { app.session.showingSettings = $0 } + )) { + SettingsModuleView() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } +} + +// MARK: – Press-scale button style + +private struct _PressScaleStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.970 : 1) + .opacity(configuration.isPressed ? 0.88 : 1) + .animation(.spring(response: 0.19, dampingFraction: 0.68), value: configuration.isPressed) + } +} + +// MARK: – Section header + +private struct _SectionHeader: View { + let title: String + let systemImage: String + var tint: Color = EdgeTheme.accent + + var body: some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(tint) + Text(title.uppercased()) + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .tracking(1.4) + .foregroundStyle(tint) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background( + Capsule(style: .continuous) + .fill(tint.opacity(0.10)) + .overlay(Capsule(style: .continuous).stroke(tint.opacity(0.18), lineWidth: 1)) + ) + } +} + +// MARK: –– HOME ─────────────────────────────────────────────────────────────── + +struct HomeModuleView: View { + @Environment(VelocityAppModel.self) private var app + + private var topLead: VelocityLead? { + app.home.leads.sorted(by: { $0.score > $1.score }).first + } + + var body: some View { + VelocityModuleScreen( + title: "Home", + subtitle: "Live investor command surface." + ) { + if let error = app.home.errorMessage { + _ErrorBanner(message: error) + } + + // ── Priority Lead hero card ────────────────────────────────── + if let lead = topLead { + _LeadHeroCard(lead: lead, alerts: app.home.alerts) + } else { + _EmptyLeadHero() + } + + // ── 2×2 Metric grid ───────────────────────────────────────── + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) { + VelocityMetricTile(label: "Leads", value: "\(app.home.leads.count)", tint: EdgeTheme.accent) + VelocityMetricTile(label: "Due 24h", value: "\(app.home.alerts?.upcomingCalendarEvents24h ?? 0)", tint: EdgeTheme.warning) + VelocityMetricTile(label: "Transcripts", value: "\(app.home.alerts?.pendingTranscriptions ?? 0)", tint: EdgeTheme.accentWarm) + VelocityMetricTile(label: "System", value: app.home.adminHealth?.status.capitalized ?? "—", tint: EdgeTheme.success) + } + + // ── Quick actions ───────────────────────────────────────────── + VelocityGlassCard(title: "Quick Actions") { + VStack(spacing: 8) { + _ActionRow(icon: "person.text.rectangle", label: "Lead Dossier") { app.session.selectedModule = .command; app.command.selectedSection = .oracle } + _ActionRow(icon: "square.and.pencil", label: "Operator Note") { app.session.selectedModule = .command; app.command.selectedSection = .notes } + _ActionRow(icon: "waveform.path.ecg.rectangle", label: "Review Transcript") { app.session.selectedModule = .command; app.command.selectedSection = .transcriptions } + _ActionRow(icon: "eye", label: "Sentinel Live") { app.session.selectedModule = .sentinel; app.sentinel.selectedSection = .live } + _ActionRow(icon: "wand.and.stars", label: "Dream Weaver") { app.session.selectedModule = .inventory; app.inventory.selectedSection = .dreamWeaver } + } + } + + // ── Upcoming calendar ───────────────────────────────────────── + VelocityGlassCard(title: "Upcoming") { + if app.home.calendar.isEmpty { + _EmptyState(icon: "calendar", message: "No confirmed follow-ups scheduled.") + } else { + ForEach(app.home.calendar.prefix(4)) { event in + _TimelineRow( + icon: "calendar.circle.fill", + title: event.title, + subtitle: event.location ?? "Lead-linked follow-up", + trailing: event.startAt.edgeRelativeShort, + tint: EdgeTheme.accent + ) + } + } + } + + // ── Recent comms ───────────────────────────────────────────── + VelocityGlassCard(title: "Recent Communications") { + if app.home.events.isEmpty { + _EmptyState(icon: "bubble.left.and.bubble.right", message: "No recent communication events.") + } else { + ForEach(app.home.events.prefix(4)) { event in + _TimelineRow( + icon: _channelIcon(event.channel), + title: event.channel.replacingOccurrences(of: "_", with: " ").capitalized, + subtitle: event.summary ?? "No summary available.", + trailing: event.timestamp.edgeRelativeShort, + tint: EdgeTheme.accentWarm + ) + } + } + } + } + .refreshable { await app.refreshCurrentModule() } + .task { + await app.home.refresh(token: app.session.token ?? "") + await app.session.sendHeartbeat(module: .home, screen: "home_dashboard") + } + } + + private func _channelIcon(_ channel: String) -> String { + switch channel.lowercased() { + case let c where c.contains("call"): return "phone.circle.fill" + case let c where c.contains("email"): return "envelope.circle.fill" + case let c where c.contains("whats"): return "message.circle.fill" + default: return "bubble.left.circle.fill" + } + } +} + +private struct _LeadHeroCard: View { + let lead: VelocityLead + let alerts: VelocityAlertSnapshot? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + ZStack { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.18)) + Text(String(lead.name.prefix(1))) + .font(.system(size: 22, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + } + .frame(width: 52, height: 52) + + VStack(alignment: .leading, spacing: 5) { + Text(lead.name) + .font(.system(size: 19, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + Text("\(lead.qualification.capitalized) intent · \(lead.unitInterest)") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.75)) + } + + Spacer() + + // Score badge + VStack(spacing: 2) { + Text("\(lead.score)") + .font(.system(size: 22, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + Text("SCORE") + .font(.system(size: 8, weight: .heavy, design: .rounded)) + .tracking(1.4) + .foregroundStyle(.white.opacity(0.60)) + } + } + + Divider().background(Color.white.opacity(0.15)) + + HStack(spacing: 8) { + _HeroPill(label: "\(alerts?.pendingInsights ?? 0) insights", tint: .white.opacity(0.80)) + _HeroPill(label: lead.budget, tint: .white.opacity(0.80)) + _HeroPill(label: lead.kanbanStatus.capitalized, tint: .white.opacity(0.80)) + } + } + .padding(20) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(LinearGradient( + colors: [EdgeTheme.accent, EdgeTheme.accentPurple.opacity(0.88)], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color.white.opacity(0.16), lineWidth: 1) + ) + .shadow(color: EdgeTheme.accent.opacity(0.30), radius: 28, y: 12) + ) + } +} + +private struct _EmptyLeadHero: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "person.crop.circle.dashed") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(EdgeTheme.mutedFg) + Text("No live leads in queue") + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + Text("Awaiting the first live lead in this operator scope.") + .font(.system(size: 13, weight: .regular, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(28) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color.white.opacity(0.04)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(EdgeTheme.borderSubtle, lineWidth: 1) + ) + ) + } +} + +private struct _HeroPill: View { + let label: String + let tint: Color + + var body: some View { + Text(label) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule(style: .continuous) + .fill(Color.white.opacity(0.16)) + ) + } +} + +// MARK: –– COMMAND ──────────────────────────────────────────────────────────── + +struct CommandModuleView: View { + @Environment(VelocityAppModel.self) private var app + + var body: some View { + VelocityModuleScreen(title: "Command", subtitle: "Lead control, CRM, comms, notes, calendar, transcripts & insights.") { + + // Section tabs + VelocitySectionPicker( + sections: VelocityCommandStore.Section.allCases, + selection: Binding(get: { app.command.selectedSection }, set: { app.command.selectedSection = $0 }), + title: { $0.rawValue } + ) + + if let error = app.command.errorMessage { _ErrorBanner(message: error) } + + if let msg = app.command.actionMessage { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(EdgeTheme.success) + Text(msg) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(EdgeTheme.success.opacity(0.10)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(EdgeTheme.success.opacity(0.20), lineWidth: 1)) + ) + } + + switch app.command.selectedSection { + case .oracle: oracleSection + case .crm: crmSection + case .alerts: alertsSection + case .communications: commsSection + case .notes: notesSection + case .calendar: calendarSection + case .transcriptions: transcriptSection + case .insights: insightsSection + } + } + .refreshable { await app.refreshCurrentModule() } + .task { + await app.command.refresh(token: app.session.token ?? "") + await app.session.sendHeartbeat(module: .command, screen: "command_oracle") + } + .onChange(of: app.command.selectedSection) { _, s in + Task { await app.session.sendHeartbeat(module: .command, screen: "command_\(s.rawValue.lowercased())") } + } + } + + // Oracle + private var oracleSection: some View { + VStack(spacing: 14) { + if let lead = app.command.primaryLead { + VelocityGlassCard(title: "Priority Lead", tint: EdgeTheme.accent) { + VStack(spacing: 0) { + _KV("Name", lead.name) + _Divider() + _KV("Stage", lead.kanbanStatus) + _Divider() + _KV("Budget", lead.budget) + _Divider() + _KV("Unit", lead.unitInterest) + _Divider() + _KV("Score", "\(lead.score)") + } + } + } + if let c360 = app.command.client360 { + VelocityGlassCard(title: "Client 360", tint: EdgeTheme.accentSecondary) { + Text(c360.summaryText) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + .lineSpacing(3) + } + } + if app.command.primaryLead == nil { + _EmptyState(icon: "person.badge.clock", message: "No live lead available for Oracle summary.") + } + } + } + + // CRM + private var crmSection: some View { + VStack(spacing: 14) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + VelocityMetricTile(label: "Contacts", value: "\(app.command.contacts.count)", tint: EdgeTheme.accent) + VelocityMetricTile(label: "Deals", value: "\(app.command.opportunities.count)", tint: EdgeTheme.accentSecondary) + VelocityMetricTile(label: "Tasks", value: "\(app.command.tasks.count)", tint: EdgeTheme.warning) + VelocityMetricTile(label: "Kanban Cols", value: "\(app.command.kanban.count)", tint: EdgeTheme.accentWarm) + } + + if !app.command.contacts.isEmpty { + VelocityGlassCard(title: "Top Contacts") { + ForEach(app.command.contacts.prefix(5)) { contact in + _TimelineRow( + icon: "person.circle.fill", + title: contact.fullName, + subtitle: [contact.primaryEmail, contact.primaryPhone].compactMap { $0 }.joined(separator: " · "), + trailing: contact.leadStatus ?? "Live", + tint: EdgeTheme.accent + ) + } + } + } + + VelocityGlassCard(title: "Create Follow-Up Task") { + _InlineEditor( + placeholder: "e.g. Call back tomorrow 11 AM", + text: Binding(get: { app.command.taskDraft }, set: { app.command.taskDraft = $0 }) + ) + Button("Create Task") { + Task { await app.command.createFollowUp(token: app.session.token ?? "") } + } + .buttonStyle(_ProminentButton(enabled: !app.command.taskDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + .disabled(app.command.taskDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + // Alerts + private var alertsSection: some View { + VelocityGlassCard(title: "Alert Stack", tint: EdgeTheme.danger) { + _KV("Pending Insights", "\(app.command.alerts?.pendingInsights ?? 0)") + _Divider() + _KV("Due in 24h", "\(app.command.alerts?.upcomingCalendarEvents24h ?? 0)") + _Divider() + _KV("Pending Transcripts", "\(app.command.alerts?.pendingTranscriptions ?? 0)") + } + } + + // Comms + private var commsSection: some View { + VelocityGlassCard(title: "Communication Threads") { + if app.command.events.isEmpty { + _EmptyState(icon: "bubble.left.and.bubble.right", message: "No live communication events returned yet.") + } else { + ForEach(app.command.events) { event in + _TimelineRow( + icon: event.recordingRef == nil ? "bubble.left.circle.fill" : "waveform.circle.fill", + title: event.channel.replacingOccurrences(of: "_", with: " ").capitalized, + subtitle: event.summary ?? "No summary available.", + trailing: event.timestamp.edgeRelativeShort, + tint: event.recordingRef == nil ? EdgeTheme.accent : EdgeTheme.accentWarm + ) + } + } + } + } + + // Notes + private var notesSection: some View { + VStack(spacing: 14) { + if !app.command.memoryFacts.isEmpty { + VelocityGlassCard(title: "Memory Facts") { + ForEach(app.command.memoryFacts.prefix(6)) { fact in + _TimelineRow( + icon: "lightbulb.circle.fill", + title: fact.factType.replacingOccurrences(of: "_", with: " ").capitalized, + subtitle: fact.factText, + trailing: fact.createdAt.edgeRelativeShort, + tint: EdgeTheme.accentSecondary + ) + } + } + } else { + _EmptyState(icon: "note.text", message: "No operator memory facts stored for this lead yet.") + } + + VelocityGlassCard(title: "Capture Note") { + _InlineEditor( + placeholder: "e.g. Client prefers corner units on higher floors…", + text: Binding(get: { app.command.noteDraft }, set: { app.command.noteDraft = $0 }), + multiline: true + ) + Button("Save Note") { + Task { await app.command.createNote(token: app.session.token ?? "") } + } + .buttonStyle(_ProminentButton(enabled: !app.command.noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + .disabled(app.command.noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + // Calendar + private var calendarSection: some View { + VStack(spacing: 14) { + VelocityGlassCard(title: "Schedule Follow-Up") { + _InlineEditor( + placeholder: "e.g. Site visit at Sobha Hartland", + text: Binding(get: { app.command.calendarTitleDraft }, set: { app.command.calendarTitleDraft = $0 }) + ) + Button("Create Event") { + Task { await app.command.createCalendarEvent(token: app.session.token ?? "") } + } + .buttonStyle(_ProminentButton(enabled: !app.command.calendarTitleDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)) + .disabled(app.command.calendarTitleDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if !app.command.calendar.isEmpty { + VelocityGlassCard(title: "Upcoming") { + ForEach(app.command.calendar.prefix(6)) { event in + _TimelineRow( + icon: "calendar.circle.fill", + title: event.title, + subtitle: event.location ?? event.description ?? "Live calendar event", + trailing: event.startAt.edgeRelativeShort, + tint: event.status == "cancelled" ? EdgeTheme.danger : EdgeTheme.accent + ) + } + } + } + } + } + + // Transcript + private var transcriptSection: some View { + VelocityGlassCard(title: "Call Transcript") { + if let t = app.command.transcript { + _KV("Status", t.job.status) + _Divider() + _KV("Provider", t.job.provider ?? "Unknown") + _Divider() + _KV("Language", t.job.language ?? "Unknown") + _Divider() + _KV("Segments", "\(t.segments.count)") + + if !t.segments.isEmpty { + Divider().background(EdgeTheme.borderSubtle).padding(.vertical, 4) + ForEach(t.segments.prefix(4)) { seg in + _TimelineRow( + icon: "waveform.circle.fill", + title: seg.speakerLabel ?? "Speaker", + subtitle: seg.text, + trailing: seg.startMs.map { "\($0 / 1000)s" } ?? "—", + tint: EdgeTheme.accentWarm + ) + } + } + } else { + _EmptyState(icon: "waveform.path.ecg.rectangle", message: "No recording-backed transcript for the current lead.") + } + } + } + + // Insights + private var insightsSection: some View { + VStack(spacing: 12) { + if app.command.insights.isEmpty { + _EmptyState(icon: "sparkles", message: "No actionable insights available for this lead.") + } else { + ForEach(app.command.insights.prefix(5)) { insight in + VelocityGlassCard(title: "Insight", tint: EdgeTheme.accentWarm) { + Text(insight.summary) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + if let action = insight.suggestedAction { + Text(action) + .font(.system(size: 13, weight: .regular, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + .padding(.top, 2) + } + HStack(spacing: 10) { + Button("Accept") { + Task { await app.command.act(on: insight, action: "accepted", token: app.session.token ?? "") } + } + .buttonStyle(_ChipButton(tint: EdgeTheme.success)) + Button("Dismiss") { + Task { await app.command.act(on: insight, action: "dismissed", token: app.session.token ?? "") } + } + .buttonStyle(_ChipButton(tint: EdgeTheme.danger)) + } + .padding(.top, 6) + } + } + } + } + } +} + +// MARK: –– SENTINEL ─────────────────────────────────────────────────────────── + +struct SentinelModuleView: View { + @Environment(VelocityAppModel.self) private var app + + var body: some View { + VelocityModuleScreen(title: "Sentinel", subtitle: "Showroom intelligence — live visitor load, alert posture & video stack.") { + + VelocitySectionPicker( + sections: VelocitySentinelStore.Section.allCases, + selection: Binding(get: { app.sentinel.selectedSection }, set: { app.sentinel.selectedSection = $0 }), + title: { $0.rawValue } + ) + + if let error = app.sentinel.errorMessage { _ErrorBanner(message: error) } + + if app.sentinel.selectedSection == .overview { + // Status hero + _SentinelHero( + leads: app.sentinel.leads.count, + videos: app.sentinel.videos.count, + insights: app.sentinel.alerts?.pendingInsights ?? 0, + health: app.sentinel.adminHealth?.status + ) + + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + VelocityMetricTile(label: "Active Leads", value: "\(app.sentinel.leads.count)", tint: EdgeTheme.accent) + VelocityMetricTile(label: "Video Assets", value: "\(app.sentinel.videos.count)", tint: EdgeTheme.accentWarm) + VelocityMetricTile(label: "Transcripts", value: "\(app.sentinel.alerts?.pendingTranscriptions ?? 0)", tint: EdgeTheme.warning) + VelocityMetricTile(label: "Health", value: app.sentinel.adminHealth?.status.capitalized ?? "—", tint: EdgeTheme.success) + } + + VelocityGlassCard(title: "High-Score Sessions") { + if app.sentinel.leads.isEmpty { + _EmptyState(icon: "eye.slash", message: "No active visitor sessions.") + } else { + ForEach(app.sentinel.leads.sorted(by: { $0.score > $1.score }).prefix(5)) { lead in + _TimelineRow( + icon: "person.crop.circle.fill", + title: lead.name, + subtitle: "\(lead.qualification.capitalized) · \(lead.unitInterest)", + trailing: "\(lead.score)", + tint: lead.score > 80 ? EdgeTheme.success : EdgeTheme.warning + ) + } + } + } + } else { + VelocityGlassCard(title: "Live Session Stack") { + if app.sentinel.videos.isEmpty { + _EmptyState(icon: "play.slash.fill", message: "No live marketing videos available for review.") + } else { + ForEach(app.sentinel.videos.prefix(5)) { video in + _TimelineRow( + icon: "play.circle.fill", + title: video.title, + subtitle: "\(video.propertyName) · \(video.unitNumber) · \(video.type)", + trailing: "Live", + tint: EdgeTheme.accentSecondary + ) + } + } + } + } + } + .refreshable { await app.refreshCurrentModule() } + .task { + await app.sentinel.refresh(token: app.session.token ?? "") + await app.session.sendHeartbeat(module: .sentinel, screen: "sentinel_overview") + } + .onChange(of: app.sentinel.selectedSection) { _, s in + Task { await app.session.sendHeartbeat(module: .sentinel, screen: "sentinel_\(s.rawValue.lowercased())") } + } + } +} + +private struct _SentinelHero: View { + let leads: Int + let videos: Int + let insights: Int + let health: String? + + var body: some View { + HStack(spacing: 0) { + _StatPill(value: "\(leads)", label: "Visitors", tint: EdgeTheme.accent) + _Separator() + _StatPill(value: "\(videos)", label: "Videos", tint: EdgeTheme.accentWarm) + _Separator() + _StatPill(value: "\(insights)", label: "Insights", tint: EdgeTheme.danger) + _Separator() + _StatPill(value: health?.capitalized ?? "—", label: "System", tint: EdgeTheme.success) + } + .padding(.vertical, 18) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color.white.opacity(0.045)) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(EdgeTheme.borderGlass, lineWidth: 1) + ) + ) + } + + private func _Separator() -> some View { + Rectangle() + .fill(EdgeTheme.borderSubtle) + .frame(width: 1, height: 32) + } + + private struct _StatPill: View { + let value: String + let label: String + let tint: Color + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.system(size: 22, weight: .heavy, design: .rounded)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(label.uppercased()) + .font(.system(size: 9, weight: .heavy, design: .rounded)) + .tracking(1.2) + .foregroundStyle(EdgeTheme.subtleFg) + } + .frame(maxWidth: .infinity) + } + } +} + +// MARK: –– INVENTORY ────────────────────────────────────────────────────────── + +struct InventoryModuleView: View { + @Environment(VelocityAppModel.self) private var app + @State private var showingCamera = false + @State private var showingShare = false + + var body: some View { + VelocityModuleScreen(title: "Inventory", subtitle: "Portfolio, unit detail, Dream Weaver & Sunseeker.") { + + VelocitySectionPicker( + sections: VelocityInventoryStore.Section.allCases, + selection: Binding(get: { app.inventory.selectedSection }, set: { app.inventory.selectedSection = $0 }), + title: { $0.rawValue } + ) + + if let error = app.inventory.errorMessage { _ErrorBanner(message: error) } + + switch app.inventory.selectedSection { + case .portfolio: portfolioSection + case .units: unitsSection + case .dreamWeaver: dreamWeaverSection + case .sunseeker: sunseekerSection + } + } + .refreshable { await app.refreshCurrentModule() } + .task { + await app.inventory.refresh(token: app.session.token ?? "") + await app.inventory.refreshDreamWeaverHealth() + await app.session.sendHeartbeat(module: .inventory, screen: "inventory_portfolio") + } + .onChange(of: app.inventory.selectedSection) { _, s in + Task { await app.session.sendHeartbeat(module: .inventory, screen: "inventory_\(s.rawValue.lowercased())") } + } + .sheet(isPresented: $showingCamera) { + VelocityImagePicker(isPresented: $showingCamera) { img in + app.inventory.sourceImage = img + app.inventory.generatedImage = nil + app.inventory.dreamWeaverMessage = nil + } + } + .sheet(isPresented: $showingShare) { + if let img = app.inventory.generatedImage { VelocityShareSheet(image: img) } + } + } + + // Portfolio + private var portfolioSection: some View { + VelocityGlassCard(title: "Property Portfolio") { + if app.inventory.properties.isEmpty { + _EmptyState(icon: "building.2", message: "No properties returned for this operator scope.") + } else { + ForEach(app.inventory.properties.prefix(8)) { prop in + Button { + app.inventory.selectedPropertyID = prop.propertyId + Task { await app.inventory.refresh(token: app.session.token ?? "") } + app.inventory.selectedSection = .units + } label: { + HStack { + _TimelineRow( + icon: "building.2.fill", + title: prop.projectName, + subtitle: "\(prop.developerName) · \(prop.propertyType)", + trailing: prop.status.capitalized, + tint: EdgeTheme.accent + ) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(EdgeTheme.subtleFg) + } + } + .buttonStyle(.plain) + } + } + } + } + + // Units / Detail + private var unitsSection: some View { + VStack(spacing: 14) { + if let prop = app.inventory.selectedProperty { + VelocityGlassCard(title: "Property Detail") { + _KV("Project", prop.projectName) + _Divider() + _KV("Developer", prop.developerName) + _Divider() + _KV("Type", prop.propertyType) + _Divider() + _KV("Status", prop.status.capitalized) + _Divider() + _KV("Location", JSONValue.object(prop.location).summaryText) + } + } else { + _EmptyState(icon: "building.2.crop.circle", message: "Select a property from the Portfolio tab.") + } + + VelocityGlassCard(title: "Media & Floor Plans") { + if app.inventory.media.isEmpty { + _EmptyState(icon: "photo.on.rectangle.angled", message: "No media assets for this property.") + } else { + ForEach(app.inventory.media.prefix(6)) { m in + _TimelineRow( + icon: "photo.circle.fill", + title: m.mediaType.replacingOccurrences(of: "_", with: " ").capitalized, + subtitle: m.url, + trailing: "Asset", + tint: EdgeTheme.accentWarm + ) + } + } + } + } + } + + // Dream Weaver + private var dreamWeaverSection: some View { + VStack(spacing: 14) { + // Status banner + HStack(spacing: 10) { + Circle() + .fill(app.inventory.dreamWeaverOnline == true ? EdgeTheme.success : EdgeTheme.danger) + .frame(width: 8, height: 8) + .shadow(color: app.inventory.dreamWeaverOnline == true ? EdgeTheme.success : EdgeTheme.danger, radius: 4) + Text(app.inventory.dreamWeaverOnline == true ? "Gateway online" : app.inventory.dreamWeaverOnline == false ? "Gateway offline" : "Checking gateway…") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + Spacer() + Text(app.inventory.roomType.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(EdgeTheme.accent) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Capsule(style: .continuous).fill(EdgeTheme.accent.opacity(0.12))) + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.04)) + .overlay(RoundedRectangle(cornerRadius: 18, style: .continuous).stroke(EdgeTheme.borderSubtle, lineWidth: 1)) + ) + + // Image viewport + ZStack { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.black.opacity(0.90)) + .frame(minHeight: 320) + + if let image = app.inventory.generatedImage ?? app.inventory.sourceImage { + Image(uiImage: image) + .resizable() + .scaledToFit() + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .padding(14) + } else { + VStack(spacing: 10) { + Image(systemName: "camera.viewfinder") + .font(.system(size: 44, weight: .ultraLight)) + .foregroundStyle(EdgeTheme.subtleFg) + Text("Capture a room to reimagine it") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } + } + + if app.inventory.dreamWeaverBusy { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.black.opacity(0.55)) + VStack(spacing: 12) { + ProgressView().tint(.white).scaleEffect(1.2) + Text("Running Dream Weaver pipeline…") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.80)) + } + } + } + + // Room selector + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(["bedroom","living_room","bathroom","kitchen","dining_room","home_office","hallway","balcony"], id: \.self) { room in + Button(room.replacingOccurrences(of: "_", with: " ").capitalized) { + app.inventory.roomType = room + } + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(app.inventory.roomType == room ? EdgeTheme.foreground : EdgeTheme.mutedFg) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Capsule(style: .continuous) + .fill(app.inventory.roomType == room ? EdgeTheme.accent.opacity(0.20) : Color.white.opacity(0.06)) + .overlay(Capsule(style: .continuous).stroke(app.inventory.roomType == room ? EdgeTheme.accent.opacity(0.40) : EdgeTheme.borderSubtle, lineWidth: 1)) + ) + .buttonStyle(.plain) + } + } + .padding(.horizontal, 2) + } + + // Keywords + _InlineEditor( + placeholder: "Optional style keywords (e.g. minimalist, warm tones…)", + text: Binding(get: { app.inventory.keywords }, set: { app.inventory.keywords = $0 }) + ) + + // Actions + HStack(spacing: 10) { + Button("Capture") { showingCamera = true } + .buttonStyle(_ChipButton(tint: EdgeTheme.accentSecondary)) + .frame(maxWidth: .infinity) + + Button("Reimagine") { Task { await app.inventory.generateDreamWeaver() } } + .buttonStyle(_ProminentButton( + enabled: app.inventory.sourceImage != nil && !app.inventory.dreamWeaverBusy + )) + .disabled(app.inventory.sourceImage == nil || app.inventory.dreamWeaverBusy) + .frame(maxWidth: .infinity) + } + + if app.inventory.generatedImage != nil { + Button("Share Result") { showingShare = true } + .buttonStyle(_ChipButton(tint: EdgeTheme.accentWarm)) + .frame(maxWidth: .infinity) + } + + if let msg = app.inventory.dreamWeaverMessage { + HStack(spacing: 8) { + Image(systemName: msg.localizedCaseInsensitiveContains("render") ? "checkmark.circle.fill" : "exclamationmark.circle.fill") + .foregroundStyle(msg.localizedCaseInsensitiveContains("render") ? EdgeTheme.success : EdgeTheme.warning) + Text(msg) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } + .padding(12) + .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(Color.white.opacity(0.04))) + } + } + } + + // Sunseeker + private var sunseekerSection: some View { + VStack(spacing: 14) { + VStack(spacing: 16) { + Image(systemName: "sun.max.trianglebadge.exclamationmark") + .font(.system(size: 44, weight: .light)) + .foregroundStyle(EdgeTheme.warning) + + Text("Real Device Required") + .font(.system(size: 17, weight: .heavy, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + + Text("Sunseeker uses ARKit, CoreLocation, and CoreMotion. This surface is reserved for real-device deployment where full sensor access is available.") + .font(.system(size: 13, weight: .regular, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + .multilineTextAlignment(.center) + .lineSpacing(3) + } + .padding(28) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(EdgeTheme.warning.opacity(0.06)) + .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(EdgeTheme.warning.opacity(0.18), lineWidth: 1)) + ) + } + } +} + +// MARK: –– CATALYST ─────────────────────────────────────────────────────────── + +struct CatalystModuleView: View { + @Environment(VelocityAppModel.self) private var app + + var body: some View { + VelocityModuleScreen(title: "Catalyst", subtitle: "Campaign intelligence — spend, reach, ROI & creative posture.") { + + VelocitySectionPicker( + sections: VelocityCatalystStore.Section.allCases, + selection: Binding(get: { app.catalyst.selectedSection }, set: { app.catalyst.selectedSection = $0 }), + title: { $0.rawValue } + ) + + if let error = app.catalyst.errorMessage { _ErrorBanner(message: error) } + + // Summary hero tiles + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + VelocityMetricTile(label: "Campaigns", value: "\(app.catalyst.campaigns.count)", tint: EdgeTheme.accent) + VelocityMetricTile(label: "Insights", value: "\(app.catalyst.insights.count)", tint: EdgeTheme.accentWarm) + VelocityMetricTile(label: "Total Spend", value: app.catalyst.campaigns.reduce(0.0) { $0 + $1.spent }.asCurrency, tint: EdgeTheme.warning) + VelocityMetricTile(label: "Conversions", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.conversions })", tint: EdgeTheme.success) + } + + switch app.catalyst.selectedSection { + case .studio, .marketing: creativeSection + case .command: commandSection + case .roi: roiSection + case .warRoom: warRoomSection + } + } + .refreshable { await app.refreshCurrentModule() } + .task { + await app.catalyst.refresh(token: app.session.token ?? "") + await app.session.sendHeartbeat(module: .catalyst, screen: "catalyst_studio") + } + .onChange(of: app.catalyst.selectedSection) { _, s in + Task { await app.session.sendHeartbeat(module: .catalyst, screen: "catalyst_\(s.rawValue.lowercased())") } + } + } + + private var creativeSection: some View { + VelocityGlassCard(title: "Campaign Studio") { + if app.catalyst.campaigns.isEmpty { + _EmptyState(icon: "megaphone.fill", message: "No active campaigns in this marketing scope.") + } else { + ForEach(app.catalyst.campaigns.prefix(6)) { c in + _TimelineRow( + icon: _platformIcon(c.platform), + title: c.name, + subtitle: "\(c.platform.capitalized) · \(c.objective ?? "Campaign")", + trailing: c.status.capitalized, + tint: EdgeTheme.accent + ) + } + } + } + } + + private var commandSection: some View { + VelocityGlassCard(title: "Campaign Command") { + if app.catalyst.campaigns.isEmpty { + _EmptyState(icon: "chart.bar.xaxis", message: "No campaigns to display.") + } else { + ForEach(app.catalyst.campaigns.prefix(6)) { c in + VStack(alignment: .leading, spacing: 4) { + Text(c.name) + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + HStack { + Text("Budget \(c.budget.asCurrency)") + Text("·") + Text("Spent \(c.spent.asCurrency)") + } + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } + .padding(.vertical, 4) + if c.id != app.catalyst.campaigns.prefix(6).last?.id { + Divider().background(EdgeTheme.borderSubtle) + } + } + } + } + } + + private var roiSection: some View { + VStack(spacing: 14) { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { + VelocityMetricTile(label: "Spend", value: app.catalyst.campaigns.reduce(0.0) { $0 + $1.spent }.asCurrency, tint: EdgeTheme.warning) + VelocityMetricTile(label: "Impressions", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.impressions })", tint: EdgeTheme.accent) + VelocityMetricTile(label: "Clicks", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.clicks })", tint: EdgeTheme.accentSecondary) + VelocityMetricTile(label: "Conversions", value: "\(app.catalyst.campaigns.reduce(0) { $0 + $1.conversions })", tint: EdgeTheme.success) + } + } + } + + private var warRoomSection: some View { + VelocityGlassCard(title: "War Room", tint: EdgeTheme.accentWarm) { + if app.catalyst.insights.isEmpty { + _EmptyState(icon: "bolt.shield", message: "No live marketing intelligence events.") + } else { + ForEach(app.catalyst.insights.prefix(6)) { i in + _TimelineRow( + icon: "chart.line.uptrend.xyaxis.circle.fill", + title: i.campaignId ?? "Campaign", + subtitle: "\(i.platform?.capitalized ?? "Platform") · Spend \((i.spend ?? 0).asCurrency)", + trailing: "\(i.clicks ?? 0) clicks", + tint: EdgeTheme.accentWarm + ) + } + } + } + } + + private func _platformIcon(_ p: String) -> String { + switch p.lowercased() { + case "meta", "facebook": return "f.circle.fill" + case "google": return "g.circle.fill" + case "tiktok": return "play.circle.fill" + default: return "megaphone.circle.fill" + } + } +} + +// MARK: –– SETTINGS ─────────────────────────────────────────────────────────── + +struct SettingsModuleView: View { + @Environment(VelocityAppModel.self) private var app + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ZStack { + EdgeAmbientBackground().ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(spacing: 20) { + + // Profile card + _ProfileCard( + name: app.session.displayName, + role: app.session.roleLabel, + email: app.session.profile?.email ?? "" + ) + + VelocityGlassCard(title: "Runtime") { + _KV("Backend", EdgeAppConfig.baseURL) + _Divider() + _KV("Version", EdgeAppConfig.appVersion) + _Divider() + _KV("Heartbeat", app.session.lastHeartbeatAt?.edgeRelativeShort ?? "None yet") + _Divider() + _KV("Last Screen", app.session.lastScreenName) + _Divider() + _KV("Auth Mode", EdgeAppConfig.authModeDescription) + } + + if let health = app.settings.adminHealth { + VelocityGlassCard(title: "System Health", tint: health.status == "ok" ? EdgeTheme.success : EdgeTheme.danger) { + _KV("Status", health.status.capitalized) + _Divider() + _KV("Transcriptions", "\(health.queues.pendingTranscriptions)") + _Divider() + _KV("Synthetic Jobs", "\(health.queues.pendingSyntheticJobs)") + _Divider() + _KV("Admin Actions", "\(health.queues.pendingAdminActions)") + _Divider() + _KV("Inventory Batches","\(health.queues.pendingInventoryBatches)") + } + } + + if let error = app.settings.errorMessage { _ErrorBanner(message: error) } + + // Sign out + Button { + app.session.logout() + dismiss() + } label: { + HStack(spacing: 8) { + Image(systemName: "rectangle.portrait.and.arrow.right") + Text("Sign Out") + } + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(EdgeTheme.danger) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(EdgeTheme.danger.opacity(0.10)) + .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous).stroke(EdgeTheme.danger.opacity(0.22), lineWidth: 1)) + ) + } + .buttonStyle(_PressScaleStyle()) + } + .padding(.horizontal, 16) // Apple's canonical content margin + .padding(.top, 16) + .padding(.bottom, 40) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbarColorScheme(.dark, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .foregroundStyle(EdgeTheme.accent) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + } + } + .task { + if let token = app.session.token { + await app.settings.refresh(token: token) + await app.session.sendHeartbeat(module: app.session.selectedModule, screen: "settings_sheet") + } + } + } + } +} + +private struct _ProfileCard: View { + let name: String + let role: String + let email: String + + var body: some View { + HStack(spacing: 16) { + ZStack { + Circle() + .fill(LinearGradient(colors: [EdgeTheme.accent, EdgeTheme.accentPurple], startPoint: .topLeading, endPoint: .bottomTrailing)) + Text(String(name.prefix(1)).uppercased()) + .font(.system(size: 22, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + } + .frame(width: 56, height: 56) + + VStack(alignment: .leading, spacing: 4) { + Text(name) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + Text(role) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(EdgeTheme.accent) + if !email.isEmpty { + Text(email) + .font(.system(size: 12, weight: .regular, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } + } + + Spacer() + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(EdgeTheme.cardGradient) + .overlay(RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(EdgeTheme.borderGlass, lineWidth: 1)) + .shadow(color: .black.opacity(0.25), radius: 18, y: 8) + ) + } +} + +// MARK: – Shared sub-components ─────────────────────────────────────────────── + +private struct _ActionRow: View { + let icon: String + let label: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + // Tinted icon in a fixed 32×32 touch target — Apple Settings icon grid + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(EdgeTheme.accent.opacity(0.14)) + .frame(width: 32, height: 32) + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(EdgeTheme.accent) + } + Text(label) + .font(.system(.subheadline, design: .rounded, weight: .medium)) + .foregroundStyle(EdgeTheme.foreground) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color(uiColor: .tertiaryLabel)) + } + // 11pt V — Apple's standard list row height minus content height + .padding(.vertical, 11) + } + .buttonStyle(_PressScaleStyle()) + } +} + +private struct _TimelineRow: View { + let icon: String + let title: String + let subtitle: String + let trailing: String + let tint: Color + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Filled SF Symbol icon — tinted, clear visual anchor + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(tint) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(.subheadline, design: .rounded, weight: .semibold)) + .foregroundStyle(EdgeTheme.foreground) + .lineLimit(1) + Text(subtitle) + .font(.system(.caption, design: .rounded, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + + Spacer(minLength: 8) + + Text(trailing) + .font(.system(.caption2, design: .rounded, weight: .medium)) + .foregroundStyle(Color(uiColor: .tertiaryLabel)) + .lineLimit(1) + } + // 10pt V — Apple's standard list row rhythm (Settings rows use 11pt) + .padding(.vertical, 10) + } +} + +private struct _EmptyState: View { + let icon: String + let message: String + + var body: some View { + VStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 32, weight: .ultraLight)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(Color(uiColor: .tertiaryLabel)) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(2) + } + .frame(maxWidth: .infinity) + // 24pt V — generous breathing room in an otherwise-dense list context + .padding(.vertical, 24) + } +} + +private struct _ErrorBanner: View { + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(EdgeTheme.danger) + Text(message) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.danger.opacity(0.85)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(EdgeTheme.danger.opacity(0.09)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(EdgeTheme.danger.opacity(0.16), lineWidth: 1)) + ) + } +} + +private struct _InlineEditor: View { + let placeholder: String + @Binding var text: String + var multiline: Bool = false + + var body: some View { + TextField(placeholder, text: $text, axis: multiline ? .vertical : .horizontal) + .font(.system(.subheadline, design: .rounded, weight: .regular)) + .foregroundStyle(EdgeTheme.foreground) + .lineLimit(multiline ? 6 : 1) + // 14pt H / 11pt V — matches Apple's inline search field proportions + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.white.opacity(0.06)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(text.isEmpty ? EdgeTheme.borderSubtle : EdgeTheme.borderAccent, lineWidth: 0.75) + ) + ) + } +} + +private struct _KV: View { + let label: String + let value: String + + init(_ label: String, _ value: String) { + self.label = label + self.value = value + } + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(label) + // Caption in secondary — matches Settings label column + .font(.system(.caption, design: .rounded, weight: .regular)) + .foregroundStyle(.secondary) + .frame(minWidth: 90, alignment: .leading) + Spacer(minLength: 12) + Text(value) + .font(.system(.callout, design: .rounded, weight: .medium)) + .foregroundStyle(EdgeTheme.foreground) + .multilineTextAlignment(.trailing) + } + // 9pt V — tight but not cramped; mirrors Xcode's inspector row height + .padding(.vertical, 9) + } +} + +private func _Divider() -> some View { + // 0.5pt hairline — Apple's physical pixel-precise separator (UITableView default) + // 16pt leading inset matches the card's 16pt horizontal content margin + Rectangle() + .fill(EdgeTheme.borderSubtle) + .frame(height: 0.5) + .padding(.leading, 16) +} + +// MARK: – Button Styles + +private struct _ProminentButton: ButtonStyle { + let enabled: Bool + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 46) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + enabled + ? AnyShapeStyle(LinearGradient(colors: [EdgeTheme.accent, EdgeTheme.accentPurple], startPoint: .leading, endPoint: .trailing)) + : AnyShapeStyle(Color.white.opacity(0.07)) + ) + .shadow(color: enabled ? EdgeTheme.accent.opacity(0.35) : .clear, radius: 12, y: 5) + ) + .scaleEffect(configuration.isPressed ? 0.972 : 1) + .opacity(configuration.isPressed ? 0.88 : 1) + .animation(.spring(response: 0.20, dampingFraction: 0.70), value: configuration.isPressed) + } +} + +private struct _ChipButton: ButtonStyle { + let tint: Color + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(tint) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(tint.opacity(0.10)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(tint.opacity(0.22), lineWidth: 1)) + ) + .scaleEffect(configuration.isPressed ? 0.972 : 1) + .animation(.spring(response: 0.18, dampingFraction: 0.70), value: configuration.isPressed) + } +} + +// MARK: – UIKit bridges + +private struct VelocityImagePicker: UIViewControllerRepresentable { + @Binding var isPresented: Bool + let onImage: (UIImage) -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.allowsEditing = false + picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary + return picker + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + private let parent: VelocityImagePicker + init(_ parent: VelocityImagePicker) { self.parent = parent } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + parent.isPresented = false + } + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { parent.onImage(image) } + parent.isPresented = false + } + } +} + +private struct VelocityShareSheet: UIViewControllerRepresentable { + let image: UIImage + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: [image], applicationActivities: nil) + } + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// MARK: – Extensions + +private extension String { + var edgeRelativeShort: String { + guard let date = ISO8601DateFormatter().date(from: self) else { return self } + return date.edgeRelativeShort + } +} + + +private extension JSONValue { + var summaryText: String { + switch self { + case .string(let v): return v + case .number(let v): return String(v) + case .bool(let v): return v ? "Yes" : "No" + case .object(let d): return d.map { "\($0.key): \($0.value.summaryText)" }.prefix(4).joined(separator: " · ") + case .array(let l): return l.map(\.summaryText).prefix(4).joined(separator: ", ") + case .null: return "—" + } + } +} + +private extension Double { + var asCurrency: String { + let f = NumberFormatter() + f.numberStyle = .currency + f.currencyCode = "AED" + f.maximumFractionDigits = 0 + return f.string(from: NSNumber(value: self)) ?? "AED \(Int(self))" + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Features/Alerts/EdgeAlertsView.swift b/iOS/velocity-iphone/velocity-iphone/Features/Alerts/EdgeAlertsView.swift new file mode 100644 index 00000000..d7300593 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Features/Alerts/EdgeAlertsView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct EdgeAlertsView: View { + @State private var store = EdgeAppStore.shared + private let metricColumns = [GridItem(.adaptive(minimum: 150), spacing: 12)] + + var body: some View { + EdgeShell( + title: "Alerts", + subtitle: "High-urgency signal flow for the active operator window." + ) { + if let error = store.errorMessage { + EdgeErrorCard(message: error) + } + + if store.isLoading && store.alerts == nil { + EdgeLoadingCard(message: "Fetching live alert posture and registering the iPhone edge surface.") + } else if let alerts = store.alerts { + EdgeHeroCard( + title: "Active Alert Stack", + subtitle: "A fast mobile view into what needs action first across insight review, transcripts, and calendar drift.", + icon: "bolt.badge.clock" + ) { + HStack(spacing: 8) { + EdgeStatusPill(label: "Insights \(alerts.pendingInsights)", color: EdgeTheme.danger) + EdgeStatusPill(label: "Transcripts \(alerts.pendingTranscriptions)", color: EdgeTheme.warning) + EdgeStatusPill(label: "24h \(alerts.upcomingCalendarEvents24h)", color: EdgeTheme.success) + } + } + + LazyVGrid(columns: metricColumns, spacing: 12) { + EdgeMetricCard(label: "Insights", value: "\(alerts.pendingInsights)", tint: EdgeTheme.danger) + EdgeMetricCard(label: "Transcripts", value: "\(alerts.pendingTranscriptions)", tint: EdgeTheme.warning) + EdgeMetricCard(label: "24h", value: "\(alerts.upcomingCalendarEvents24h)", tint: EdgeTheme.success) + } + + EdgeCard(title: "Operator posture") { + if let lead = store.selectedLead { + EdgeTimelineRow( + title: lead.name, + subtitle: "\(lead.qualification.capitalized) lead · \(lead.unitInterest) · \(lead.budget)", + trailing: "\(lead.score)", + tint: EdgeTheme.accentSecondary + ) + Text("This lead currently sits at the top of the live urgency model and should anchor your next conversation.") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } else { + Text("No live lead is available yet for urgency ranking.") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } + } + } else { + EdgeEmptyCard(title: "Alerts", message: "No live alert payload was returned.") + } + } + .task { await store.refresh(screen: "alerts") } + .refreshable { await store.refresh(screen: "alerts") } + .edgeAutoRefresh(store: store, screen: "alerts") + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Features/Communications/EdgeCommunicationsView.swift b/iOS/velocity-iphone/velocity-iphone/Features/Communications/EdgeCommunicationsView.swift new file mode 100644 index 00000000..fa5fd70a --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Features/Communications/EdgeCommunicationsView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct EdgeCommunicationsView: View { + @State private var store = EdgeAppStore.shared + + var body: some View { + EdgeShell( + title: "Communications", + subtitle: "Recent live call and messaging movement around the priority lead." + ) { + if let error = store.errorMessage { + EdgeErrorCard(message: error) + } + + if store.isLoading && store.events.isEmpty { + EdgeLoadingCard(message: "Fetching live communication events.") + } else if let lead = store.selectedLead { + EdgeHeroCard( + title: "Current Thread", + subtitle: "\(lead.name) is the current highest-priority lead on this device.", + icon: "phone.badge.waveform" + ) { + HStack(spacing: 8) { + EdgeStatusPill(label: lead.unitInterest, color: EdgeTheme.accent) + EdgeStatusPill(label: lead.qualification.capitalized, color: EdgeTheme.accentSecondary) + } + } + + if store.events.isEmpty { + EdgeEmptyCard(title: "Communications", message: "No live communication events were returned yet.") + } else { + EdgeCard(title: "Recent threads") { + ForEach(store.events) { event in + EdgeTimelineRow( + title: event.channel.replacingOccurrences(of: "_", with: " ").capitalized, + subtitle: event.summary ?? "No summary available.", + trailing: event.timestampDate?.edgeRelativeShort ?? event.timestamp, + tint: event.recordingRef == nil ? EdgeTheme.accent : EdgeTheme.accentWarm + ) + } + } + } + } else { + EdgeEmptyCard(title: "Communications", message: "No live lead context is available for this surface.") + } + } + .task { await store.refresh(screen: "communications") } + .refreshable { await store.refresh(screen: "communications") } + .edgeAutoRefresh(store: store, screen: "communications") + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Features/LeadSummary/EdgeLeadSummaryView.swift b/iOS/velocity-iphone/velocity-iphone/Features/LeadSummary/EdgeLeadSummaryView.swift new file mode 100644 index 00000000..36c8bf63 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Features/LeadSummary/EdgeLeadSummaryView.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct EdgeLeadSummaryView: View { + @State private var store = EdgeAppStore.shared + + var body: some View { + EdgeShell( + title: "Lead Summary", + subtitle: "A compact executive view of the strongest live buyer signal." + ) { + if let error = store.errorMessage { + EdgeErrorCard(message: error) + } + + if store.isLoading && store.leads.isEmpty { + EdgeLoadingCard(message: "Fetching live leads.") + } else if let lead = store.selectedLead { + EdgeHeroCard( + title: lead.name, + subtitle: "\(lead.qualification.capitalized) intent with interest in \(lead.unitInterest).", + icon: "person.crop.circle.badge.checkmark" + ) { + HStack(spacing: 8) { + EdgeStatusPill(label: "Score \(lead.score)", color: EdgeTheme.accent) + EdgeStatusPill(label: lead.budget, color: EdgeTheme.accentWarm) + } + } + + EdgeCard(title: "Top lead") { + EdgeKeyValueRow(label: "Lead", value: lead.name) + EdgeKeyValueRow(label: "Qualification", value: lead.qualification.capitalized) + EdgeKeyValueRow(label: "Unit Interest", value: lead.unitInterest) + EdgeKeyValueRow(label: "Budget", value: lead.budget) + } + + EdgeCard(title: "Top lead queue") { + ForEach(store.leads.sorted(by: { $0.score > $1.score }).prefix(5)) { item in + EdgeTimelineRow( + title: "\(item.name) · \(item.score)", + subtitle: "\(item.qualification.capitalized) · \(item.unitInterest) · \(item.budget)", + trailing: "Live", + tint: item.id == lead.id ? EdgeTheme.accent : EdgeTheme.accentWarm + ) + } + } + } else { + EdgeEmptyCard(title: "Lead Summary", message: "No live leads are visible to this operator scope yet.") + } + } + .task { await store.refresh(screen: "lead_summary") } + .refreshable { await store.refresh(screen: "lead_summary") } + .edgeAutoRefresh(store: store, screen: "lead_summary") + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Features/Notes/EdgeNotesView.swift b/iOS/velocity-iphone/velocity-iphone/Features/Notes/EdgeNotesView.swift new file mode 100644 index 00000000..d0afb290 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Features/Notes/EdgeNotesView.swift @@ -0,0 +1,93 @@ +import SwiftUI + +struct EdgeNotesView: View { + @State private var store = EdgeAppStore.shared + @State private var noteText = "" + + var body: some View { + EdgeShell( + title: "Notes", + subtitle: "Capture fast operator memory with a cleaner live-writing surface." + ) { + if let error = store.errorMessage { + EdgeErrorCard(message: error) + } + + if store.isLoading && store.selectedLead == nil { + EdgeLoadingCard(message: "Fetching live lead and memory context.") + } else if let lead = store.selectedLead { + EdgeHeroCard( + title: "Quick Capture", + subtitle: "Write production notes directly against the current highest-priority lead.", + icon: "square.and.pencil.circle.fill" + ) { + HStack(spacing: 8) { + EdgeStatusPill(label: lead.name, color: EdgeTheme.accent) + EdgeStatusPill(label: lead.unitInterest, color: EdgeTheme.accentWarm) + } + } + + EdgeCard(title: "Lead memory") { + Text(lead.name) + .font(.system(size: 19, weight: .bold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + + if store.memoryFacts.isEmpty { + Text("No persisted memory facts yet.") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } else { + ForEach(store.memoryFacts) { fact in + EdgeTimelineRow( + title: fact.factType.replacingOccurrences(of: "_", with: " ").capitalized, + subtitle: fact.factText, + trailing: "Memory", + tint: EdgeTheme.accentSecondary + ) + } + } + } + + EdgeCard(title: "Create quick note") { + TextField("Operator note", text: $noteText, axis: .vertical) + .textFieldStyle(.plain) + .textInputAutocapitalization(.sentences) + .foregroundStyle(EdgeTheme.foreground) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color.white.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(EdgeTheme.borderSubtle, lineWidth: 1) + ) + ) + + Button { + Task { + await store.createNote(noteText.trimmingCharacters(in: .whitespacesAndNewlines)) + if store.noteStatusMessage?.localizedCaseInsensitiveContains("saved") == true { + noteText = "" + } + } + } label: { + Label("Save note", systemImage: "paperplane.fill") + } + .buttonStyle(EdgePrimaryButtonStyle(enabled: !noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && EdgeAppConfig.isConfigured)) + .disabled(noteText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !EdgeAppConfig.isConfigured) + + if let noteStatusMessage = store.noteStatusMessage { + Text(noteStatusMessage) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(noteStatusMessage.localizedCaseInsensitiveContains("saved") ? EdgeTheme.success : EdgeTheme.danger) + } + } + } else { + EdgeEmptyCard(title: "Notes", message: "No live lead is available yet, so note capture cannot be targeted.") + } + } + .task { await store.refresh(screen: "notes") } + .refreshable { await store.refresh(screen: "notes") } + .edgeAutoRefresh(store: store, screen: "notes") + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Features/Settings/EdgeSettingsView.swift b/iOS/velocity-iphone/velocity-iphone/Features/Settings/EdgeSettingsView.swift new file mode 100644 index 00000000..782fcc5c --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Features/Settings/EdgeSettingsView.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct EdgeSettingsView: View { + @State private var store = EdgeAppStore.shared + + var body: some View { + EdgeShell( + title: "Settings", + subtitle: "Runtime posture and deployment truth for the production iPhone edge surface." + ) { + if let error = store.errorMessage { + EdgeErrorCard(message: error) + } + + EdgeHeroCard( + title: "Production Runtime", + subtitle: "This device keeps the iPad Velocity styling language while staying functionally aligned with the Android edge-phone surface.", + icon: "gearshape.2.fill" + ) { + HStack(spacing: 8) { + EdgeStatusPill(label: store.authDescription, color: EdgeTheme.accent) + EdgeStatusPill(label: EdgeAppConfig.appVersion, color: EdgeTheme.accentWarm) + } + } + + EdgeCard(title: "Connectivity") { + settingsRow(label: "Backend", value: EdgeAppConfig.baseURL) + settingsRow(label: "Auth mode", value: store.authDescription) + settingsRow(label: "App version", value: EdgeAppConfig.appVersion) + settingsRow(label: "Last sync", value: store.lastSyncAt?.edgeRelativeShort ?? "No live fetch yet") + settingsRow(label: "Last heartbeat", value: store.lastHeartbeatAt?.edgeRelativeShort ?? "No heartbeat yet") + } + + EdgeCard(title: "Production notes") { + Text("This iPhone app keeps the iPad Velocity styling language, but its feature scope intentionally matches the Android edge-phone surface.") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(EdgeTheme.foreground) + Text("It uses live backend routes only and registers `iphone_edge` heartbeats through `/api/mobile-edge/session` when credentials are configured.") + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundStyle(EdgeTheme.mutedFg) + } + } + .task { await store.refresh(screen: "settings") } + .refreshable { await store.refresh(screen: "settings") } + .edgeAutoRefresh(store: store, screen: "settings") + } + + private func settingsRow(label: String, value: String) -> some View { + EdgeKeyValueRow(label: label, value: value) + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/Features/Transcriptions/EdgeTranscriptionsView.swift b/iOS/velocity-iphone/velocity-iphone/Features/Transcriptions/EdgeTranscriptionsView.swift new file mode 100644 index 00000000..6693e065 --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/Features/Transcriptions/EdgeTranscriptionsView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct EdgeTranscriptionsView: View { + @State private var store = EdgeAppStore.shared + + var body: some View { + EdgeShell( + title: "Transcriptions", + subtitle: "Recording-backed transcript readiness for the latest live communication." + ) { + if let error = store.errorMessage { + EdgeErrorCard(message: error) + } + + if store.isLoading && store.transcript == nil { + EdgeLoadingCard(message: "Fetching transcript status.") + } else if let transcript = store.transcript { + EdgeHeroCard( + title: "Transcript Pipeline", + subtitle: "A narrow phone view into live processing status for the most recent recording-backed event.", + icon: "waveform.path.ecg.rectangle" + ) { + HStack(spacing: 8) { + EdgeStatusPill(label: transcript.status.capitalized, color: EdgeTheme.accent) + EdgeStatusPill(label: "\(transcript.segmentCount) segments", color: EdgeTheme.accentSecondary) + } + } + + EdgeCard(title: "Transcript pipeline") { + EdgeKeyValueRow(label: "Event", value: transcript.eventId) + EdgeKeyValueRow(label: "Job Status", value: transcript.status) + EdgeKeyValueRow(label: "Segments", value: "\(transcript.segmentCount)") + } + } else { + EdgeEmptyCard(title: "Transcriptions", message: "No live recording-backed event is available yet for transcript review.") + } + } + .task { await store.refresh(screen: "transcriptions") } + .refreshable { await store.refresh(screen: "transcriptions") } + .edgeAutoRefresh(store: store, screen: "transcriptions") + } +} diff --git a/iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift b/iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift new file mode 100644 index 00000000..2a1856ab --- /dev/null +++ b/iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct VelocityIPhoneApp: App { + @State private var appModel = VelocityAppModel() + + var body: some Scene { + WindowGroup { + EdgeRootView() + .preferredColorScheme(.dark) + .environment(appModel) + } + } +} diff --git a/iOS/velocity/velocity/App/ContentView.swift b/iOS/velocity/velocity/App/ContentView.swift index 6da73698..a5f23322 100644 --- a/iOS/velocity/velocity/App/ContentView.swift +++ b/iOS/velocity/velocity/App/ContentView.swift @@ -101,15 +101,15 @@ struct ContentView: View { RoundedRectangle(cornerRadius: 8) .fill(VelocityTheme.accent) .frame(width: 32, height: 32) - Text("AF") + Text(operatorInitials) .font(.system(size: 11, weight: .bold)) .foregroundStyle(.white) } VStack(alignment: .leading, spacing: 2) { - Text("Ahmed Al-Farsi") + Text(operatorName) .font(.system(size: 12, weight: .medium)) .foregroundStyle(VelocityTheme.foreground) - Text("Sales Director") + Text(AppConfig.authModeDescription) .font(.system(size: 10)) .foregroundStyle(VelocityTheme.mutedFg) } @@ -142,6 +142,20 @@ struct ContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + private var operatorName: String { + AppConfig.apiEmail ?? "Velocity Operator" + } + + private var operatorInitials: String { + let source = AppConfig.apiEmail ?? "VO" + let parts = source + .replacingOccurrences(of: "@", with: " ") + .split(separator: ".") + .flatMap { $0.split(separator: " ") } + let initials = parts.prefix(2).compactMap(\.first) + return initials.isEmpty ? "VO" : String(initials) + } } // MARK: – Sidebar Row diff --git a/iOS/velocity/velocity/Core/Config/AppConfig.swift b/iOS/velocity/velocity/Core/Config/AppConfig.swift index 92d4b4d4..4818e1bd 100644 --- a/iOS/velocity/velocity/Core/Config/AppConfig.swift +++ b/iOS/velocity/velocity/Core/Config/AppConfig.swift @@ -16,8 +16,22 @@ enum AppConfig { } /// Base URL for the Velocity backend / gateway. - static let baseURL: String = value(for: "BASE_URL") ?? "http://54.91.19.60:8082" + static let baseURL: String = value(for: "BASE_URL") ?? "https://api.desineuron.in" static let apiEmail: String? = value(for: "API_EMAIL") static let apiPassword: String? = value(for: "API_PASSWORD") static let apiBearerToken: String? = value(for: "API_BEARER_TOKEN") + + static var isLiveConfigured: Bool { + apiBearerToken != nil || (apiEmail != nil && apiPassword != nil) + } + + static var authModeDescription: String { + if apiBearerToken != nil { + return "Bearer token" + } + if apiEmail != nil && apiPassword != nil { + return "Email/password" + } + return "Credentials required" + } } diff --git a/iOS/velocity/velocity/Core/Networking/VelocityAPIClient.swift b/iOS/velocity/velocity/Core/Networking/VelocityAPIClient.swift index 99dc9767..c85a7213 100644 --- a/iOS/velocity/velocity/Core/Networking/VelocityAPIClient.swift +++ b/iOS/velocity/velocity/Core/Networking/VelocityAPIClient.swift @@ -1,5 +1,46 @@ import Foundation +enum JSONValue: Decodable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value.") + } + } + + var stringValue: String? { + switch self { + case .string(let value): + return value + case .number(let value): + return String(value) + case .bool(let value): + return value ? "true" : "false" + default: + return nil + } + } +} + struct VelocityLeadDTO: Decodable, Identifiable { let id: String let name: String @@ -92,6 +133,43 @@ struct VelocityCalendarEventDTO: Decodable, Identifiable { } } +struct VelocityPropertyDTO: Decodable, Identifiable { + let propertyId: String + let projectName: String + let developerName: String + let propertyType: String + let location: [String: JSONValue]? + let priceBands: [[String: JSONValue]] + let unitMix: [[String: JSONValue]] + let status: String + let ingestedAt: String? + let createdAt: String? + + var id: String { propertyId } + + enum CodingKeys: String, CodingKey { + case propertyId = "property_id" + case projectName = "project_name" + case developerName = "developer_name" + case propertyType = "property_type" + case location + case priceBands = "price_bands" + case unitMix = "unit_mix" + case status + case ingestedAt = "ingested_at" + case createdAt = "created_at" + } + + var locationSummary: String { + let city = location?["city"]?.stringValue + let district = location?["district"]?.stringValue + if let city, let district { + return "\(district), \(city)" + } + return city ?? district ?? "Location pending" + } +} + struct VelocityAlertSnapshotDTO: Decodable { let pendingInsights: Int let upcomingCalendarEvents24h: Int @@ -151,6 +229,10 @@ actor VelocityAPIClient { let events: [VelocityCalendarEventDTO] } + private struct PropertiesEnvelope: Decodable { + let properties: [VelocityPropertyDTO] + } + private let decoder = JSONDecoder() private var cachedToken: String? @@ -177,6 +259,15 @@ actor VelocityAPIClient { return response.events } + func fetchProperties(limit: Int = 25) async throws -> [VelocityPropertyDTO] { + let request = try await authorizedRequest( + path: "/api/inventory/properties", + queryItems: [URLQueryItem(name: "limit", value: String(limit))] + ) + let response: PropertiesEnvelope = try await perform(request) + return response.properties + } + func fetchAlerts() async throws -> VelocityAlertSnapshotDTO { let request = try await authorizedRequest(path: "/api/mobile-edge/alerts") return try await perform(request) @@ -256,3 +347,17 @@ actor VelocityAPIClient { private struct APIErrorPayload: Decodable { let detail: String? } + +private let velocityDateFormatter = ISO8601DateFormatter() + +extension VelocityCommunicationEventDTO { + var timestampDate: Date? { + velocityDateFormatter.date(from: timestamp) + } +} + +extension VelocityCalendarEventDTO { + var startDate: Date? { + velocityDateFormatter.date(from: startAt) + } +} diff --git a/iOS/velocity/velocity/Core/State/AppStore.swift b/iOS/velocity/velocity/Core/State/AppStore.swift index 1755ab9a..e5e7f1d3 100644 --- a/iOS/velocity/velocity/Core/State/AppStore.swift +++ b/iOS/velocity/velocity/Core/State/AppStore.swift @@ -1,256 +1,149 @@ -import SwiftUI -import Combine - -// MARK: – Data Models - -enum SentimentType: String, CaseIterable { - case excited, interested, neutral, confused, disinterested - var score: Int { - switch self { - case .excited: return 100 - case .interested: return 80 - case .neutral: return 50 - case .confused: return 30 - case .disinterested: return 10 - } - } - var emoji: String { - switch self { - case .excited: return "😃" - case .interested: return "🤔" - case .neutral: return "😐" - case .confused: return "😕" - case .disinterested: return "😴" - } - } - var color: Color { - switch self { - case .excited: return VelocityTheme.success - case .interested: return VelocityTheme.accent - case .neutral: return VelocityTheme.mutedFg - case .confused: return VelocityTheme.warning - case .disinterested: return VelocityTheme.danger - } - } -} - -struct Visitor: Identifiable { - let id: String - let faceId: String - var sentiment: SentimentType - var confidence: Double - var dwellTime: Int // seconds - var zone: String - let timestamp: Date -} - -enum LeadSource: String { - case whatsapp = "WhatsApp" - case walkin = "Walk-in" - case website = "Website" -} - -enum LeadStatus: String { - case hot = "Hot" - case engaged = "Engaged" - case new = "New" - case qualified = "Qualified" - case closed = "Closed" - - var color: Color { - switch self { - case .hot: return VelocityTheme.danger - case .engaged: return VelocityTheme.accent - case .new: return VelocityTheme.mutedFg - case .qualified: return VelocityTheme.success - case .closed: return Color(red: 0.60, green: 0.57, blue: 0.99) - } - } -} - -struct Lead: Identifiable { - let id: String - let name: String - let phone: String - let source: LeadSource - var status: LeadStatus - var lastMessage: String - var lastActive: Date - var unreadCount: Int - let qualification: String - let budget: String - let interest: String - var initials: String { String(name.split(separator: " ").prefix(2).compactMap(\.first)) } -} - -struct ChatMessage: Identifiable { - let id: String - let sender: String // "user" | "oracle" | "ai" - let content: String - let timestamp: Date -} - -struct SystemHealth { - var cpu: Double // 0–1 - var gpu: Double - var memory: Double -} +import Foundation +import Observation struct DashboardMetrics { - var activeVisitors: Int - var revenue: String - var aiJobs: Int - var dailyVisitors: Int - var sentimentScore: Double // 0–100 - var systemHealth: SystemHealth + let leadCount: Int + let whaleLeadCount: Int + let propertyCount: Int + let todayCalendarCount: Int + let pendingInsights: Int + let pendingTranscriptions: Int } -// MARK: – Shared Store - +@MainActor @Observable final class AppStore { - static let shared = AppStore() - private init() { startTimer() } - // ── Dashboard ───────────────────────────────────────────────── - var metrics = DashboardMetrics( - activeVisitors: 17, - revenue: "$3.2M", - aiJobs: 24, - dailyVisitors: 128, - sentimentScore: 78, - systemHealth: SystemHealth(cpu: 0.42, gpu: 0.61, memory: 0.55) - ) + private init() {} - var dashboardMessages: [ChatMessage] = [ - ChatMessage(id: "d0", sender: "ai", - content: "Hello, Ahmed. I've analysed the Q3 pipeline. Would you like a refined strategy for the Apex Innovations deal?", - timestamp: Date().addingTimeInterval(-300)) - ] - var isDashboardThinking = false + var leads: [VelocityLeadDTO] = [] + var properties: [VelocityPropertyDTO] = [] + var calendarEvents: [VelocityCalendarEventDTO] = [] + var leadEvents: [String: [VelocityCommunicationEventDTO]] = [:] + var alertSnapshot: VelocityAlertSnapshotDTO? + var isLoading = false + var errorMessage: String? + var lastRefreshAt: Date? - // ── Visitors ────────────────────────────────────────────────── - var visitors: [Visitor] = [ - Visitor(id: "v1", faceId: "face_001", sentiment: .excited, confidence: 0.92, dwellTime: 450, zone: "Penthouse Show", timestamp: Date()), - Visitor(id: "v2", faceId: "face_002", sentiment: .interested, confidence: 0.87, dwellTime: 320, zone: "Amenity Deck VR", timestamp: Date()), - Visitor(id: "v3", faceId: "face_003", sentiment: .neutral, confidence: 0.78, dwellTime: 180, zone: "Reception", timestamp: Date()), - Visitor(id: "v4", faceId: "face_004", sentiment: .confused, confidence: 0.74, dwellTime: 95, zone: "Penthouse Show", timestamp: Date()), - Visitor(id: "v5", faceId: "face_005", sentiment: .disinterested, confidence: 0.65, dwellTime: 60, zone: "Gallery", timestamp: Date()), - ] - - // ── Alerts ──────────────────────────────────────────────────── - var isAlertActive = false - var alertMessage = "" - - func triggerAlert(_ msg: String) { - isAlertActive = true - alertMessage = msg - } - func clearAlert() { - isAlertActive = false - alertMessage = "" - } - - // ── Leads (Oracle) ──────────────────────────────────────────── - var leads: [Lead] = [ - Lead(id: "1", name: "Mohammed Al-Rashid", phone: "+971 55 123 4567", source: .whatsapp, - status: .hot, lastMessage: "Can we schedule a viewing for the penthouse tomorrow?", - lastActive: Date().addingTimeInterval(-300), unreadCount: 2, - qualification: "whale", budget: "AED 15M+", interest: "Penthouse Suite"), - Lead(id: "2", name: "Sarah Chen", phone: "+971 50 987 6543", source: .walkin, - status: .engaged, lastMessage: "Thank you for the brochure. I will review with my partner.", - lastActive: Date().addingTimeInterval(-1800), unreadCount: 0, - qualification: "potential", budget: "AED 5–8M", interest: "2BR Sea View"), - Lead(id: "3", name: "James Wilson", phone: "+971 52 456 7890", source: .website, - status: .new, lastMessage: "Interested in investment opportunities.", - lastActive: Date().addingTimeInterval(-7200), unreadCount: 1, - qualification: "potential", budget: "AED 3–5M", interest: "1BR Investment"), - Lead(id: "4", name: "Fatima Hassan", phone: "+971 54 321 0987", source: .whatsapp, - status: .qualified,lastMessage: "What are the payment plan options?", - lastActive: Date().addingTimeInterval(-14400), unreadCount: 0, - qualification: "whale", budget: "AED 12M+", interest: "3BR + Maid"), - Lead(id: "5", name: "David Kumar", phone: "+971 56 789 0123", source: .walkin, - status: .closed, lastMessage: "Contract signed. Thank you!", - lastActive: Date().addingTimeInterval(-86400), unreadCount: 0, - qualification: "whale", budget: "AED 20M", interest: "Full Floor"), - ] - - var messages: [String: [ChatMessage]] = [ - "1": [ - ChatMessage(id: "m1", sender: "user", content: "Hi, I am interested in the penthouse units.", - timestamp: Date().addingTimeInterval(-7200)), - ChatMessage(id: "m2", sender: "oracle", - content: "Welcome! Our penthouse collection features 4 exclusive units with panoramic sea views. Prices start at AED 15M.", - timestamp: Date().addingTimeInterval(-7200 + 30)), - ChatMessage(id: "m3", sender: "user", content: "Can we schedule a viewing tomorrow?", - timestamp: Date().addingTimeInterval(-300)), - ], - "2": [ - ChatMessage(id: "m4", sender: "oracle", - content: "Hello Sarah! Here is the digital brochure for the 2-bedroom units we discussed.", - timestamp: Date().addingTimeInterval(-14400)), - ChatMessage(id: "m5", sender: "user", content: "Thank you. I will review with my partner.", - timestamp: Date().addingTimeInterval(-1800)), - ], - ] - - var activeLeadId: String? = "1" - var isOracleThinking = false - - func addDashboardMessage(sender: String, content: String) { - let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date()) - dashboardMessages.append(msg) - } - - func addOracleMessage(leadId: String, sender: String, content: String) { - let msg = ChatMessage(id: UUID().uuidString, sender: sender, content: content, timestamp: Date()) - if messages[leadId] == nil { messages[leadId] = [] } - messages[leadId]!.append(msg) - } - - // ── Live ticker ─────────────────────────────────────────────── - private var timerTask: AnyCancellable? - private var alertTask: DispatchWorkItem? - - private func startTimer() { - timerTask = Timer.publish(every: 5, on: .main, in: .common) - .autoconnect() - .sink { [weak self] _ in self?.tick() } - } - - private func tick() { - // jitter visitor count ±1 - let delta = Int.random(in: -1...1) - metrics.activeVisitors = max(10, metrics.activeVisitors + delta) - - // jitter sentiment ±2 - let sDelta = Double.random(in: -2...2) - metrics.sentimentScore = min(100, max(40, metrics.sentimentScore + sDelta)) - - // jitter system health - metrics.systemHealth.cpu = Double.random(in: 0.30...0.65) - metrics.systemHealth.gpu = Double.random(in: 0.45...0.75) - metrics.systemHealth.memory = Double.random(in: 0.40...0.70) - - // Random alert (same 10% chance as WebOS every tick) - if !isAlertActive && Double.random(in: 0...1) > 0.85 { - triggerAlert("Confusion detected in Zone B – Penthouse Gallery") - let work = DispatchWorkItem { [weak self] in self?.clearAlert() } - alertTask = work - DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: work) + var operatorIdentity: String { + if let email = AppConfig.apiEmail, !email.isEmpty { + return email } + if let token = AppConfig.apiBearerToken, !token.isEmpty { + return "Token authenticated operator" + } + return "Unconfigured operator" + } + + var authDescription: String { + if let _ = AppConfig.apiBearerToken { + return "Bearer token" + } + if AppConfig.apiEmail != nil, AppConfig.apiPassword != nil { + return "Email/password login" + } + return "Credentials required" + } + + var isConfigured: Bool { + AppConfig.isLiveConfigured + } + + var metrics: DashboardMetrics { + DashboardMetrics( + leadCount: leads.count, + whaleLeadCount: leads.filter { $0.score >= 90 || $0.qualification.lowercased() == "whale" }.count, + propertyCount: properties.count, + todayCalendarCount: calendarEvents.filter { $0.startsToday }.count, + pendingInsights: alertSnapshot?.pendingInsights ?? 0, + pendingTranscriptions: alertSnapshot?.pendingTranscriptions ?? 0 + ) + } + + var highlightedLeads: [VelocityLeadDTO] { + Array(leads.sorted(by: { $0.score > $1.score }).prefix(5)) + } + + var timelineEvents: [TimelineEvent] { + leadEvents + .flatMap { leadId, events in + events.map { TimelineEvent(leadId: leadId, event: $0, leadName: leadName(for: leadId)) } + } + .sorted(by: { $0.date > $1.date }) + } + + func refresh(silent: Bool = false) async { + if !silent { + isLoading = true + } + + do { + async let leadsTask = VelocityAPIClient.shared.fetchLeads() + async let propertiesTask = VelocityAPIClient.shared.fetchProperties() + async let calendarTask = VelocityAPIClient.shared.fetchCalendarEvents() + async let alertsTask = VelocityAPIClient.shared.fetchAlerts() + + let fetchedLeads = try await leadsTask + let fetchedProperties = try await propertiesTask + let fetchedCalendar = try await calendarTask + let fetchedAlerts = try await alertsTask + + let leadFocus = Array(fetchedLeads.sorted(by: { $0.score > $1.score }).prefix(6)) + var eventMap: [String: [VelocityCommunicationEventDTO]] = [:] + for lead in leadFocus { + let events = try await VelocityAPIClient.shared.fetchEvents(for: lead.id, limit: 4) + eventMap[lead.id] = events + } + + leads = fetchedLeads + properties = fetchedProperties + calendarEvents = fetchedCalendar + alertSnapshot = fetchedAlerts + leadEvents = eventMap + lastRefreshAt = Date() + errorMessage = nil + isLoading = false + } catch { + errorMessage = error.localizedDescription + if !silent { + leads = [] + properties = [] + calendarEvents = [] + alertSnapshot = nil + leadEvents = [:] + } + isLoading = false + } + } + + func leadName(for leadId: String) -> String { + leads.first(where: { $0.id == leadId })?.name ?? "Unknown lead" } } -// MARK: – Helpers +struct TimelineEvent: Identifiable { + let leadId: String + let event: VelocityCommunicationEventDTO + let leadName: String + + var id: String { event.id } + var date: Date { event.timestampDate ?? .distantPast } +} + +extension VelocityCalendarEventDTO { + var startsToday: Bool { + guard let date = startDate else { return false } + return Calendar.current.isDateInToday(date) + } +} extension Date { var relativeShort: String { - let diff = Int(Date().timeIntervalSince(self)) - if diff < 60 { return "now" } - if diff < 3600 { return "\(diff / 60)m ago" } - if diff < 86400 { return "\(diff / 3600)h ago" } - return "\(diff / 86400)d ago" + let delta = Int(Date().timeIntervalSince(self)) + if delta < 60 { return "now" } + if delta < 3600 { return "\(delta / 60)m ago" } + if delta < 86400 { return "\(delta / 3600)h ago" } + return "\(delta / 86400)d ago" } } diff --git a/iOS/velocity/velocity/Features/Dashboard/DashboardView.swift b/iOS/velocity/velocity/Features/Dashboard/DashboardView.swift index 5d49e891..b3c5e5ea 100644 --- a/iOS/velocity/velocity/Features/Dashboard/DashboardView.swift +++ b/iOS/velocity/velocity/Features/Dashboard/DashboardView.swift @@ -1,442 +1,267 @@ import SwiftUI struct DashboardView: View { - private var store: AppStore { AppStore.shared } - @State private var chatInput = "" + @State private var store = AppStore.shared + private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect() private let columns = [GridItem(.adaptive(minimum: 220), spacing: 14)] var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - pageHeader + header - // KPI Grid — live from store - LazyVGrid(columns: columns, spacing: 14) { - LiveKPICard( - title: "Visitors", - value: "\(store.metrics.activeVisitors)", - subtitle: "Active now", - icon: "person.2", - accentColor: VelocityTheme.accent, - glowColor: VelocityTheme.accent.opacity(0.22), - badge: "LIVE" - ) - LiveKPICard( - title: "Revenue", - value: store.metrics.revenue, - subtitle: "30-day forecast", - icon: "chart.line.uptrend.xyaxis", - accentColor: Color(red: 0.13, green: 0.83, blue: 0.93), - glowColor: Color(red: 0.13, green: 0.83, blue: 0.93).opacity(0.18) - ) - LiveKPICard( - title: "AI Jobs", - value: "\(store.metrics.aiJobs)", - subtitle: "Queue depth", - icon: "cpu", - accentColor: Color(red: 0.60, green: 0.57, blue: 0.99), - glowColor: Color(red: 0.60, green: 0.57, blue: 0.99).opacity(0.20) - ) - LiveKPICard( - title: "Listings", - value: "\(store.metrics.dailyVisitors)", - subtitle: "Active units", - icon: "building.2", - accentColor: VelocityTheme.success, - glowColor: VelocityTheme.success.opacity(0.18) - ) + if let error = store.errorMessage { + errorBanner(error) } - .animation(.easeInOut(duration: 0.4), value: store.metrics.activeVisitors) - // Sentiment Gauge - sentimentGauge - - // System Health - systemHealthPanel - - // AI Chat Widget - aiChatWidget + if store.isLoading && store.lastRefreshAt == nil { + loadingPanel + } else { + metricsGrid + liveStatusPanel + leadFocusPanel + inventoryPanel + } } .padding(20) } .background(VelocityTheme.background) .scrollContentBackground(.hidden) + .task { await store.refresh() } + .refreshable { await store.refresh() } + .onReceive(refreshTimer) { _ in + Task { await store.refresh(silent: true) } + } } - // MARK: – Page Header - private var pageHeader: some View { - HStack { - VStack(alignment: .leading, spacing: 3) { + private var header: some View { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { Text("Dashboard") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) - Text("Project Velocity · v.1.1") + Text("Live mobile operator posture for leads, inventory, and follow-up load.") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } Spacer() - HStack(spacing: 5) { - Circle() - .fill(VelocityTheme.success) - .frame(width: 7, height: 7) - Text("Live") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(VelocityTheme.success) + VStack(alignment: .trailing, spacing: 8) { + statusBadge( + label: store.isConfigured ? "Live backend" : "Config required", + color: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning + ) + if let lastRefresh = store.lastRefreshAt { + Text("Updated \(lastRefresh.relativeShort)") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(VelocityTheme.mutedFg) + } } } } - // MARK: – Sentiment Gauge - private var sentimentGauge: some View { - VStack(alignment: .leading, spacing: 12) { + private var metricsGrid: some View { + LazyVGrid(columns: columns, spacing: 14) { + MetricCard(title: "Leads", value: "\(store.metrics.leadCount)", subtitle: "Live CRM records", color: VelocityTheme.accent) + MetricCard(title: "Whale Leads", value: "\(store.metrics.whaleLeadCount)", subtitle: "Score 90+ or whale qualified", color: VelocityTheme.success) + MetricCard(title: "Inventory", value: "\(store.metrics.propertyCount)", subtitle: "Property rows available", color: VelocityTheme.warning) + MetricCard(title: "Today", value: "\(store.metrics.todayCalendarCount)", subtitle: "Calendar slots scheduled", color: Color(red: 0.60, green: 0.57, blue: 0.99)) + } + } + + private var liveStatusPanel: some View { + VStack(alignment: .leading, spacing: 14) { HStack { - Image(systemName: "waveform.path.ecg") - .font(.system(size: 12)) - .foregroundStyle(VelocityTheme.accent) - Text("Sentiment Thermometer") - .font(.system(size: 13, weight: .semibold)) + Text("Live Status") + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) Spacer() - Text("Showroom Vibe") - .font(.system(size: 11)) - .foregroundStyle(VelocityTheme.mutedFg) - let label = store.metrics.sentimentScore >= 70 ? "Excellent" : - store.metrics.sentimentScore >= 50 ? "Good" : "Needs Attention" - let labelColor: Color = store.metrics.sentimentScore >= 70 ? VelocityTheme.success : - store.metrics.sentimentScore >= 50 ? VelocityTheme.warning : VelocityTheme.danger - Text(label) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(labelColor) + statusBadge(label: AppConfig.authModeDescription, color: VelocityTheme.accent) } - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 5) - .fill(Color.white.opacity(0.05)) - .frame(height: 26) - - RoundedRectangle(cornerRadius: 5) - .fill( - LinearGradient( - colors: [Color(red: 0.11, green: 0.30, blue: 0.86), - VelocityTheme.accent, - Color(red: 0.38, green: 0.65, blue: 0.98)], - startPoint: .leading, endPoint: .trailing - ) - ) - .frame(width: geo.size.width * (store.metrics.sentimentScore / 100), height: 26) - .shadow(color: VelocityTheme.accent.opacity(0.6), radius: 6) - .animation(.easeInOut(duration: 0.8), value: store.metrics.sentimentScore) - - Text("\(Int(store.metrics.sentimentScore))%") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.white) - .frame(maxWidth: .infinity) - } - } - .frame(height: 26) + detailRow(title: "Endpoint", value: AppConfig.baseURL) + detailRow(title: "Operator", value: store.operatorIdentity) + detailRow(title: "Pending insights", value: "\(store.metrics.pendingInsights)") + detailRow(title: "Pending transcriptions", value: "\(store.metrics.pendingTranscriptions)") } .padding(20) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1)) - ) + .glassCard(cornerRadius: 18) } - // MARK: – System Health - private var systemHealthPanel: some View { - let gauges: [(label: String, value: Double, color: Color)] = [ - ("CPU", store.metrics.systemHealth.cpu, VelocityTheme.accent), - ("GPU", store.metrics.systemHealth.gpu, Color(red: 0.50, green: 0.56, blue: 0.97)), - ("Memory", store.metrics.systemHealth.memory, Color(red: 0.13, green: 0.83, blue: 0.93)), - ] + private var leadFocusPanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Lead Focus") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) - return VStack(alignment: .leading, spacing: 14) { - HStack { - Image(systemName: "cpu").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent) - Text("System Health") - .font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Spacer() - HStack(spacing: 4) { - Circle().fill(VelocityTheme.success).frame(width: 5, height: 5) - Text("Optimal").font(.system(size: 11, weight: .medium)).foregroundStyle(VelocityTheme.success) - } - } - - HStack(spacing: 16) { - ForEach(gauges, id: \.label) { g in - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(g.label).font(.system(size: 10, weight: .medium)).tracking(0.8) - .foregroundStyle(VelocityTheme.mutedFg) - Spacer() - Text("\(Int(g.value * 100))%").font(.system(size: 10, weight: .semibold)) + if store.highlightedLeads.isEmpty { + emptyMessage("No live leads have been returned by the backend yet.") + } else { + ForEach(store.highlightedLeads) { lead in + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + Text(lead.name) + .font(.system(size: 14, weight: .semibold)) .foregroundStyle(VelocityTheme.foreground) + Text("\(lead.unitInterest) · \(lead.budget)") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) } - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.05)).frame(height: 5) - RoundedRectangle(cornerRadius: 3).fill(g.color) - .frame(width: geo.size.width * g.value, height: 5) - .shadow(color: g.color.opacity(0.6), radius: 4) - .animation(.easeInOut(duration: 0.6), value: g.value) - } + Spacer() + VStack(alignment: .trailing, spacing: 4) { + Text("\(lead.score)") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(VelocityTheme.foreground) + Text(lead.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(VelocityTheme.accent) } - .frame(height: 5) } - .frame(maxWidth: .infinity) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(VelocityTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(VelocityTheme.borderSubtle, lineWidth: 1) + ) + ) } } } .padding(20) - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1)) - ) + .glassCard(cornerRadius: 18) } - // MARK: – AI Chat Widget - private var aiChatWidget: some View { - VStack(spacing: 0) { - // Header - HStack(spacing: 10) { - ZStack { - Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 34, height: 34) - Image(systemName: "sparkles").font(.system(size: 14)).foregroundStyle(VelocityTheme.accent) - } - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 5) { - Text("AI Onboard").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Image(systemName: "staroflife.fill").font(.system(size: 7)).foregroundStyle(VelocityTheme.accent) - } - HStack(spacing: 4) { - Circle().fill(VelocityTheme.success).frame(width: 5, height: 5) - Text("Online").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - } - } - Spacer() - } - .padding(16) + private var inventoryPanel: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Inventory Coverage") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) - Divider().background(VelocityTheme.borderSubtle) - - // Messages - ScrollViewReader { proxy in - ScrollView { - VStack(spacing: 12) { - ForEach(store.dashboardMessages) { msg in - ChatBubble(message: msg) - .id(msg.id) - } - if store.isDashboardThinking { - TypingIndicator() - } - } - .padding(16) - } - .frame(height: 240) - .onChange(of: store.dashboardMessages.count) { - if let last = store.dashboardMessages.last { - withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } - } - } - .onChange(of: store.isDashboardThinking) { - if store.isDashboardThinking { - withAnimation { proxy.scrollTo("typing", anchor: .bottom) } + if store.properties.isEmpty { + emptyMessage("No live inventory properties are available yet for this operator scope.") + } else { + ForEach(store.properties.prefix(4)) { property in + VStack(alignment: .leading, spacing: 6) { + Text(property.projectName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Text("\(property.developerName) · \(property.propertyType.capitalized)") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) + Text(property.locationSummary) + .font(.system(size: 12)) + .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) + ) + ) } } - - Divider().background(VelocityTheme.borderSubtle) - - // Input - HStack(spacing: 10) { - TextField("Ask AI assistant...", text: $chatInput) - .font(.system(size: 13)) - .foregroundStyle(VelocityTheme.foreground) - .tint(VelocityTheme.accent) - .onSubmit { sendDashboardMessage() } - - Button(action: sendDashboardMessage) { - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 22)) - .foregroundStyle(chatInput.isEmpty ? VelocityTheme.mutedFg : VelocityTheme.accent) - } - .disabled(chatInput.isEmpty || store.isDashboardThinking) - } - .padding(14) } - .background( - RoundedRectangle(cornerRadius: 16) - .fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1)) - ) + .padding(20) + .glassCard(cornerRadius: 18) } - private func sendDashboardMessage() { - let text = chatInput.trimmingCharacters(in: .whitespaces) - guard !text.isEmpty else { return } - chatInput = "" - store.addDashboardMessage(sender: "user", content: text) - store.isDashboardThinking = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - store.isDashboardThinking = false - store.addDashboardMessage( - sender: "ai", - content: dashboardAIResponse(for: text) + private func detailRow(title: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .tracking(1) + .foregroundStyle(VelocityTheme.mutedFg) + Text(value) + .font(.system(size: 14)) + .foregroundStyle(VelocityTheme.foreground) + } + } + + private func emptyMessage(_ message: String) -> some View { + Text(message) + .font(.system(size: 13)) + .foregroundStyle(VelocityTheme.mutedFg) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func statusBadge(label: String, color: Color) -> some View { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(color.opacity(0.12)) + .overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1)) ) - } } - private func dashboardAIResponse(for prompt: String) -> String { - let p = prompt.lowercased() - if p.contains("penthouse") || p.contains("apex") { - return "Apex Innovations probability score is now at 85%. I recommend scheduling a closing meeting next Tuesday — sentiment analysis shows high engagement." - } else if p.contains("visitor") || p.contains("traffic") { - return "Currently \(store.metrics.activeVisitors) active visitors. Zone B (Penthouse Gallery) is generating the highest dwell time today — average 8 minutes." - } else if p.contains("revenue") || p.contains("deal") { - return "Pipeline value stands at \(store.metrics.revenue) for the 30-day forecast. 3 deals are in final negotiation — I recommend prioritising Mohammed Al-Rashid." - } else if p.contains("sentiment") { - return "Showroom sentiment is at \(Int(store.metrics.sentimentScore))% — above the excellent threshold. Visitors in Zone D (Reception) show the highest satisfaction scores." + private var loadingPanel: some View { + VStack(alignment: .leading, spacing: 12) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) + Text("Loading live dashboard data...") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Text("Velocity is reading leads, alerts, calendar events, and inventory summaries from the backend.") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) } - return "I've analysed the current data. Revenue is tracking at \(store.metrics.revenue) with \(store.metrics.activeVisitors) active visitors. Shall I prepare a detailed pipeline report?" + .padding(20) + .glassCard(cornerRadius: 18) + } + + private func errorBanner(_ message: String) -> some View { + Text(message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(VelocityTheme.danger) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(VelocityTheme.danger.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1) + ) + ) } } -// MARK: – KPI Card (live-bound) -private struct LiveKPICard: View { +private struct MetricCard: View { let title: String let value: String let subtitle: String - let icon: String - let accentColor: Color - let glowColor: Color - var badge: String? = nil + let color: Color var body: some View { - VStack(alignment: .leading, spacing: 0) { - HStack { - ZStack { - RoundedRectangle(cornerRadius: 8).fill(accentColor.opacity(0.12)).frame(width: 32, height: 32) - Image(systemName: icon).font(.system(size: 14, weight: .medium)).foregroundStyle(accentColor) - } - Spacer() - if let badge { - HStack(spacing: 4) { - Circle().fill(VelocityTheme.success).frame(width: 5, height: 5) - Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success) - } - } - } - .padding(.bottom, 20) - + VStack(alignment: .leading, spacing: 10) { Text(title.uppercased()) - .font(.system(size: 10, weight: .medium)).tracking(1.2) - .foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 4) - + .font(.system(size: 10, weight: .semibold)) + .tracking(1) + .foregroundStyle(VelocityTheme.mutedFg) Text(value) - .font(.system(size: 34, weight: .semibold)) + .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) - .contentTransition(.numericText()) - .minimumScaleFactor(0.7).lineLimit(1).padding(.bottom, 4) - - Text(subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg) + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) + RoundedRectangle(cornerRadius: 4) + .fill(color) + .frame(width: 52, height: 4) } - .padding(20) - .frame(maxWidth: .infinity, minHeight: 148, alignment: .leading) - .background( - ZStack(alignment: .bottomTrailing) { - RoundedRectangle(cornerRadius: 16).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - Ellipse().fill(glowColor).frame(width: 120, height: 90).blur(radius: 28).offset(x: 20, y: 20) - VStack { - Rectangle() - .fill(LinearGradient(colors: [.clear, .white.opacity(0.10), .clear], startPoint: .leading, endPoint: .trailing)) - .frame(height: 1) - Spacer() - } - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - .overlay(RoundedRectangle(cornerRadius: 16).stroke(VelocityTheme.borderAccent, lineWidth: 1)) - .shadow(color: .black.opacity(0.55), radius: 16, y: 4) - ) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard(cornerRadius: 16) } } -// MARK: – Chat Bubble -private struct ChatBubble: View { - let message: ChatMessage - private var isUser: Bool { message.sender == "user" } - - var body: some View { - HStack(alignment: .bottom, spacing: 8) { - if isUser { Spacer(minLength: 40) } - - if !isUser { - ZStack { - Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26) - Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent) - } - } - - Text(message.content) - .font(.system(size: 13)) - .foregroundStyle(isUser ? .white : VelocityTheme.foreground) - .padding(.horizontal, 12).padding(.vertical, 9) - .background( - RoundedRectangle(cornerRadius: isUser ? 14 : 14) - .fill(isUser - ? VelocityTheme.accent.opacity(0.85) - : Color.white.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(isUser ? .clear : Color.white.opacity(0.10), lineWidth: 1) - ) - ) - - if isUser { - ZStack { - Circle().fill(Color(red: 0.08, green: 0.10, blue: 0.18)).frame(width: 26, height: 26) - Text("A").font(.system(size: 10, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - } - } - if !isUser { Spacer(minLength: 40) } - } - } -} - -// MARK: – Typing Indicator -private struct TypingIndicator: View { - @State private var phase = 0 - - var body: some View { - HStack(alignment: .bottom, spacing: 8) { - ZStack { - Circle().fill(VelocityTheme.accent.opacity(0.18)).frame(width: 26, height: 26) - Image(systemName: "sparkles").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent) - } - HStack(spacing: 4) { - ForEach(0..<3, id: \.self) { i in - Circle() - .fill(VelocityTheme.mutedFg) - .frame(width: 6, height: 6) - .scaleEffect(phase == i ? 1.4 : 0.8) - .animation(.easeInOut(duration: 0.4).repeatForever().delay(Double(i) * 0.15), value: phase) - } - } - .padding(.horizontal, 14).padding(.vertical, 12) - .background(RoundedRectangle(cornerRadius: 14).fill(Color.white.opacity(0.06)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.white.opacity(0.10), lineWidth: 1))) - Spacer(minLength: 40) - } - .id("typing") - .onAppear { - withAnimation { phase = 1 } - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - phase = (phase + 1) % 3 - } - } - } +#Preview { + DashboardView() } diff --git a/iOS/velocity/velocity/Features/Inventory/InventoryView.swift b/iOS/velocity/velocity/Features/Inventory/InventoryView.swift index dd7221f9..c472e67b 100644 --- a/iOS/velocity/velocity/Features/Inventory/InventoryView.swift +++ b/iOS/velocity/velocity/Features/Inventory/InventoryView.swift @@ -75,8 +75,11 @@ struct InventoryView: View { switch store.mode { case .sunseeker: #if targetEnvironment(simulator) - SimulatorSunOverlayView(sunNodesReady: $store.sunNodesReady) - .clipShape(RoundedRectangle(cornerRadius: 20)) + SimulatorUnavailableCard( + icon: "arkit", + title: "Sunseeker requires a real device", + message: "The production build no longer renders a simulated AR sun path with fake location or heading data. Use a physical iPad to inspect the live camera-based overlay." + ) #else SunseekerPanel(sunNodesReady: $store.sunNodesReady) #endif diff --git a/iOS/velocity/velocity/Features/Oracle/OracleView.swift b/iOS/velocity/velocity/Features/Oracle/OracleView.swift index 12503da4..7ab9d87c 100644 --- a/iOS/velocity/velocity/Features/Oracle/OracleView.swift +++ b/iOS/velocity/velocity/Features/Oracle/OracleView.swift @@ -1,960 +1,325 @@ import SwiftUI -// MARK: – Oracle Canvas Modes enum OracleMode: String, CaseIterable { - case pipeline = "Pipeline" - case teamPerformance = "Team Performance" - case accountTimeline = "Account Timeline" - case leadMap = "Lead Map" - case calendarTasks = "Calendar & Tasks" + case pipeline = "Pipeline" + case accountTimeline = "Account Timeline" + case calendarTasks = "Calendar & Tasks" + case teamPerformance = "Team Performance" + case leadMap = "Lead Map" var icon: String { switch self { - case .pipeline: return "square.grid.3x1.below.line.grid.1x2" - case .teamPerformance: return "person.3" - case .accountTimeline: return "clock.arrow.circlepath" - case .leadMap: return "map" - case .calendarTasks: return "calendar" + case .pipeline: + return "square.grid.3x1.below.line.grid.1x2" + case .accountTimeline: + return "clock.arrow.circlepath" + case .calendarTasks: + return "calendar" + case .teamPerformance: + return "person.3" + case .leadMap: + return "map" } } } -// MARK: – Pipeline mock data (extended with detail fields) -struct OracleLeadCard: Identifiable { - let id = UUID() - let initials: String - let name: String - let company: String - let value: String - let status: LeadStatus - let phone: String - let interest: String - let qualification: String -} - -private let pipelineData: [(stage: String, cards: [OracleLeadCard])] = [ - ("New", [ - OracleLeadCard(initials: "JW", name: "James Wilson", company: "Website", value: "AED 3.5M", status: .new, - phone: "+971 52 456 7890", interest: "1BR Investment", qualification: "Potential"), - ]), - ("Qualified", [ - OracleLeadCard(initials: "FH", name: "Fatima Hassan", company: "WhatsApp", value: "AED 12M", status: .qualified, - phone: "+971 54 321 0987", interest: "3BR + Maid", qualification: "Whale"), - OracleLeadCard(initials: "SC", name: "Sarah Chen", company: "Walk-in", value: "AED 6.5M", status: .engaged, - phone: "+971 50 987 6543", interest: "2BR Sea View", qualification: "Potential"), - ]), - ("Proposal", [ - OracleLeadCard(initials: "MA", name: "Mohammed Al-Rashid", company: "WhatsApp", value: "AED 15M+", status: .hot, - phone: "+971 55 123 4567", interest: "Penthouse Suite", qualification: "Whale"), - ]), - ("Closed", [ - OracleLeadCard(initials: "DK", name: "David Kumar", company: "Walk-in", value: "AED 20M", status: .closed, - phone: "+971 56 789 0123", interest: "Full Floor", qualification: "Whale"), - ]), -] - -struct TeamMemberData: Identifiable { - let id = UUID() - let initials: String; let name: String; let deals: Int; let revenue: String; let trend: String -} -private let teamData: [TeamMemberData] = [ - .init(initials: "RA", name: "Rania Al-Farsi", deals: 42, revenue: "$2.1M", trend: "↑ 18%"), - .init(initials: "KM", name: "Khaled Mensah", deals: 31, revenue: "$1.6M", trend: "↑ 12%"), - .init(initials: "LT", name: "Lina Torres", deals: 28, revenue: "$1.3M", trend: "→ 2%"), - .init(initials: "AH", name: "Ahmed Hassan", deals: 19, revenue: "$0.9M", trend: "↓ 5%"), -] - -struct OracleTimelineEvent: Identifiable { - let id = UUID() - let badge: String; let summary: String; let when: String; let detail: String -} -private let timelineEvents: [OracleTimelineEvent] = [ - .init(badge: "MEETING", summary: "VR Amenity Tour – Apex Innovations", when: "2h ago", - detail: "CFO and Legal Director attended. Strong reaction to the panoramic sea view suite. Follow-up proposal requested by Tuesday."), - .init(badge: "EMAIL", summary: "Proposal deck sent to legal team", when: "Yesterday", - detail: "58-page proposal + payment plan schedule sent via DocuSign. Legal team has 5 business days to review."), - .init(badge: "CALL", summary: "Budget discussion – CFO confirmed", when: "Mon", - detail: "Budget ceiling confirmed at AED 15M+. CFO expressed strong preference for a penthouse unit with private terrace."), - .init(badge: "VISIT", summary: "Site walkthrough – Penthouse Suite", when: "Last week", - detail: "First in-person visit. 45-minute walkthrough of Penthouse A & B. Visitor dwell time analysis showed 'excited' sentiment for 90% of the visit."), -] - -struct RegionPin: Identifiable { - let id = UUID() - let label: String; let country: String; let count: Int; let temp: String; let topLead: String -} -private let mapPins: [RegionPin] = [ - .init(label: "UAE", country: "🇦🇪", count: 8, temp: "hot", topLead: "Mohammed Al-Rashid"), - .init(label: "Saudi Arabia", country: "🇸🇦", count: 5, temp: "warm", topLead: "Al-Mansour Group"), - .init(label: "UK", country: "🇬🇧", count: 3, temp: "cold", topLead: "Rexford Capital"), - .init(label: "USA", country: "🇺🇸", count: 4, temp: "warm", topLead: "Apex Innovations"), - .init(label: "India", country: "🇮🇳", count: 6, temp: "hot", topLead: "Starlight Systems"), - .init(label: "Germany", country: "🇩🇪", count: 2, temp: "cold", topLead: "TechWave GmbH"), -] - -struct CalTask: Identifiable { - let id = UUID() - let title: String; let subtitle: String; let due: String -} -private let calTasks: [CalTask] = [ - .init(title: "Follow up with Mohammed", subtitle: "High-value penthouse lead – 2 unread messages", due: "Today 3 PM"), - .init(title: "Send contract to Fatima", subtitle: "3BR unit finalised – payment plan to confirm", due: "Tomorrow 10 AM"), - .init(title: "Schedule VR tour – James", subtitle: "Website lead, potential 1BR investor", due: "Thu 2 PM"), -] - -// MARK: – OracleView (main) struct OracleView: View { + @State private var store = AppStore.shared @State private var selectedMode: OracleMode = .pipeline - @State private var prompt = "Show me a pipeline view by stage for Q4." - @State private var insightText = "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours." - @State private var isSubmitting = false - - // Sheet states - @State private var selectedLead: OracleLeadCard? = nil - @State private var selectedMember: TeamMemberData? = nil - @State private var selectedRegion: RegionPin? = nil - @State private var scheduledTask: CalTask? = nil - @State private var showScheduleConfirm = false + private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect() var body: some View { - ZStack(alignment: .bottom) { - VStack(alignment: .leading, spacing: 0) { - pageHeader - .padding(.horizontal, 24).padding(.top, 24).padding(.bottom, 16) + VStack(alignment: .leading, spacing: 0) { + header + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 16) - insightCard - .padding(.horizontal, 24).padding(.bottom, 14) + modePicker + .padding(.horizontal, 24) + .padding(.bottom, 14) - ScrollView { - canvasView - .padding(.horizontal, 24) - .padding(.bottom, 120) - } + if let error = store.errorMessage { + errorBanner(error) + .padding(.horizontal, 24) + .padding(.bottom, 14) } - promptBar - .padding(.horizontal, 20) - .padding(.bottom, 12) + ScrollView { + canvas + .padding(.horizontal, 24) + .padding(.bottom, 24) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(VelocityTheme.background) - - // Lead detail sheet - .sheet(item: $selectedLead) { card in - LeadDetailSheet(card: card) - } - // Team member sheet - .sheet(item: $selectedMember) { member in - MemberDetailSheet(member: member) - } - // Region callout sheet - .sheet(item: $selectedRegion) { pin in - RegionDetailSheet(pin: pin) - } - // Schedule confirmation alert - .alert("Confirm Schedule", - isPresented: $showScheduleConfirm, - presenting: scheduledTask) { task in - Button("Schedule") { - // In a real app this would create a calendar event - } - Button("Cancel", role: .cancel) {} - } message: { task in - Text("Add \"\(task.title)\" to your calendar for \(task.due)?") + .task { await store.refresh() } + .refreshable { await store.refresh() } + .onReceive(refreshTimer) { _ in + Task { await store.refresh(silent: true) } } } - // MARK: – Sub-views - private var pageHeader: some View { + private var header: some View { HStack { VStack(alignment: .leading, spacing: 3) { - Text("Oracle").font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - Text("AI intelligence pipeline").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg) + Text("Oracle") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(VelocityTheme.foreground) + Text("Live sales intelligence assembled from leads, communication events, and calendar data.") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) } Spacer() - if isSubmitting { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: VelocityTheme.accent)) - .scaleEffect(0.8) - } + Text(store.lastRefreshAt?.relativeShort ?? "Awaiting sync") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(VelocityTheme.mutedFg) } } - private var insightCard: some View { - HStack(alignment: .center, spacing: 0) { - RoundedRectangle(cornerRadius: 2) - .fill(LinearGradient(colors: [Color(red: 0.58, green: 0.77, blue: 0.99), VelocityTheme.accent], - startPoint: .top, endPoint: .bottom)) - .frame(width: 3) - - HStack { - VStack(alignment: .leading, spacing: 3) { - Text("AI INSIGHT").font(.system(size: 9, weight: .semibold)).tracking(1.5) - .foregroundStyle(VelocityTheme.accent) - Text(insightText).font(.system(size: 13)).foregroundStyle(VelocityTheme.foreground).lineLimit(2) - } - Spacer() - HStack(spacing: 5) { - Image(systemName: selectedMode.icon).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent) - Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99)) + private var modePicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(OracleMode.allCases, id: \.self) { mode in + Button { + selectedMode = mode + } label: { + HStack(spacing: 6) { + Image(systemName: mode.icon) + Text(mode.rawValue) + } + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(selectedMode == mode ? VelocityTheme.foreground : VelocityTheme.mutedFg) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + Capsule() + .fill(selectedMode == mode ? VelocityTheme.accent.opacity(0.16) : VelocityTheme.surface) + .overlay( + Capsule() + .stroke(selectedMode == mode ? VelocityTheme.borderAccent : VelocityTheme.borderSubtle, lineWidth: 1) + ) + ) + } + .buttonStyle(.plain) } } - .padding(.horizontal, 14).padding(.vertical, 12) } - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color(red: 0.09, green: 0.15, blue: 0.33).opacity(0.55)) - .overlay(RoundedRectangle(cornerRadius: 12).stroke(VelocityTheme.borderAccent, lineWidth: 1)) - ) - .clipShape(RoundedRectangle(cornerRadius: 12)) } @ViewBuilder - private var canvasView: some View { + private var canvas: some View { switch selectedMode { case .pipeline: - PipelineCanvas(onSelectLead: { selectedLead = $0 }) - case .teamPerformance: - TeamPerformanceCanvas(onSelectMember: { selectedMember = $0 }) + pipelineCanvas case .accountTimeline: - AccountTimelineCanvas() - case .leadMap: - LeadMapCanvas(onSelectRegion: { selectedRegion = $0 }) + timelineCanvas case .calendarTasks: - CalendarCanvas(onSchedule: { task in - scheduledTask = task - showScheduleConfirm = true - }) + calendarCanvas + case .teamPerformance: + unavailableCanvas( + title: "Broker performance feed unavailable", + message: "The current mobile contract does not expose broker-attributed performance rollups yet, so Oracle avoids inventing team metrics here." + ) + case .leadMap: + unavailableCanvas( + title: "Lead map route unavailable", + message: "No production geography route exists for mobile Oracle yet. This view stays disabled until a real geo-backed endpoint is added." + ) } } - // MARK: – Prompt Bar - private var promptBar: some View { - VStack(spacing: 0) { - TextField("Ask Oracle anything…", text: $prompt) - .font(.system(size: 14)) - .foregroundStyle(VelocityTheme.foreground) - .tint(VelocityTheme.accent) - .onSubmit { submitPrompt() } - .padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 8) + private var pipelineCanvas: some View { + let grouped = Dictionary(grouping: store.leads, by: { $0.kanbanStatus.replacingOccurrences(of: "_", with: " ").capitalized }) + let stages = grouped.keys.sorted() - HStack { - Menu { - ForEach(OracleMode.allCases, id: \.self) { mode in - Button { - selectedMode = mode - prompt = modeSamplePrompt(mode) - insightText = oracleInsight(for: mode) - } label: { - Label(mode.rawValue, systemImage: mode.icon) + return VStack(alignment: .leading, spacing: 16) { + summaryCard( + title: "Pipeline Summary", + body: "This view groups live CRM leads by current kanban status. Whale leads and high-score opportunities float to the top of each lane." + ) + + if stages.isEmpty { + emptyCard("No live pipeline rows are available yet.") + } else { + ForEach(stages, id: \.self) { stage in + VStack(alignment: .leading, spacing: 12) { + Text(stage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + + ForEach((grouped[stage] ?? []).sorted(by: { $0.score > $1.score })) { lead in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(lead.name) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Spacer() + Text("\(lead.score)") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(VelocityTheme.accent) + } + Text("\(lead.qualification.capitalized) · \(lead.unitInterest)") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) + Text(lead.budget) + .font(.system(size: 12)) + .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) + ) + ) } } - } label: { - HStack(spacing: 5) { - Image(systemName: selectedMode.icon).font(.system(size: 10)) - Text(selectedMode.rawValue).font(.system(size: 11, weight: .medium)) - Image(systemName: "chevron.down").font(.system(size: 8)) - } - .foregroundStyle(Color(red: 0.58, green: 0.77, blue: 0.99)) - .padding(.horizontal, 10).padding(.vertical, 6) - .background(Capsule().fill(VelocityTheme.accent.opacity(0.14)) - .overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.3), lineWidth: 1))) + .padding(18) + .glassCard(cornerRadius: 18) } - Spacer() - Button(action: submitPrompt) { - ZStack { - Circle() - .fill(isSubmitting ? VelocityTheme.mutedFg : VelocityTheme.accent) - .shadow(color: VelocityTheme.accent.opacity(0.5), radius: 8) - if isSubmitting { - ProgressView().progressViewStyle(CircularProgressViewStyle(tint: .white)).scaleEffect(0.6) - } else { - Image(systemName: "paperplane.fill").font(.system(size: 12)).foregroundStyle(.white) - } - } - .frame(width: 34, height: 34) - } - .disabled(isSubmitting || prompt.trimmingCharacters(in: .whitespaces).isEmpty) - } - .padding(.horizontal, 12).padding(.bottom, 12) - } - .background( - RoundedRectangle(cornerRadius: 18) - .fill(Color(red: 0.039, green: 0.043, blue: 0.063).opacity(0.95)) - .overlay(RoundedRectangle(cornerRadius: 18).stroke(Color.white.opacity(0.11), lineWidth: 1)) - .shadow(color: .black.opacity(0.6), radius: 20, y: -4) - ) - } - - // MARK: – Prompt logic - private func submitPrompt() { - let clean = prompt.trimmingCharacters(in: .whitespaces) - guard !clean.isEmpty && !isSubmitting else { return } - isSubmitting = true - let lower = clean.lowercased() - if lower.contains("team") || lower.contains("performance") || lower.contains("sales") { - selectedMode = .teamPerformance - } else if lower.contains("account") || lower.contains("apex") || lower.contains("timeline") { - selectedMode = .accountTimeline - } else if lower.contains("map") || lower.contains("geographic") || lower.contains("location") { - selectedMode = .leadMap - } else if lower.contains("calendar") || lower.contains("schedule") || lower.contains("task") { - selectedMode = .calendarTasks - } else { - selectedMode = .pipeline - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.9) { - withAnimation(.easeInOut(duration: 0.3)) { - insightText = oracleInsight(for: selectedMode) - isSubmitting = false } } } - private func modeSamplePrompt(_ mode: OracleMode) -> String { - switch mode { - case .pipeline: return "Show me a pipeline view by stage for Q4." - case .teamPerformance: return "What's the performance of the sales team this month?" - case .accountTimeline: return "Find all contacts at Apex Innovations and their recent activity." - case .leadMap: return "Give me a geographic map of all leads." - case .calendarTasks: return "Schedule follow-ups with the top 3 high-value leads." - } - } + private var timelineCanvas: some View { + VStack(alignment: .leading, spacing: 16) { + summaryCard( + title: "Account Timeline", + body: "Recent communication events are pulled from the mobile-edge event stream for the highest-priority leads." + ) - private func oracleInsight(for mode: OracleMode) -> String { - switch mode { - case .pipeline: return "Pipeline is healthy. Mohammed Al-Rashid is your highest-value close opportunity — follow up within 24 hours." - case .teamPerformance: return "Rania Al-Farsi leads the team at $2.1M closed. Overall quota attainment is at 87% — ahead of last month." - case .accountTimeline: return "Apex Innovations has 4 active stakeholders. Confusion detected in legal stage — recommend expediting contract review." - case .leadMap: return "UAE and India show the hottest lead concentration. 8 high-value prospects in UAE require immediate outreach." - case .calendarTasks: return "3 high-priority follow-ups scheduled. Mohammed Al-Rashid has 2 unread messages — respond within 24h." - } - } -} - -// MARK: – Pipeline Canvas -private struct PipelineCanvas: View { - let onSelectLead: (OracleLeadCard) -> Void - private let cols = [GridItem(.adaptive(minimum: 200), spacing: 12)] - - var body: some View { - LazyVGrid(columns: cols, alignment: .leading, spacing: 12) { - ForEach(pipelineData, id: \.stage) { col in - VStack(alignment: .leading, spacing: 10) { - HStack { - Text(col.stage.uppercased()) - .font(.system(size: 10, weight: .semibold)).tracking(1) + if store.timelineEvents.isEmpty { + emptyCard("No live communication events were returned for the current lead set.") + } else { + ForEach(store.timelineEvents.prefix(10)) { item in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(item.leadName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Spacer() + Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(VelocityTheme.accent) + } + Text(item.event.summary ?? "No summary available.") + .font(.system(size: 13)) + .foregroundStyle(VelocityTheme.foreground) + Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp) + .font(.system(size: 11)) .foregroundStyle(VelocityTheme.mutedFg) - Spacer() - Text("\(col.cards.count)") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(VelocityTheme.accent) - .padding(.horizontal, 7).padding(.vertical, 3) - .background(Capsule().fill(VelocityTheme.accent.opacity(0.12)) - .overlay(Capsule().stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1))) - } - ForEach(col.cards) { card in - TappableLeadCard(card: card, onTap: { onSelectLead(card) }) } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard(cornerRadius: 16) } - .padding(16) - .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 TappableLeadCard: View { - let card: OracleLeadCard - let onTap: () -> Void - @State private var pressed = false + private var calendarCanvas: some View { + VStack(alignment: .leading, spacing: 16) { + summaryCard( + title: "Calendar & Tasks", + body: "Confirmed operator calendar events from the live backend appear here without any synthesized task filler." + ) - var body: some View { - HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 8).fill(card.status.color.opacity(0.18)).frame(width: 36, height: 36) - Text(card.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(card.status.color) - } - VStack(alignment: .leading, spacing: 2) { - Text(card.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground) - Text(card.company).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - } - Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(card.value).font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.accent) - Image(systemName: "chevron.right").font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10) - .fill(pressed ? VelocityTheme.accent.opacity(0.10) : Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10) - .stroke(pressed ? VelocityTheme.accent.opacity(0.35) : Color.white.opacity(0.06), lineWidth: 1)) - ) - .scaleEffect(pressed ? 0.97 : 1.0) - .animation(.easeInOut(duration: 0.12), value: pressed) - .onTapGesture { - pressed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false } - onTap() - } - } -} - -// MARK: – Lead Detail Sheet -private struct LeadDetailSheet: View { - let card: OracleLeadCard - @Environment(\.dismiss) private var dismiss - - var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 20) { - // Avatar + name - HStack(spacing: 16) { - ZStack { - RoundedRectangle(cornerRadius: 14).fill(card.status.color.opacity(0.20)).frame(width: 60, height: 60) - Text(card.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(card.status.color) - } - VStack(alignment: .leading, spacing: 4) { - Text(card.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - HStack(spacing: 6) { - Text(card.status.rawValue) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(card.status.color) - .padding(.horizontal, 8).padding(.vertical, 3) - .background(Capsule().fill(card.status.color.opacity(0.14))) - Text(card.qualification).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg) + if store.calendarEvents.isEmpty { + emptyCard("No live calendar events are scheduled yet for this operator.") + } else { + ForEach(store.calendarEvents.prefix(10)) { event in + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(event.title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Spacer() + Text(event.status.capitalized) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(color(for: event.status)) } + Text(formattedDateRange(event)) + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.foreground) + Text(event.location ?? "No location") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) } - } - .padding(.top, 8) - - Divider().background(VelocityTheme.borderSubtle) - - // Details grid - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { - DetailField(label: "Deal Value", value: card.value) - DetailField(label: "Source", value: card.company) - DetailField(label: "Interest", value: card.interest) - DetailField(label: "Phone", value: card.phone) - } - - Divider().background(VelocityTheme.borderSubtle) - - // Action buttons - HStack(spacing: 12) { - ActionChip(icon: "phone.fill", label: "Call", color: VelocityTheme.success) - ActionChip(icon: "message.fill", label: "Message", color: VelocityTheme.accent) - ActionChip(icon: "calendar.badge.plus", label: "Schedule", color: Color(red: 0.60, green: 0.57, blue: 0.99)) - } - - Spacer() - } - .padding(24) - .background(VelocityTheme.background.ignoresSafeArea()) - .navigationTitle("Lead Details") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() } - .foregroundStyle(VelocityTheme.accent) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard(cornerRadius: 16) } } } } -} -private struct DetailField: View { - let label: String; let value: String - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(label.uppercased()).font(.system(size: 9, weight: .semibold)).tracking(1) + private func unavailableCanvas(title: String, message: String) -> some View { + VStack(alignment: .leading, spacing: 16) { + summaryCard(title: title, body: message) + } + } + + private func summaryCard(title: String, body: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Text(body) + .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) - Text(value).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground) } - .padding(12) + .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.07), lineWidth: 1))) + .glassCard(cornerRadius: 18) + } + + private func emptyCard(_ message: String) -> some View { + Text(message) + .font(.system(size: 13)) + .foregroundStyle(VelocityTheme.mutedFg) + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard(cornerRadius: 18) + } + + private func color(for status: String) -> Color { + switch status.lowercased() { + case "confirmed": + return VelocityTheme.success + case "tentative": + return VelocityTheme.warning + default: + return VelocityTheme.accent + } + } + + private func formattedDateRange(_ event: VelocityCalendarEventDTO) -> String { + guard let start = event.startDate else { return event.startAt } + let formatter = DateFormatter() + formatter.dateFormat = "EEE, MMM d · h:mm a" + return formatter.string(from: start) + } + + private func errorBanner(_ message: String) -> some View { + Text(message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(VelocityTheme.danger) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(VelocityTheme.danger.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1) + ) + ) } } -private struct ActionChip: View { - let icon: String; let label: String; let color: Color - @State private var pressed = false - var body: some View { - HStack(spacing: 6) { - Image(systemName: icon).font(.system(size: 12)) - Text(label).font(.system(size: 12, weight: .semibold)) - } - .foregroundStyle(color) - .padding(.horizontal, 16).padding(.vertical, 10) - .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: 10).fill(color.opacity(pressed ? 0.22 : 0.13)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.30), lineWidth: 1))) - .scaleEffect(pressed ? 0.96 : 1.0) - .animation(.easeInOut(duration: 0.12), value: pressed) - .onTapGesture { pressed = true; DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { pressed = false } } - } -} - -// MARK: – Team Performance Canvas -private struct TeamPerformanceCanvas: View { - let onSelectMember: (TeamMemberData) -> Void - - var body: some View { - VStack(spacing: 14) { - quotaPanel - teamListPanel - } - } - - private var quotaPanel: some View { - HStack(spacing: 14) { - ZStack { - Circle().stroke(Color.white.opacity(0.06), lineWidth: 10).frame(width: 110, height: 110) - Circle() - .trim(from: 0, to: 0.87) - .stroke(AngularGradient(colors: [VelocityTheme.accent, Color(red: 0.13, green: 0.83, blue: 0.93)], - center: .center), - style: StrokeStyle(lineWidth: 10, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .frame(width: 110, height: 110) - VStack(spacing: 2) { - Text("87%").font(.system(size: 26, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - Text("QUOTA").font(.system(size: 8, weight: .semibold)).tracking(1.2).foregroundStyle(VelocityTheme.accent) - } - } - VStack(alignment: .leading, spacing: 4) { - Text("Quota Attainment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Text("Monthly target exceeded").font(.system(size: 11)).foregroundStyle(VelocityTheme.success) - Text("Q4 FY2025–26").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg) - } - Spacer() - } - .padding(20) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } - - private var teamListPanel: some View { - VStack(alignment: .leading, spacing: 2) { - Text("TEAM PERFORMANCE").font(.system(size: 10, weight: .semibold)).tracking(1.2) - .foregroundStyle(VelocityTheme.mutedFg).padding(.bottom, 8) - ForEach(teamData) { member in - TappableTeamRow(member: member, onTap: { onSelectMember(member) }) - } - } - .padding(20) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } -} - -private struct TappableTeamRow: View { - let member: TeamMemberData - let onTap: () -> Void - @State private var pressed = false - - var body: some View { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 8).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 36, height: 36) - Text(member.initials).font(.system(size: 12, weight: .bold)).foregroundStyle(VelocityTheme.accent) - } - VStack(alignment: .leading, spacing: 2) { - Text(member.name).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground) - Text("\(member.deals) deals closed").font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg) - } - Spacer() - VStack(alignment: .trailing, spacing: 2) { - Text(member.revenue).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent) - Text(member.trend) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(member.trend.hasPrefix("↑") ? VelocityTheme.success : - member.trend.hasPrefix("↓") ? VelocityTheme.danger : VelocityTheme.mutedFg) - } - Image(systemName: "chevron.right").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10) - .fill(pressed ? VelocityTheme.accent.opacity(0.08) : Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10) - .stroke(pressed ? VelocityTheme.accent.opacity(0.25) : Color.white.opacity(0.06), lineWidth: 1))) - .scaleEffect(pressed ? 0.98 : 1.0) - .animation(.easeInOut(duration: 0.12), value: pressed) - .onTapGesture { - pressed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false } - onTap() - } - } -} - -// MARK: – Team Member Detail Sheet -private struct MemberDetailSheet: View { - let member: TeamMemberData - @Environment(\.dismiss) private var dismiss - var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 20) { - HStack(spacing: 16) { - ZStack { - RoundedRectangle(cornerRadius: 14).fill(VelocityTheme.accent.opacity(0.18)).frame(width: 60, height: 60) - Text(member.initials).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.accent) - } - VStack(alignment: .leading, spacing: 4) { - Text(member.name).font(.system(size: 20, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - Text("Sales Executive").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg) - } - } - .padding(.top, 8) - Divider().background(VelocityTheme.borderSubtle) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { - DetailField(label: "Revenue Closed", value: member.revenue) - DetailField(label: "Deals Closed", value: "\(member.deals)") - DetailField(label: "Trend", value: member.trend) - DetailField(label: "Period", value: "Q4 FY2025–26") - } - Spacer() - } - .padding(24) - .background(VelocityTheme.background.ignoresSafeArea()) - .navigationTitle("Team Member") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent) - } - } - } - } -} - -// MARK: – Account Timeline Canvas -private struct AccountTimelineCanvas: View { - @State private var expandedId: UUID? = nil - - var body: some View { - VStack(spacing: 14) { - // Account overview - VStack(alignment: .leading, spacing: 12) { - Text("ACCOUNT OVERVIEW").font(.system(size: 9, weight: .semibold)).tracking(1.5).foregroundStyle(VelocityTheme.accent) - Text("Apex Innovations").font(.system(size: 24, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - HStack(spacing: 14) { - InfoMini(label: "Deal Value", value: "AED 15M+") - InfoMini(label: "Primary Contact", value: "CEO – James T.") - InfoMini(label: "Industry", value: "Technology") - } - } - .padding(20) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - - // Expandable timeline - VStack(alignment: .leading, spacing: 0) { - Text("Activity Timeline").font(.system(size: 13, weight: .semibold)) - .foregroundStyle(VelocityTheme.foreground).padding(.bottom, 16) - ForEach(Array(timelineEvents.enumerated()), id: \.offset) { i, event in - TimelineEventRow(event: event, isLast: i == timelineEvents.count - 1, - isExpanded: expandedId == event.id) { - withAnimation(.easeInOut(duration: 0.25)) { - expandedId = expandedId == event.id ? nil : event.id - } - } - } - } - .padding(20) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } - } -} - -private struct TimelineEventRow: View { - let event: OracleTimelineEvent - let isLast: Bool - let isExpanded: Bool - let onTap: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 14) { - VStack(spacing: 0) { - Circle().fill(VelocityTheme.accent).frame(width: 10, height: 10) - .overlay(Circle().stroke(VelocityTheme.background, lineWidth: 2)) - if !isLast { - Rectangle() - .fill(LinearGradient(colors: [VelocityTheme.accent.opacity(0.5), .clear], - startPoint: .top, endPoint: .bottom)) - .frame(width: 2) - .frame(height: isExpanded ? 100 : 50) - .animation(.easeInOut(duration: 0.25), value: isExpanded) - } - } - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(event.badge).font(.system(size: 9, weight: .bold)).tracking(1.2) - .foregroundStyle(VelocityTheme.accent) - .padding(.horizontal, 7).padding(.vertical, 3) - .background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.15)) - .overlay(RoundedRectangle(cornerRadius: 4).stroke(VelocityTheme.accent.opacity(0.25), lineWidth: 1))) - Spacer() - Text(event.when).font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - Image(systemName: isExpanded ? "chevron.up" : "chevron.down") - .font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg) - } - Text(event.summary).font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.foreground) - if isExpanded { - Text(event.detail).font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg) - .padding(.top, 4) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10) - .fill(isExpanded ? VelocityTheme.accent.opacity(0.06) : Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10) - .stroke(isExpanded ? VelocityTheme.accent.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1))) - .onTapGesture { onTap() } - .padding(.bottom, 8) - } - } -} - -private struct InfoMini: View { - let label: String; let value: String - var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(label).font(.system(size: 9)).tracking(0.8).foregroundStyle(VelocityTheme.mutedFg) - Text(value).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.accent) - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 8).fill(Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.white.opacity(0.06), lineWidth: 1))) - } -} - -// MARK: – Lead Map Canvas -private struct LeadMapCanvas: View { - let onSelectRegion: (RegionPin) -> Void - private let cols = [GridItem(.adaptive(minimum: 140), spacing: 10)] - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack(spacing: 16) { - LegendDot(color: VelocityTheme.danger, label: "Hot Lead") - LegendDot(color: Color(red: 0.13, green: 0.83, blue: 0.93), label: "Warm Lead") - LegendDot(color: VelocityTheme.mutedFg, label: "Cold Lead") - Spacer() - } - LazyVGrid(columns: cols, spacing: 10) { - ForEach(mapPins) { pin in - TappableRegionPin(pin: pin, onTap: { onSelectRegion(pin) }) - } - } - } - .padding(20) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } -} - -private struct TappableRegionPin: View { - let pin: RegionPin - let onTap: () -> Void - @State private var pressed = false - - private var pinColor: Color { - pin.temp == "hot" ? VelocityTheme.danger : - pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg - } - - var body: some View { - HStack(spacing: 10) { - Text(pin.country).font(.system(size: 24)) - VStack(alignment: .leading, spacing: 2) { - Text(pin.label).font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - HStack(spacing: 4) { - Circle().fill(pinColor).frame(width: 6, height: 6).shadow(color: pinColor.opacity(0.8), radius: 3) - Text("\(pin.count) leads").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - } - } - Spacer() - Image(systemName: "arrow.up.right.circle") - .font(.system(size: 13)).foregroundStyle(VelocityTheme.mutedFg) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10) - .fill(pressed ? pinColor.opacity(0.12) : Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(pinColor.opacity(pressed ? 0.5 : 0.25), lineWidth: 1))) - .scaleEffect(pressed ? 0.97 : 1.0) - .animation(.easeInOut(duration: 0.12), value: pressed) - .onTapGesture { - pressed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { pressed = false } - onTap() - } - } -} - -private struct LegendDot: View { - let color: Color; let label: String - var body: some View { - HStack(spacing: 6) { - Circle().fill(color).frame(width: 8, height: 8).shadow(color: color.opacity(0.8), radius: 3) - Text(label).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg) - } - } -} - -// MARK: – Region Detail Sheet -private struct RegionDetailSheet: View { - let pin: RegionPin - @Environment(\.dismiss) private var dismiss - private var pinColor: Color { - pin.temp == "hot" ? VelocityTheme.danger : - pin.temp == "warm" ? Color(red: 0.13, green: 0.83, blue: 0.93) : VelocityTheme.mutedFg - } - var body: some View { - NavigationStack { - VStack(alignment: .leading, spacing: 20) { - HStack(spacing: 16) { - Text(pin.country).font(.system(size: 52)) - VStack(alignment: .leading, spacing: 4) { - Text(pin.label).font(.system(size: 22, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - HStack(spacing: 6) { - Circle().fill(pinColor).frame(width: 7, height: 7) - Text(pin.temp.capitalized + " Market") - .font(.system(size: 12, weight: .medium)).foregroundStyle(pinColor) - } - } - } - .padding(.top, 8) - Divider().background(VelocityTheme.borderSubtle) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 14) { - DetailField(label: "Active Leads", value: "\(pin.count)") - DetailField(label: "Top Lead", value: pin.topLead) - DetailField(label: "Temperature", value: pin.temp.capitalized) - DetailField(label: "Priority", value: pin.temp == "hot" ? "High 🔴" : pin.temp == "warm" ? "Medium 🟡" : "Low ⚪") - } - Spacer() - } - .padding(24) - .background(VelocityTheme.background.ignoresSafeArea()) - .navigationTitle("Region Details") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() }.foregroundStyle(VelocityTheme.accent) - } - } - } - } -} - -// MARK: – Calendar Canvas -private struct CalendarCanvas: View { - let onSchedule: (CalTask) -> Void - let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] - - var body: some View { - VStack(spacing: 14) { - weekPanel - tasksPanel - } - } - - private var weekPanel: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Weekly Schedule").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - HStack(spacing: 6) { - ForEach(days, id: \.self) { day in - Text(day).font(.system(size: 10, weight: .medium)).tracking(0.5) - .foregroundStyle(VelocityTheme.mutedFg).frame(maxWidth: .infinity) - } - } - HStack(spacing: 6) { - ForEach(Array(days.enumerated()), id: \.offset) { i, _ in - RoundedRectangle(cornerRadius: 8) - .fill(i == 2 ? VelocityTheme.accent.opacity(0.15) : Color.white.opacity(0.03)) - .overlay(RoundedRectangle(cornerRadius: 8) - .stroke(i == 2 ? VelocityTheme.accent.opacity(0.3) : Color.white.opacity(0.05), lineWidth: 1)) - .frame(height: 60) - } - } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } - - private var tasksPanel: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 5) { - Circle().fill(Color(red: 0.13, green: 0.83, blue: 0.93)).frame(width: 6, height: 6) - Text("Tasks & Actions").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - } - .padding(.bottom, 4) - ForEach(calTasks) { task in - CalTaskRow(task: task, onSchedule: { onSchedule(task) }) - } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } -} - -private struct CalTaskRow: View { - let task: CalTask - let onSchedule: () -> Void - @State private var scheduled = false - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(task.title).font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Spacer() - Text(scheduled ? "Scheduled ✓" : "Action") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(scheduled ? VelocityTheme.success : VelocityTheme.mutedFg) - .padding(.horizontal, 6).padding(.vertical, 3) - .background(RoundedRectangle(cornerRadius: 4) - .fill(scheduled ? VelocityTheme.success.opacity(0.12) : Color.white.opacity(0.06))) - } - Text(task.subtitle).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg).lineLimit(2) - HStack { - Image(systemName: "clock").font(.system(size: 10)).foregroundStyle(VelocityTheme.accent) - Text(task.due).font(.system(size: 11)).foregroundStyle(VelocityTheme.accent) - Spacer() - Button { - onSchedule() - withAnimation(.easeInOut(duration: 0.3)) { scheduled = true } - } label: { - HStack(spacing: 5) { - Image(systemName: scheduled ? "checkmark" : "calendar.badge.plus") - .font(.system(size: 10, weight: .semibold)) - Text(scheduled ? "Scheduled" : "Schedule") - .font(.system(size: 11, weight: .semibold)) - } - .foregroundStyle(.white) - .padding(.horizontal, 12).padding(.vertical, 5) - .background(RoundedRectangle(cornerRadius: 7) - .fill(scheduled ? VelocityTheme.success : VelocityTheme.accent) - .shadow(color: (scheduled ? VelocityTheme.success : VelocityTheme.accent).opacity(0.4), radius: 6)) - } - } - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10) - .fill(scheduled ? VelocityTheme.success.opacity(0.05) : Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10) - .stroke(scheduled ? VelocityTheme.success.opacity(0.2) : Color.white.opacity(0.06), lineWidth: 1))) - } +#Preview { + OracleView() } diff --git a/iOS/velocity/velocity/Features/Sentinel/SentinelView.swift b/iOS/velocity/velocity/Features/Sentinel/SentinelView.swift index affd44fd..26d89f76 100644 --- a/iOS/velocity/velocity/Features/Sentinel/SentinelView.swift +++ b/iOS/velocity/velocity/Features/Sentinel/SentinelView.swift @@ -1,413 +1,204 @@ import SwiftUI struct SentinelView: View { - private var store: AppStore { AppStore.shared } - private let indigo = Color(red: 0.60, green: 0.57, blue: 0.99) + @State private var store = AppStore.shared + private let refreshTimer = Timer.publish(every: 20, on: .main, in: .common).autoconnect() var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { - pageHeader - kpiGrid - analyticsRow - bottomRow + header + + if let error = store.errorMessage { + errorBanner(error) + } + + availabilityCard + postureCards + timelineCard } .padding(24) } .background(VelocityTheme.background) .scrollContentBackground(.hidden) + .task { await store.refresh() } + .refreshable { await store.refresh() } + .onReceive(refreshTimer) { _ in + Task { await store.refresh(silent: true) } + } } - // MARK: – Sub-views extracted so the type-checker can cope - private var pageHeader: some View { + private var header: some View { VStack(alignment: .leading, spacing: 4) { Text("Sentinel") - .font(.system(size: 28, weight: .bold)).foregroundStyle(VelocityTheme.foreground) - Text("FaceID · visitor analytics · real-time alerts") - .font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(VelocityTheme.foreground) + Text("Truthful live posture for alerts and comms load; visitor analytics stay disabled until a real Sentinel stream is exposed.") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) } } - private var kpiGrid: some View { - let cols = [GridItem(.adaptive(minimum: 180), spacing: 12)] - return LazyVGrid(columns: cols, spacing: 12) { - SentinelKPI(icon: "person.2.fill", iconColor: VelocityTheme.accent, - label: "Active Visitors", value: "\(store.visitors.count)", - sub: "Currently tracked", badge: "LIVE") - SentinelKPI(icon: "waveform.path.ecg", iconColor: VelocityTheme.success, - label: "Avg Sentiment", value: "\(avgSentiment)%", - sub: "Overall mood") - SentinelKPI(icon: "eye.fill", iconColor: indigo, - label: "Detection Accuracy", value: "\(avgConfidence)%", - sub: "Avg confidence") - SentinelKPI(icon: "faceid", iconColor: VelocityTheme.warning, - label: "Tracked Today", value: "47", - sub: "Unique faces") - } - .animation(.easeInOut(duration: 0.4), value: store.visitors.count) - } - - private var analyticsRow: some View { - let cols = [GridItem(.flexible(), spacing: 14), GridItem(.flexible(minimum: 260), spacing: 14)] - return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) { - ZoneAnalyticsPanel() - ClientInsightsPanel() - } - } - - private var bottomRow: some View { - let cols = [GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14), - GridItem(.flexible(), spacing: 14)] - return LazyVGrid(columns: cols, alignment: .leading, spacing: 14) { - SentimentDistributionPanel(visitors: store.visitors) - DwellTimePanel() - AlertPanel(isActive: store.isAlertActive, message: store.alertMessage) - } - } - - private var avgSentiment: Int { - guard !store.visitors.isEmpty else { return 0 } - let total = store.visitors.reduce(0) { $0 + $1.sentiment.score } - return total / store.visitors.count - } - - private var avgConfidence: Int { - guard !store.visitors.isEmpty else { return 0 } - let total = store.visitors.reduce(0.0) { $0 + $1.confidence } - return Int((total / Double(store.visitors.count)) * 100) - } -} - -// MARK: – KPI Card -private struct SentinelKPI: View { - let icon: String; let iconColor: Color - let label: String; let value: String; let sub: String - var badge: String? = nil - - var body: some View { + private var availabilityCard: some View { VStack(alignment: .leading, spacing: 12) { HStack { - ZStack { - RoundedRectangle(cornerRadius: 8).fill(iconColor.opacity(0.14)).frame(width: 34, height: 34) - Image(systemName: icon).font(.system(size: 14)).foregroundStyle(iconColor) - } + Text("Feed Availability") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) Spacer() - if let badge { - HStack(spacing: 4) { - Circle().fill(VelocityTheme.success).frame(width: 5, height: 5) - Text(badge).font(.system(size: 9, weight: .semibold)).foregroundStyle(VelocityTheme.success) - } - } - } - Text(label.uppercased()).font(.system(size: 10, weight: .medium)).tracking(1.0) - .foregroundStyle(VelocityTheme.mutedFg) - Text(value).font(.system(size: 30, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - .contentTransition(.numericText()) - Text(sub).font(.system(size: 11)).foregroundStyle(VelocityTheme.subtleFg) - } - .padding(18) - .frame(maxWidth: .infinity, minHeight: 140, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(iconColor.opacity(0.18), lineWidth: 1)) - ) - } -} - -// MARK: – Zone Analytics -private struct ZoneAnalyticsPanel: View { - private let zones: [(id: String, name: String, count: Int, sentiment: Int)] = [ - ("A", "Main Showroom", 5, 72), - ("B", "Penthouse Gallery",3, 85), - ("C", "Amenity Deck VR", 2, 68), - ("D", "Reception", 2, 90), - ] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "mappin.and.ellipse").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent) - Text("Zone Analytics").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - } - ForEach(zones, id: \.id) { zone in - HStack(spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 6).fill(VelocityTheme.accent.opacity(0.14)).frame(width: 28, height: 28) - Text(zone.id).font(.system(size: 11, weight: .bold)).foregroundStyle(VelocityTheme.accent) - } - VStack(alignment: .leading, spacing: 2) { - Text(zone.name).font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.foreground) - Text("\(zone.count) visitors").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - } - Spacer() - HStack(spacing: 4) { - let c: Color = zone.sentiment >= 80 ? VelocityTheme.success : - zone.sentiment >= 60 ? VelocityTheme.accent : VelocityTheme.warning - Circle().fill(c).frame(width: 7, height: 7) - Text("\(zone.sentiment)%").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - } - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1))) - } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } -} - -// MARK: – Client Insights -private struct ClientInsightsPanel: View { - private struct Insight { - let name: String; let stage: String; let sentiment: String - let score: Int; let insight: String; let color: Color - - var icon: String { - score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" - } - var scoreColor: Color { - score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger - } - } - - private let insights: [Insight] = [ - .init(name: "Quantum Dynamics", stage: "Proposal Review", sentiment: "Positive", score: 92, - insight: "Key decision maker showed high engagement. Emphasise custom penthouse integration.", - color: VelocityTheme.success), - .init(name: "Nebula Ventures", stage: "Discovery", sentiment: "Neutral", score: 45, - insight: "Initial interest detected but hesitation around pricing model tier.", - color: VelocityTheme.warning), - .init(name: "Apex Industries", stage: "Negotiation", sentiment: "Mixed", score: 68, - insight: "Confusion markers during contract terms review. Legal team is the blocker.", - color: VelocityTheme.danger), - .init(name: "Starlight Systems", stage: "Initial Contact", sentiment: "Positive", score: 78, - insight: "Strong ESG engagement. Align pitch with sustainability goals to accelerate close.", - color: VelocityTheme.accent), - ] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - insightHeader - insightGrid - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14) - .fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14) - .stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } - - private var insightHeader: some View { - HStack(spacing: 6) { - Image(systemName: "sparkles").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent) - Text("AI Strategic Insights") - .font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Spacer() - Text("LIVE ANALYSIS").font(.system(size: 8, weight: .bold)).tracking(1) - .foregroundStyle(VelocityTheme.accent) - .padding(.horizontal, 6).padding(.vertical, 3) - .background(RoundedRectangle(cornerRadius: 4).fill(VelocityTheme.accent.opacity(0.12)) - .overlay(RoundedRectangle(cornerRadius: 4) - .stroke(VelocityTheme.accent.opacity(0.2), lineWidth: 1))) - } - } - - private var insightGrid: some View { - let cols = [GridItem(.flexible(), spacing: 10), GridItem(.flexible(), spacing: 10)] - return LazyVGrid(columns: cols, alignment: .leading, spacing: 10) { - ForEach(insights, id: \.name) { item in - InsightCard( - name: item.name, stage: item.stage, sentiment: item.sentiment, - score: item.score, insight: item.insight, color: item.color, - icon: item.icon, scoreColor: item.scoreColor + statusBadge( + label: "No mock feed", + color: VelocityTheme.warning ) } + + Text("This iPad build does not synthesize visitor counts, facial detections, or sentiment scores. A dedicated production Sentinel route is still required before those analytics can be shown safely.") + .font(.system(size: 14)) + .foregroundStyle(VelocityTheme.foreground) + + Text("Current surface instead reports real operator urgency from the live mobile-edge backend.") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) + } + .padding(20) + .glassCard(cornerRadius: 18) + } + + private var postureCards: some View { + HStack(spacing: 14) { + SentinelCard( + title: "Pending insights", + value: "\(store.metrics.pendingInsights)", + subtitle: "Recommendations waiting on operator review", + color: VelocityTheme.danger + ) + SentinelCard( + title: "Transcript queue", + value: "\(store.metrics.pendingTranscriptions)", + subtitle: "Imported recordings still processing", + color: VelocityTheme.warning + ) + SentinelCard( + title: "Upcoming 24h", + value: "\(store.alertSnapshot?.upcomingCalendarEvents24h ?? 0)", + subtitle: "Calendar events due soon", + color: VelocityTheme.success + ) } } -} -private struct InsightCard: View { - struct Item { - let name: String; let stage: String; let sentiment: String - let score: Int; let insight: String; let color: Color - var icon: String { score >= 80 ? "arrow.up.right" : score >= 50 ? "minus" : "exclamationmark.triangle" } - var scoreColor: Color { score >= 80 ? VelocityTheme.success : score >= 50 ? VelocityTheme.warning : VelocityTheme.danger } - } - - // Accept ClientInsightsPanel.Insight via protocol duck-typing workaround: - let name: String; let stage: String; let sentiment: String - let score: Int; let insight: String; let color: Color - let icon: String; let scoreColor: Color - - var body: some View { - VStack(alignment: .leading, spacing: 8) { + private var timelineCard: some View { + VStack(alignment: .leading, spacing: 14) { HStack { - ZStack { - RoundedRectangle(cornerRadius: 6).fill(color.opacity(0.18)).frame(width: 28, height: 28) - Image(systemName: icon).font(.system(size: 11)).foregroundStyle(color) - } + Text("Recent Operator Timeline") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) Spacer() - Text("\(score)").font(.system(size: 11, weight: .bold)) - .foregroundStyle(scoreColor) - .padding(.horizontal, 6).padding(.vertical, 2) - .background(RoundedRectangle(cornerRadius: 4).fill(scoreColor.opacity(0.15))) - } - Text(name).font(.system(size: 12, weight: .semibold)) - .foregroundStyle(VelocityTheme.foreground).lineLimit(1) - Text(insight).font(.system(size: 10)) - .foregroundStyle(VelocityTheme.mutedFg).lineLimit(3) - HStack { - Text(stage).font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg) - Spacer() - Text(sentiment).font(.system(size: 9, weight: .semibold)).foregroundStyle(color) - } - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(color.opacity(0.15), lineWidth: 1))) - } -} - - -// MARK: – Sentiment Distribution -private struct SentimentDistributionPanel: View { - let visitors: [Visitor] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "chart.bar").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent) - Text("Sentiment").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - } - ForEach(SentimentType.allCases, id: \.self) { type in - let count = visitors.filter { $0.sentiment == type }.count - let fraction = visitors.isEmpty ? 0.0 : Double(count) / Double(visitors.count) - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(type.emoji).font(.system(size: 14)) - Text(type.rawValue.capitalized).font(.system(size: 11)).foregroundStyle(VelocityTheme.mutedFg) - Spacer() - Text("\(count)").font(.system(size: 11, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - } - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3).fill(Color.white.opacity(0.06)).frame(height: 5) - RoundedRectangle(cornerRadius: 3).fill(type.color) - .frame(width: geo.size.width * fraction, height: 5) - .animation(.easeOut(duration: 0.6), value: fraction) - } - } - .frame(height: 5) + if let lastRefresh = store.lastRefreshAt { + Text("Updated \(lastRefresh.relativeShort)") + .font(.system(size: 11)) + .foregroundStyle(VelocityTheme.mutedFg) } } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } -} -// MARK: – Dwell Time Panel -private struct DwellTimePanel: View { - private let data: [(range: String, count: Int, trend: String)] = [ - ("< 5 min", 3, "down"), - ("5–15 min", 5, "up"), - ("15–30 min", 8, "up"), - ("> 30 min", 4, "stable"), - ] - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "timer").font(.system(size: 12)).foregroundStyle(VelocityTheme.accent) - Text("Dwell Time").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - } - let cols = [GridItem(.flexible(), spacing: 8), GridItem(.flexible(), spacing: 8)] - LazyVGrid(columns: cols, spacing: 8) { - ForEach(data, id: \.range) { item in - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(item.range).font(.system(size: 9)).foregroundStyle(VelocityTheme.mutedFg) - Spacer() - Image(systemName: item.trend == "up" ? "arrow.up.right" : - item.trend == "down" ? "arrow.down.right" : "minus") - .font(.system(size: 9)) - .foregroundStyle(item.trend == "up" ? VelocityTheme.success : - item.trend == "down" ? VelocityTheme.danger : VelocityTheme.mutedFg) - } - Text("\(item.count)").font(.system(size: 22, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Text("visitors").font(.system(size: 9)).foregroundStyle(VelocityTheme.subtleFg) - } - .padding(10) - .background(RoundedRectangle(cornerRadius: 10).fill(Color.white.opacity(0.04)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.white.opacity(0.06), lineWidth: 1))) - } - } - } - .padding(16) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) - } -} - -// MARK: – Alert Panel -private struct AlertPanel: View { - let isActive: Bool - let message: String - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 6) { - Image(systemName: "bell.badge").font(.system(size: 12)).foregroundStyle(VelocityTheme.warning) - Text("Alerts").font(.system(size: 13, weight: .semibold)).foregroundStyle(VelocityTheme.foreground) - Spacer() - Text(isActive ? "Active" : "Clear") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(isActive ? VelocityTheme.warning : VelocityTheme.success) - .padding(.horizontal, 7).padding(.vertical, 3) - .background(Capsule().fill((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.15)) - .overlay(Capsule().stroke((isActive ? VelocityTheme.warning : VelocityTheme.success).opacity(0.25), lineWidth: 1))) - } - - if isActive { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 14)).foregroundStyle(VelocityTheme.warning) - Text("Sentiment Alert").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.warning) - } - Text(message).font(.system(size: 12)).foregroundStyle(VelocityTheme.foreground).lineLimit(3) - Text("Just now").font(.system(size: 10)).foregroundStyle(VelocityTheme.mutedFg) - } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.warning.opacity(0.08)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.warning.opacity(0.3), lineWidth: 1))) - .transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity), - removal: .scale(scale: 0.95).combined(with: .opacity))) + if store.timelineEvents.isEmpty { + Text("No live communication events have been loaded for the current high-priority leads yet.") + .font(.system(size: 13)) + .foregroundStyle(VelocityTheme.mutedFg) } else { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Image(systemName: "checkmark.shield.fill") - .font(.system(size: 14)).foregroundStyle(VelocityTheme.success) - Text("All Clear").font(.system(size: 12, weight: .semibold)).foregroundStyle(VelocityTheme.success) + ForEach(store.timelineEvents.prefix(6)) { item in + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(item.leadName) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(VelocityTheme.foreground) + Spacer() + Text(item.event.channel.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(VelocityTheme.accent) + } + Text(item.event.summary ?? "No summary available for this event.") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) + Text(item.event.timestampDate?.relativeShort ?? item.event.timestamp) + .font(.system(size: 11)) + .foregroundStyle(VelocityTheme.mutedFg) } - Text("No alerts at this time").font(.system(size: 12)).foregroundStyle(VelocityTheme.mutedFg) - Text("System nominal").font(.system(size: 10)).foregroundStyle(VelocityTheme.subtleFg) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(VelocityTheme.surface) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(VelocityTheme.borderSubtle, lineWidth: 1) + ) + ) } - .padding(12) - .background(RoundedRectangle(cornerRadius: 10).fill(VelocityTheme.success.opacity(0.08)) - .overlay(RoundedRectangle(cornerRadius: 10).stroke(VelocityTheme.success.opacity(0.3), lineWidth: 1))) - .transition(.asymmetric(insertion: .scale(scale: 0.95).combined(with: .opacity), - removal: .scale(scale: 0.95).combined(with: .opacity))) } } - .padding(16) - .animation(.easeInOut(duration: 0.3), value: isActive) - .background(RoundedRectangle(cornerRadius: 14).fill(Color(red: 0.031, green: 0.039, blue: 0.071)) - .overlay(RoundedRectangle(cornerRadius: 14).stroke(VelocityTheme.borderAccent, lineWidth: 1))) + .padding(20) + .glassCard(cornerRadius: 18) + } + + private func statusBadge(label: String, color: Color) -> some View { + Text(label) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(color) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(color.opacity(0.12)) + .overlay(Capsule().stroke(color.opacity(0.22), lineWidth: 1)) + ) + } + + private func errorBanner(_ message: String) -> some View { + Text(message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(VelocityTheme.danger) + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 14) + .fill(VelocityTheme.danger.opacity(0.10)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(VelocityTheme.danger.opacity(0.22), lineWidth: 1) + ) + ) } } + +private struct SentinelCard: View { + let title: String + let value: String + let subtitle: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(title.uppercased()) + .font(.system(size: 10, weight: .semibold)) + .tracking(1) + .foregroundStyle(VelocityTheme.mutedFg) + Text(value) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(VelocityTheme.foreground) + Text(subtitle) + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) + RoundedRectangle(cornerRadius: 4) + .fill(color) + .frame(width: 48, height: 4) + } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .glassCard(cornerRadius: 16) + } +} + +#Preview { + SentinelView() +} diff --git a/iOS/velocity/velocity/Features/Settings/SettingsView.swift b/iOS/velocity/velocity/Features/Settings/SettingsView.swift index b32a65ea..965da816 100644 --- a/iOS/velocity/velocity/Features/Settings/SettingsView.swift +++ b/iOS/velocity/velocity/Features/Settings/SettingsView.swift @@ -1,74 +1,76 @@ import SwiftUI struct SettingsView: View { + @State private var store = AppStore.shared + var body: some View { VStack(alignment: .leading, spacing: 24) { - // Page header VStack(alignment: .leading, spacing: 4) { Text("Settings") .font(.system(size: 28, weight: .bold)) .foregroundStyle(VelocityTheme.foreground) - Text("Configuration") + Text("Live runtime configuration") .font(.system(size: 12)) .foregroundStyle(VelocityTheme.mutedFg) } - // System (live) section - SettingsSection(title: "System") { - HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 7) - .fill(VelocityTheme.success.opacity(0.12)).frame(width: 30, height: 30) - Image(systemName: "bolt.fill") - .font(.system(size: 13, weight: .medium)).foregroundStyle(VelocityTheme.success) - } - Text("Connection Status").font(.system(size: 14)).foregroundStyle(VelocityTheme.foreground) - Spacer() - HStack(spacing: 5) { - Circle().fill(VelocityTheme.success).frame(width: 6, height: 6) - Text("Online").font(.system(size: 12, weight: .medium)).foregroundStyle(VelocityTheme.success) - } + SettingsSection(title: "Connectivity") { + SettingsRow( + label: "Backend endpoint", + value: AppConfig.baseURL, + icon: "server.rack", + accentColor: VelocityTheme.accent + ) + Divider().background(VelocityTheme.borderSubtle) + SettingsRow( + label: "Auth mode", + value: AppConfig.authModeDescription, + icon: "lock.shield", + accentColor: store.isConfigured ? VelocityTheme.success : VelocityTheme.warning + ) + 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: store.operatorIdentity, + icon: "person.crop.circle", + accentColor: VelocityTheme.accent + ) + Divider().background(VelocityTheme.borderSubtle) + SettingsRow( + label: "Lead records loaded", + value: "\(store.leads.count)", + icon: "person.3", + accentColor: VelocityTheme.success + ) + Divider().background(VelocityTheme.borderSubtle) + SettingsRow( + label: "Property records loaded", + value: "\(store.properties.count)", + icon: "building.2", + accentColor: VelocityTheme.warning + ) + } + + SettingsSection(title: "Production Notes") { + VStack(alignment: .leading, spacing: 8) { + Text("This build avoids local demo data. If credentials are missing or a route is unavailable, the surface reports that state instead of fabricating operator metrics.") + .font(.system(size: 13)) + .foregroundStyle(VelocityTheme.foreground) + Text("Sentinel visitor analytics remain disabled on iPad until a dedicated production feed exists; Communications, Calendar, Dashboard, Oracle pipeline, and inventory summaries are live-backed.") + .font(.system(size: 12)) + .foregroundStyle(VelocityTheme.mutedFg) } - .padding(.horizontal, 16).padding(.vertical, 12) - } - - // Backend section - SettingsSection(title: "Backend") { - SettingsRow(label: "ComfyUI Endpoint", - value: "http://192.168.x.x:8000", - icon: "server.rack", - accentColor: VelocityTheme.accent) - Divider().background(VelocityTheme.borderSubtle) - SettingsRow(label: "Dream Weaver Path", - value: "/dream-weaver", - icon: "arrow.triangle.branch", - accentColor: VelocityTheme.accent) - } - - // Display section - SettingsSection(title: "Display") { - SettingsRow(label: "Orientation", - value: "Landscape Only", - icon: "rectangle.landscape.rotate", - accentColor: VelocityTheme.mutedFg) - Divider().background(VelocityTheme.borderSubtle) - SettingsRow(label: "Theme", - value: "Dark", - icon: "moon.fill", - accentColor: Color(red: 0.60, green: 0.57, blue: 0.99)) - } - - // App info section - SettingsSection(title: "About") { - SettingsRow(label: "Version", - value: "1.1.0", - icon: "info.circle", - accentColor: VelocityTheme.mutedFg) - Divider().background(VelocityTheme.borderSubtle) - SettingsRow(label: "Build", - value: "SwiftUI · iOS 17+", - icon: "hammer", - accentColor: VelocityTheme.mutedFg) + .padding(.horizontal, 16) + .padding(.vertical, 14) } Spacer() @@ -134,6 +136,7 @@ private struct SettingsRow: View { Text(value) .font(.system(size: 13)) .foregroundStyle(VelocityTheme.mutedFg) + .multilineTextAlignment(.trailing) } .padding(.horizontal, 16) .padding(.vertical, 12)