merge upstream

This commit is contained in:
2026-04-20 00:49:17 +05:30
65 changed files with 7652 additions and 2202 deletions

View File

@@ -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.

View File

@@ -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")
}

View File

@@ -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"

View File

@@ -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 { "{}" })
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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),
)
}
}
}
}
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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),
)
}
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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")
}

View File

@@ -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"

View File

@@ -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)
}
},
) {

View File

@@ -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 { "{}" })
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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))
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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.")
}
}
}
}
}

View File

@@ -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),
)
}
}
}

View File

@@ -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()}

View File

@@ -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))
}
}

View File

@@ -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)
}
}
}

View File

@@ -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"
)
}
}

View File

@@ -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"
)
}
}

View File

@@ -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"
)
}
}

View File

@@ -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"
)
}
}

View File

@@ -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"
)
}
}

View File

@@ -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"
)
}
}

View File

@@ -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.

View File

@@ -1,10 +0,0 @@
import SwiftUI
@main
struct VelocityEdgePhoneApp: App {
var body: some Scene {
WindowGroup {
EdgeRootView()
}
}
}

View File

@@ -0,0 +1,5 @@
BASE_URL = https://api.desineuron.in
API_EMAIL =
API_PASSWORD =
API_BEARER_TOKEN =
APP_VERSION = 1.0.0

View 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>

View 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.

View 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 */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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"
}
}

View File

@@ -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) }
}
}

View 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))
}
}

View File

@@ -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)
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View 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)
}
}
}

View File

@@ -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

View File

@@ -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"
}
}

View File

@@ -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)
}
}

View File

@@ -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 // 01
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 // 0100
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 58M", 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 35M", 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"
}
}

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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"),
("515 min", 5, "up"),
("1530 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()
}

View File

@@ -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)