feat/#28 (#29)

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #29
This commit was merged in pull request #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.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),
)
}
}
}