package com.seteclabs.cellguard.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.seteclabs.cellguard.data.AreaRow import com.seteclabs.cellguard.data.SurveyDb import com.seteclabs.cellguard.data.TowerRow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import java.text.SimpleDateFormat import java.util.Date import java.util.Locale private val stamp = SimpleDateFormat("MMM d, HH:mm", Locale.US) /** * The location-tagged survey log: the 2.5-mile areas the device has passed * through, each expandable to the towers seen there. Reads [SurveyDb] directly * (the detector service writes it) and refreshes on a short tick. */ @Composable fun SurveyScreen() { val ctx = LocalContext.current val db = remember { SurveyDb(ctx) } var areas by remember { mutableStateOf>(emptyList()) } var expanded by remember { mutableStateOf(-1L) } var towers by remember { mutableStateOf>(emptyList()) } LaunchedEffect(Unit) { while (true) { areas = withContext(Dispatchers.IO) { runCatching { db.areas() }.getOrDefault(emptyList()) } if (expanded >= 0) { towers = withContext(Dispatchers.IO) { runCatching { db.towers(expanded) }.getOrDefault(emptyList()) } } delay(4_000) } } DisposableEffect(Unit) { onDispose { runCatching { db.close() } } } if (areas.isEmpty()) { Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { Text( "No survey data yet.", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 13.sp, ) Spacer(Modifier.height(6.dp)) Text( "Towers are logged with your GPS position as you move. Grant location and drive around to build the map.", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 11.sp, ) } return } LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { item { Text( "SURVEY LOG · ${areas.size} area(s)", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 12.sp, ) } items(areas, key = { it.id }) { a -> AreaCard( area = a, isOpen = a.id == expanded, towers = if (a.id == expanded) towers else emptyList(), onClick = { expanded = if (expanded == a.id) -1L else a.id }, ) } item { Spacer(Modifier.height(8.dp)) } } } @Composable private fun AreaCard(area: AreaRow, isOpen: Boolean, towers: List, onClick: () -> Unit) { Card( modifier = Modifier.fillMaxWidth().clickable { onClick() }, shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), ) { Column(Modifier.padding(14.dp)) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Text( "AREA #${area.id}", color = MaterialTheme.colorScheme.onSurface, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 13.sp, ) Spacer(Modifier.weight(1f)) Text( "${area.towerCount} tower(s)", color = CgCyan, fontFamily = FontFamily.Monospace, fontSize = 12.sp, ) } Spacer(Modifier.height(4.dp)) Text( "%.5f, %.5f".format(area.lat, area.lon), color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 11.sp, ) Text( "${stamp.format(Date(area.firstMs))} → ${stamp.format(Date(area.lastMs))}", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 11.sp, ) if (isOpen) { Spacer(Modifier.height(8.dp)) HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) Spacer(Modifier.height(8.dp)) if (towers.isEmpty()) { Text( "loading…", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 11.sp, ) } else { towers.forEach { TowerLogRow(it) } } } } } } @Composable private fun TowerLogRow(t: TowerRow) { val label = t.op.ifBlank { (t.mcc + t.mnc).ifBlank { if (t.ci >= 0) t.ci.toString() else "—" } } Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { RatChip(t.rat, t.registered) Spacer(Modifier.width(8.dp)) Text( label, color = MaterialTheme.colorScheme.onSurface, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 12.sp, ) Spacer(Modifier.weight(1f)) Text( t.rsrp?.let { "$it dBm" } ?: "—", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 11.sp, ) } Text( "CI ${dashL(t.ci)} · PCI ${dashI(t.pci)} · TAC ${dashI(t.tac)} · EARFCN ${dashI(t.earfcn)} · seen ${t.count}×", color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, fontSize = 10.sp, ) } } @Composable private fun RatChip(rat: String, registered: Boolean) { val c = when (rat.uppercase()) { "LTE" -> CgGreen "NR", "5G" -> CgCyan "WCDMA", "UMTS", "TDSCDMA" -> CgAmber "GSM", "CDMA", "2G" -> CgRed else -> CgGray } Text( if (registered) "$rat•" else rat, color = Color.Black, modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(c).padding(horizontal = 6.dp, vertical = 1.dp), fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 10.sp, ) } private fun dashL(v: Long): String = if (v >= 0) v.toString() else "—" private fun dashI(v: Int): String = if (v >= 0) v.toString() else "—"