feat/#28 (#29)

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: sagnik/Project_Velocity#29
This commit is contained in:
2026-04-20 00:48:01 +05:30
parent 4e3ce623a6
commit 57144e1bd3
65 changed files with 7652 additions and 2202 deletions

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