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:
@@ -4,6 +4,11 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
fun Project.gradleStringProperty(name: String, defaultValue: String): String {
|
||||
val raw = (findProperty(name) as String?) ?: defaultValue
|
||||
return "\"${raw.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.desineuron.velocity.tablet"
|
||||
compileSdk = 35
|
||||
@@ -16,6 +21,10 @@ android {
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
buildConfigField("String", "VELOCITY_BASE_URL", project.gradleStringProperty("VELOCITY_BASE_URL", "https://api.desineuron.in"))
|
||||
buildConfigField("String", "VELOCITY_API_EMAIL", project.gradleStringProperty("VELOCITY_API_EMAIL", ""))
|
||||
buildConfigField("String", "VELOCITY_API_PASSWORD", project.gradleStringProperty("VELOCITY_API_PASSWORD", ""))
|
||||
buildConfigField("String", "VELOCITY_BEARER_TOKEN", project.gradleStringProperty("VELOCITY_BEARER_TOKEN", ""))
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
@@ -42,6 +51,7 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +68,7 @@ dependencies {
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="Velocity Tablet"
|
||||
|
||||
@@ -77,7 +77,7 @@ private fun TabletApp() {
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("Velocity", color = Color.White, style = MaterialTheme.typography.titleMedium)
|
||||
Text("Tablet parity", color = Color(0xFF8A93A6), style = MaterialTheme.typography.labelSmall)
|
||||
Text("Live tablet", color = Color(0xFF8A93A6), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.desineuron.velocity.tablet.data
|
||||
|
||||
import com.desineuron.velocity.tablet.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
data class VelocityLead(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val score: Int,
|
||||
val qualification: String,
|
||||
val kanbanStatus: String,
|
||||
val unitInterest: String,
|
||||
val budget: String,
|
||||
)
|
||||
|
||||
data class VelocityProperty(
|
||||
val id: String,
|
||||
val projectName: String,
|
||||
val developerName: String,
|
||||
val propertyType: String,
|
||||
val status: String,
|
||||
val locationSummary: String,
|
||||
)
|
||||
|
||||
data class VelocityCalendarEvent(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val status: String,
|
||||
val location: String,
|
||||
val startAt: String,
|
||||
)
|
||||
|
||||
data class VelocityAlertSnapshot(
|
||||
val pendingInsights: Int,
|
||||
val pendingTranscriptions: Int,
|
||||
val upcomingCalendarEvents24h: Int,
|
||||
)
|
||||
|
||||
data class VelocityEvent(
|
||||
val id: String,
|
||||
val leadName: String,
|
||||
val channel: String,
|
||||
val summary: String,
|
||||
val timestamp: String,
|
||||
)
|
||||
|
||||
data class TabletDashboardSnapshot(
|
||||
val leads: List<VelocityLead>,
|
||||
val properties: List<VelocityProperty>,
|
||||
val calendarEvents: List<VelocityCalendarEvent>,
|
||||
val alerts: VelocityAlertSnapshot,
|
||||
)
|
||||
|
||||
object VelocityBackend {
|
||||
private val tokenMutex = Mutex()
|
||||
private var cachedToken: String? = null
|
||||
|
||||
val baseUrl: String = BuildConfig.VELOCITY_BASE_URL.trimEnd('/')
|
||||
val authMode: String
|
||||
get() = when {
|
||||
BuildConfig.VELOCITY_BEARER_TOKEN.isNotBlank() -> "Bearer token"
|
||||
BuildConfig.VELOCITY_API_EMAIL.isNotBlank() && BuildConfig.VELOCITY_API_PASSWORD.isNotBlank() -> "Email/password"
|
||||
else -> "Credentials required"
|
||||
}
|
||||
|
||||
val isConfigured: Boolean
|
||||
get() = BuildConfig.VELOCITY_BEARER_TOKEN.isNotBlank() ||
|
||||
(BuildConfig.VELOCITY_API_EMAIL.isNotBlank() && BuildConfig.VELOCITY_API_PASSWORD.isNotBlank())
|
||||
|
||||
suspend fun fetchDashboardSnapshot(): TabletDashboardSnapshot = withContext(Dispatchers.IO) {
|
||||
TabletDashboardSnapshot(
|
||||
leads = fetchLeads(),
|
||||
properties = fetchProperties(),
|
||||
calendarEvents = fetchCalendarEvents(),
|
||||
alerts = fetchAlerts(),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun fetchLeads(): List<VelocityLead> = withContext(Dispatchers.IO) {
|
||||
val root = getJson("/api/leads")
|
||||
val items = root.optJSONArray("data") ?: JSONArray()
|
||||
buildList {
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
add(
|
||||
VelocityLead(
|
||||
id = item.optString("id"),
|
||||
name = item.optString("name"),
|
||||
score = item.optInt("score"),
|
||||
qualification = item.optString("qualification"),
|
||||
kanbanStatus = item.optString("kanban_status"),
|
||||
unitInterest = item.optString("unit_interest"),
|
||||
budget = item.optString("budget"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchProperties(): List<VelocityProperty> = withContext(Dispatchers.IO) {
|
||||
val root = getJson("/api/inventory/properties?limit=25")
|
||||
val items = root.optJSONArray("properties") ?: JSONArray()
|
||||
buildList {
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
val location = item.optJSONObject("location")
|
||||
val city = location?.optString("city").orEmpty()
|
||||
val district = location?.optString("district").orEmpty()
|
||||
val locationSummary = listOf(district, city).filter { it.isNotBlank() }.joinToString(", ").ifBlank { "Location pending" }
|
||||
add(
|
||||
VelocityProperty(
|
||||
id = item.optString("property_id"),
|
||||
projectName = item.optString("project_name"),
|
||||
developerName = item.optString("developer_name"),
|
||||
propertyType = item.optString("property_type"),
|
||||
status = item.optString("status"),
|
||||
locationSummary = locationSummary,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchCalendarEvents(): List<VelocityCalendarEvent> = withContext(Dispatchers.IO) {
|
||||
val root = getJson("/api/mobile-edge/calendar?limit=20")
|
||||
val items = root.optJSONArray("events") ?: JSONArray()
|
||||
buildList {
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
add(
|
||||
VelocityCalendarEvent(
|
||||
id = item.optString("calendar_event_id"),
|
||||
title = item.optString("title"),
|
||||
status = item.optString("status"),
|
||||
location = item.optString("location").ifBlank { "No location" },
|
||||
startAt = item.optString("start_at"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchAlerts(): VelocityAlertSnapshot = withContext(Dispatchers.IO) {
|
||||
val root = getJson("/api/mobile-edge/alerts")
|
||||
VelocityAlertSnapshot(
|
||||
pendingInsights = root.optInt("pending_insights"),
|
||||
pendingTranscriptions = root.optInt("pending_transcriptions"),
|
||||
upcomingCalendarEvents24h = root.optInt("upcoming_calendar_events_24h"),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun fetchLeadEvents(leads: List<VelocityLead>, limitPerLead: Int = 2): List<VelocityEvent> = withContext(Dispatchers.IO) {
|
||||
val focus = leads.sortedByDescending { it.score }.take(4)
|
||||
buildList {
|
||||
focus.forEach { lead ->
|
||||
val root = getJson("/api/mobile-edge/events?lead_id=${lead.id}&limit=$limitPerLead")
|
||||
val items = root.optJSONArray("events") ?: JSONArray()
|
||||
for (index in 0 until items.length()) {
|
||||
val item = items.getJSONObject(index)
|
||||
add(
|
||||
VelocityEvent(
|
||||
id = item.optString("event_id"),
|
||||
leadName = lead.name,
|
||||
channel = item.optString("channel"),
|
||||
summary = item.optString("summary").ifBlank { "No summary available." },
|
||||
timestamp = item.optString("timestamp"),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getToken(): String {
|
||||
BuildConfig.VELOCITY_BEARER_TOKEN.takeIf { it.isNotBlank() }?.let { return it }
|
||||
if (cachedToken != null) return cachedToken!!
|
||||
return tokenMutex.withLock {
|
||||
cachedToken?.let { return@withLock it }
|
||||
if (!isConfigured) {
|
||||
throw IllegalStateException("Set VELOCITY_BEARER_TOKEN or VELOCITY_API_EMAIL/VELOCITY_API_PASSWORD in Gradle properties.")
|
||||
}
|
||||
val body = JSONObject()
|
||||
.put("email", BuildConfig.VELOCITY_API_EMAIL)
|
||||
.put("password", BuildConfig.VELOCITY_API_PASSWORD)
|
||||
val json = request("/api/auth/login", "POST", body.toString(), authenticated = false)
|
||||
json.optString("access_token").ifBlank {
|
||||
throw IllegalStateException("Velocity login did not return an access token.")
|
||||
}.also { cachedToken = it }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getJson(path: String): JSONObject {
|
||||
val token = getToken()
|
||||
return request(path, "GET", null, authenticated = true, token = token)
|
||||
}
|
||||
|
||||
private suspend fun request(
|
||||
path: String,
|
||||
method: String,
|
||||
body: String?,
|
||||
authenticated: Boolean,
|
||||
token: String? = null,
|
||||
): JSONObject = withContext(Dispatchers.IO) {
|
||||
val connection = (URL("$baseUrl$path").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = method
|
||||
connectTimeout = 20_000
|
||||
readTimeout = 20_000
|
||||
setRequestProperty("Accept", "application/json")
|
||||
if (body != null) {
|
||||
doOutput = true
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
}
|
||||
if (authenticated) {
|
||||
setRequestProperty("Authorization", "Bearer ${token.orEmpty()}")
|
||||
}
|
||||
}
|
||||
|
||||
body?.let { payload ->
|
||||
OutputStreamWriter(connection.outputStream).use { writer ->
|
||||
writer.write(payload)
|
||||
}
|
||||
}
|
||||
|
||||
val status = connection.responseCode
|
||||
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
|
||||
val text = stream?.bufferedReader()?.use(BufferedReader::readText).orEmpty()
|
||||
|
||||
if (status !in 200..299) {
|
||||
val detail = runCatching { JSONObject(text).optString("detail") }.getOrNull().orEmpty()
|
||||
throw IllegalStateException(detail.ifBlank { "Velocity request failed with HTTP $status." })
|
||||
}
|
||||
|
||||
JSONObject(text.ifBlank { "{}" })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,50 @@
|
||||
package com.desineuron.velocity.tablet.features
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import com.desineuron.velocity.tablet.data.TabletDashboardSnapshot
|
||||
import com.desineuron.velocity.tablet.data.VelocityBackend
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen() {
|
||||
FeatureScaffold(
|
||||
val state by produceState<Result<TabletDashboardSnapshot>?>(initialValue = null) {
|
||||
value = runCatching { VelocityBackend.fetchDashboardSnapshot() }
|
||||
}
|
||||
|
||||
SurfaceScaffold(
|
||||
title = "Dashboard",
|
||||
subtitle = "Sales, sentiment, and operational posture for the field team.",
|
||||
chips = listOf("Visitors live", "Revenue outlook", "Queue health"),
|
||||
)
|
||||
subtitle = "Live tablet posture for leads, inventory, and operator scheduling.",
|
||||
) {
|
||||
when (val result = state) {
|
||||
null -> LoadingCard("Fetching live leads, alerts, properties, and calendar records.")
|
||||
else -> {
|
||||
val snapshot = result.getOrNull()
|
||||
if (snapshot != null) {
|
||||
MetricRow(
|
||||
Triple("Leads", snapshot.leads.size.toString(), androidx.compose.ui.graphics.Color(0xFF38BDF8)),
|
||||
Triple("Whales", snapshot.leads.count { it.score >= 90 || it.qualification.equals("whale", true) }.toString(), androidx.compose.ui.graphics.Color(0xFF34D399)),
|
||||
Triple("Inventory", snapshot.properties.size.toString(), androidx.compose.ui.graphics.Color(0xFFF59E0B)),
|
||||
)
|
||||
DetailCard("Lead focus") {
|
||||
if (snapshot.leads.isEmpty()) {
|
||||
androidx.compose.material3.Text("No live leads are visible to this operator scope yet.", color = androidx.compose.ui.graphics.Color(0xFF94A3B8))
|
||||
} else {
|
||||
snapshot.leads.sortedByDescending { it.score }.take(5).forEach { lead ->
|
||||
androidx.compose.material3.Text("${lead.name} · ${lead.kanbanStatus} · ${lead.score}", color = androidx.compose.ui.graphics.Color.White)
|
||||
androidx.compose.material3.Text("${lead.unitInterest} · ${lead.budget}", color = androidx.compose.ui.graphics.Color(0xFF94A3B8))
|
||||
}
|
||||
}
|
||||
}
|
||||
DetailCard("Operator urgency") {
|
||||
androidx.compose.material3.Text("Pending insights: ${snapshot.alerts.pendingInsights}", color = androidx.compose.ui.graphics.Color.White)
|
||||
androidx.compose.material3.Text("Pending transcriptions: ${snapshot.alerts.pendingTranscriptions}", color = androidx.compose.ui.graphics.Color.White)
|
||||
androidx.compose.material3.Text("Upcoming 24h calendar events: ${snapshot.alerts.upcomingCalendarEvents24h}", color = androidx.compose.ui.graphics.Color.White)
|
||||
}
|
||||
} else {
|
||||
ErrorCard(result.exceptionOrNull()?.message ?: "Unable to reach the Velocity backend.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,28 +2,30 @@ package com.desineuron.velocity.tablet.features
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FeatureScaffold(
|
||||
fun SurfaceScaffold(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
chips: List<String>,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF05070B))
|
||||
.padding(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
@@ -32,33 +34,70 @@ fun FeatureScaffold(
|
||||
Text(title, style = MaterialTheme.typography.headlineMedium, color = Color.White)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8))
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
chips.forEach { chip ->
|
||||
Text(
|
||||
text = chip,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
@Composable
|
||||
fun MetricRow(vararg items: Triple<String, String, Color>) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items.forEach { (title, value, color) ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(Color(0xFF0B1220), RoundedCornerShape(24.dp))
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(title.uppercase(), style = MaterialTheme.typography.labelSmall, color = Color(0xFF94A3B8))
|
||||
Text(value, style = MaterialTheme.typography.headlineSmall, color = Color.White)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFF111827), RoundedCornerShape(14.dp))
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
.background(color, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 24.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF0B1220), RoundedCornerShape(28.dp))
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Scaffold state", style = MaterialTheme.typography.titleMedium, color = Color.White)
|
||||
Text(
|
||||
"This screen is wired into the tablet navigation graph and is ready for the shared contract package once the API clients are connected.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF94A3B8),
|
||||
)
|
||||
@Composable
|
||||
fun DetailCard(
|
||||
title: String,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFF0B1220), RoundedCornerShape(24.dp))
|
||||
.padding(18.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, color = Color.White)
|
||||
body()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LoadingCard(message: String) {
|
||||
DetailCard(title = "Loading") {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(color = Color(0xFF38BDF8))
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ErrorCard(message: String) {
|
||||
DetailCard(title = "Live backend error") {
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFFCA5A5))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyCard(message: String) {
|
||||
DetailCard(title = "No live data") {
|
||||
Text(message, style = MaterialTheme.typography.bodyMedium, color = Color(0xFF94A3B8))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
package com.desineuron.velocity.tablet.features
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.desineuron.velocity.tablet.data.VelocityBackend
|
||||
import com.desineuron.velocity.tablet.data.VelocityProperty
|
||||
|
||||
@Composable
|
||||
fun InventoryScreen() {
|
||||
FeatureScaffold(
|
||||
val state by produceState<Result<List<VelocityProperty>>?>(initialValue = null) {
|
||||
value = runCatching { VelocityBackend.fetchProperties() }
|
||||
}
|
||||
|
||||
SurfaceScaffold(
|
||||
title = "Inventory",
|
||||
subtitle = "Property catalog, media assets, and ingest lifecycle visibility.",
|
||||
chips = listOf("Import batches", "Listings", "Validation state"),
|
||||
)
|
||||
subtitle = "Live property catalog pulled from the inventory pipeline.",
|
||||
) {
|
||||
when (val result = state) {
|
||||
null -> LoadingCard("Fetching live inventory properties.")
|
||||
else -> {
|
||||
val properties = result.getOrNull()
|
||||
if (properties != null) {
|
||||
MetricRow(
|
||||
Triple("Properties", properties.size.toString(), Color(0xFF38BDF8)),
|
||||
Triple("Active", properties.count { it.status.equals("active", true) }.toString(), Color(0xFF34D399)),
|
||||
Triple("Draft", properties.count { it.status.equals("draft", true) }.toString(), Color(0xFFF59E0B)),
|
||||
)
|
||||
if (properties.isEmpty()) {
|
||||
EmptyCard("No inventory properties have been ingested for this tenant yet.")
|
||||
} else {
|
||||
DetailCard("Property list") {
|
||||
properties.take(8).forEach { property ->
|
||||
Text("${property.projectName} · ${property.propertyType}", color = Color.White)
|
||||
Text("${property.developerName} · ${property.locationSummary}", color = Color(0xFF94A3B8))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ErrorCard(result.exceptionOrNull()?.message ?: "Unable to fetch inventory properties.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,50 @@
|
||||
package com.desineuron.velocity.tablet.features
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.desineuron.velocity.tablet.data.VelocityBackend
|
||||
import com.desineuron.velocity.tablet.data.VelocityLead
|
||||
|
||||
@Composable
|
||||
fun OracleScreen() {
|
||||
FeatureScaffold(
|
||||
val state by produceState<Result<List<VelocityLead>>?>(initialValue = null) {
|
||||
value = runCatching { VelocityBackend.fetchLeads() }
|
||||
}
|
||||
|
||||
SurfaceScaffold(
|
||||
title = "Oracle",
|
||||
subtitle = "Template-guided intelligence views for pipeline and scheduling.",
|
||||
chips = listOf("Pipeline", "Lead map", "Calendar tasks"),
|
||||
)
|
||||
subtitle = "Pipeline intelligence rendered only from live CRM state.",
|
||||
) {
|
||||
when (val result = state) {
|
||||
null -> LoadingCard("Fetching live pipeline rows.")
|
||||
else -> {
|
||||
val leads = result.getOrNull()
|
||||
if (leads != null) {
|
||||
val grouped = leads.groupBy { it.kanbanStatus.ifBlank { "unclassified" } }
|
||||
MetricRow(
|
||||
Triple("Pipeline", leads.size.toString(), Color(0xFF38BDF8)),
|
||||
Triple("Whales", leads.count { it.qualification.equals("whale", true) }.toString(), Color(0xFF34D399)),
|
||||
Triple("Stages", grouped.keys.size.toString(), Color(0xFFA78BFA)),
|
||||
)
|
||||
if (leads.isEmpty()) {
|
||||
EmptyCard("No live leads are available for Oracle on this surface.")
|
||||
} else {
|
||||
grouped.toSortedMap().forEach { (stage, items) ->
|
||||
DetailCard(stage.replace('_', ' ').replaceFirstChar { it.uppercase() }) {
|
||||
items.sortedByDescending { it.score }.take(5).forEach { lead ->
|
||||
Text("${lead.name} · ${lead.score}", color = Color.White)
|
||||
Text("${lead.qualification} · ${lead.unitInterest} · ${lead.budget}", color = Color(0xFF94A3B8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ErrorCard(result.exceptionOrNull()?.message ?: "Unable to fetch Oracle pipeline data.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
package com.desineuron.velocity.tablet.features
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.desineuron.velocity.tablet.data.VelocityAlertSnapshot
|
||||
import com.desineuron.velocity.tablet.data.VelocityBackend
|
||||
|
||||
@Composable
|
||||
fun SentinelScreen() {
|
||||
FeatureScaffold(
|
||||
val state by produceState<Result<VelocityAlertSnapshot>?>(initialValue = null) {
|
||||
value = runCatching { VelocityBackend.fetchAlerts() }
|
||||
}
|
||||
|
||||
SurfaceScaffold(
|
||||
title = "Sentinel",
|
||||
subtitle = "Biometric and sentiment awareness stream for visitor sessions.",
|
||||
chips = listOf("Live session", "Journey river", "QD overlays"),
|
||||
)
|
||||
subtitle = "Truthful live urgency view with visitor analytics intentionally disabled until a real feed exists.",
|
||||
) {
|
||||
when (val result = state) {
|
||||
null -> LoadingCard("Fetching live alert posture.")
|
||||
else -> {
|
||||
val alerts = result.getOrNull()
|
||||
if (alerts != null) {
|
||||
MetricRow(
|
||||
Triple("Insights", alerts.pendingInsights.toString(), Color(0xFFFB7185)),
|
||||
Triple("Transcripts", alerts.pendingTranscriptions.toString(), Color(0xFFF59E0B)),
|
||||
Triple("24h calendar", alerts.upcomingCalendarEvents24h.toString(), Color(0xFF34D399)),
|
||||
)
|
||||
DetailCard("Sentinel availability") {
|
||||
Text(
|
||||
"This Android tablet build does not generate synthetic visitor analytics. A production Sentinel stream is still required before biometrics or sentiment metrics can be shown safely.",
|
||||
color = Color(0xFF94A3B8),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ErrorCard(result.exceptionOrNull()?.message ?: "Unable to fetch Sentinel alert posture.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
package com.desineuron.velocity.tablet.features
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.desineuron.velocity.tablet.data.VelocityBackend
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen() {
|
||||
FeatureScaffold(
|
||||
SurfaceScaffold(
|
||||
title = "Settings",
|
||||
subtitle = "Surface registration, connection state, and operator preferences.",
|
||||
chips = listOf("Install info", "API endpoint", "Operator profile"),
|
||||
)
|
||||
subtitle = "Runtime configuration for the production tablet surface.",
|
||||
) {
|
||||
DetailCard("Connectivity") {
|
||||
Text("Backend: ${VelocityBackend.baseUrl}", color = Color.White)
|
||||
Text("Auth mode: ${VelocityBackend.authMode}", color = Color(0xFF94A3B8))
|
||||
Text(
|
||||
if (VelocityBackend.isConfigured) "Live credentials configured."
|
||||
else "Credentials missing. Configure Gradle properties before production use.",
|
||||
color = if (VelocityBackend.isConfigured) Color(0xFF34D399) else Color(0xFFF59E0B),
|
||||
)
|
||||
}
|
||||
DetailCard("Production notes") {
|
||||
Text(
|
||||
"This tablet build only renders live backend data. Surfaces without production routes stay intentionally unavailable instead of falling back to placeholders.",
|
||||
color = Color(0xFF94A3B8),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user