forked from sagnik/Project_Velocity
Velocity Iphone App
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="Velocity Edge"
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.desineuron.velocity.edgephone.data
|
||||
|
||||
import com.desineuron.velocity.edgephone.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URLEncoder
|
||||
import java.net.URL
|
||||
|
||||
data class EdgeLead(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val score: Int,
|
||||
val qualification: String,
|
||||
val unitInterest: String,
|
||||
val budget: String,
|
||||
)
|
||||
|
||||
data class EdgeAlertSnapshot(
|
||||
val pendingInsights: Int,
|
||||
val pendingTranscriptions: Int,
|
||||
val upcomingCalendarEvents24h: Int,
|
||||
)
|
||||
|
||||
data class EdgeCommunicationEvent(
|
||||
val id: String,
|
||||
val leadId: String,
|
||||
val leadName: String,
|
||||
val channel: String,
|
||||
val summary: String,
|
||||
val timestamp: String,
|
||||
val recordingRef: String,
|
||||
)
|
||||
|
||||
data class EdgeMemoryFact(
|
||||
val id: String,
|
||||
val factType: String,
|
||||
val factText: String,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
data class EdgeTranscript(
|
||||
val eventId: String,
|
||||
val status: String,
|
||||
val segmentCount: Int,
|
||||
)
|
||||
|
||||
object VelocityEdgeBackend {
|
||||
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 fetchLeads(): List<EdgeLead> = 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<EdgeCommunicationEvent> = 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<EdgeMemoryFact> = 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<Unit> = 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<EdgeTranscript> = 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 { "{}" })
|
||||
}
|
||||
}
|
||||
@@ -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<Result<EdgeAlertSnapshot>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<List<EdgeCommunicationEvent>>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<List<EdgeLead>>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<List<EdgeLead>>?>(initialValue = null) {
|
||||
value = runCatching { VelocityEdgeBackend.fetchLeads() }
|
||||
}
|
||||
var noteText by remember { mutableStateOf("") }
|
||||
var submissionMessage by remember { mutableStateOf<String?>(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<Result<List<EdgeMemoryFact>>?>(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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<EdgeTranscript?>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="Velocity Tablet"
|
||||
|
||||
@@ -77,7 +77,7 @@ private fun TabletApp() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("Velocity", color = Color.White, style = MaterialTheme.typography.titleMedium)
|
||||
Text("Tablet parity", color = Color(0xFF8A93A6), style = MaterialTheme.typography.labelSmall)
|
||||
Text("Live tablet", color = Color(0xFF8A93A6), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.desineuron.velocity.tablet.data
|
||||
|
||||
import com.desineuron.velocity.tablet.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class VelocityLead(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val score: Int,
|
||||
val qualification: String,
|
||||
val kanbanStatus: String,
|
||||
val unitInterest: String,
|
||||
val budget: String,
|
||||
)
|
||||
|
||||
data class VelocityProperty(
|
||||
val id: String,
|
||||
val projectName: String,
|
||||
val developerName: String,
|
||||
val propertyType: String,
|
||||
val status: String,
|
||||
val locationSummary: String,
|
||||
)
|
||||
|
||||
data class VelocityCalendarEvent(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val status: String,
|
||||
val location: String,
|
||||
val startAt: String,
|
||||
)
|
||||
|
||||
data class VelocityAlertSnapshot(
|
||||
val pendingInsights: Int,
|
||||
val pendingTranscriptions: Int,
|
||||
val upcomingCalendarEvents24h: Int,
|
||||
)
|
||||
|
||||
data class VelocityEvent(
|
||||
val id: String,
|
||||
val leadName: String,
|
||||
val channel: String,
|
||||
val summary: String,
|
||||
val timestamp: String,
|
||||
)
|
||||
|
||||
data class TabletDashboardSnapshot(
|
||||
val leads: List<VelocityLead>,
|
||||
val properties: List<VelocityProperty>,
|
||||
val calendarEvents: List<VelocityCalendarEvent>,
|
||||
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<VelocityLead> = 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<VelocityProperty> = 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<VelocityCalendarEvent> = 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<VelocityLead>, limitPerLead: Int = 2): List<VelocityEvent> = 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 { "{}" })
|
||||
}
|
||||
}
|
||||
@@ -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<Result<TabletDashboardSnapshot>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
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<String, String, Color>) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<List<VelocityProperty>>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<List<VelocityLead>>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Result<VelocityAlertSnapshot>?>(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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,10 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct VelocityEdgePhoneApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
EdgeRootView()
|
||||
}
|
||||
}
|
||||
}
|
||||
5
iOS/velocity-iphone/Config.xcconfig.example
Normal file
5
iOS/velocity-iphone/Config.xcconfig.example
Normal file
@@ -0,0 +1,5 @@
|
||||
BASE_URL = https://api.desineuron.in
|
||||
API_EMAIL =
|
||||
API_PASSWORD =
|
||||
API_BEARER_TOKEN =
|
||||
APP_VERSION = 1.0.0
|
||||
32
iOS/velocity-iphone/Info.plist
Normal file
32
iOS/velocity-iphone/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>API_BEARER_TOKEN</key>
|
||||
<string>$(API_BEARER_TOKEN)</string>
|
||||
<key>API_EMAIL</key>
|
||||
<string>$(API_EMAIL)</string>
|
||||
<key>API_PASSWORD</key>
|
||||
<string>$(API_PASSWORD)</string>
|
||||
<key>APP_VERSION</key>
|
||||
<string>$(APP_VERSION)</string>
|
||||
<key>BASE_URL</key>
|
||||
<string>$(BASE_URL)</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Velocity needs camera access to capture live room imagery for Dream Weaver.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Velocity needs photo library access so you can select room images for Dream Weaver.</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
42
iOS/velocity-iphone/README.md
Normal file
42
iOS/velocity-iphone/README.md
Normal file
@@ -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.
|
||||
339
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj
Normal file
339
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.pbxproj
Normal file
@@ -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 = "<group>";
|
||||
};
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
B31C10082F58D9C300A74A49 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B31C10012F58D9C300A74A49 /* velocity-iphone.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
7
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
iOS/velocity-iphone/velocity-iphone.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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..<attempts {
|
||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
let status: DreamWeaverStatus = try await rawRequest(DreamWeaverStatus.self, path: "/dream-weaver/status/\(jobId)")
|
||||
if status.ready {
|
||||
return try buildURL(path: "/dream-weaver/result/\(jobId)")
|
||||
}
|
||||
if status.status == "error" {
|
||||
throw DreamWeaverRuntimeError.api(status.error ?? "Dream Weaver failed.")
|
||||
}
|
||||
}
|
||||
throw DreamWeaverRuntimeError.timeout
|
||||
}
|
||||
|
||||
private func rawRequest<T: Decodable>(_ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
495
iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift
Normal file
495
iOS/velocity-iphone/velocity-iphone/Core/UI/EdgeTheme.swift
Normal file
@@ -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<Content: View>: 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<Content: View>: 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<Content: View>: 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))
|
||||
}
|
||||
}
|
||||
@@ -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<Content: View>: 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<Content: View>: 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<Content: View>: 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<Selection: Hashable & CaseIterable & Identifiable>: 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1924
iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift
Normal file
1924
iOS/velocity-iphone/velocity-iphone/EdgeRootView.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
14
iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift
Normal file
14
iOS/velocity-iphone/velocity-iphone/VelocityIPhoneApp.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user