From 6a3c39d16d4fd0e91cb098c68f2fffd1ee1f5f81 Mon Sep 17 00:00:00 2001 From: Kara Zajac Date: Wed, 15 Jul 2026 00:20:01 -0400 Subject: [PATCH] Polish UI + freshen core deps - Rewrote MainScreen: top bar, prominent status hero (idle/clear/watching/alert), active vs trusted sections, per-tracker cards with status icons, and a detail bottom sheet with co-movement evidence + trust action. - Sensitivity selector with per-mode explanation; on-device/no-network footer. - core-ktx 1.13.1->1.15.0, activity-compose 1.9.3->1.10.1 (kept the proven Kotlin/AGP/Compose matrix for build stability). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5 --- .../org/soulstone/vigil/ui/MainScreen.kt | 395 ++++++++++++++---- gradle/libs.versions.toml | 4 +- 2 files changed, 324 insertions(+), 75 deletions(-) diff --git a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt index 5d8b33e..69fd700 100644 --- a/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt +++ b/app/src/main/kotlin/org/soulstone/vigil/ui/MainScreen.kt @@ -1,31 +1,60 @@ package org.soulstone.vigil.ui import android.text.format.DateUtils +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.GppMaybe +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Radar +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +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.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.soulstone.vigil.BuildConfig import org.soulstone.vigil.data.db.TrackerEntity import org.soulstone.vigil.model.Sensitivity import org.soulstone.vigil.model.TrackerEcosystem @@ -34,6 +63,7 @@ import org.soulstone.vigil.ui.theme.VigilGreen import org.soulstone.vigil.ui.theme.VigilPeach import org.soulstone.vigil.ui.theme.VigilRed +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( running: Boolean, @@ -44,98 +74,231 @@ fun MainScreen( onSetSensitivity: (Sensitivity) -> Unit, onApprove: (String, Boolean) -> Unit ) { - val alerting = trackers.count { statusOf(it) == TrackerStatus.ALERTING } - val suspicious = trackers.count { statusOf(it) == TrackerStatus.SUSPICIOUS } + var detail by remember { mutableStateOf(null) } - Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { - Column(Modifier.fillMaxSize().padding(16.dp)) { - Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) - Text( - "what's been following you", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(Modifier.height(16.dp)) + val active = trackers.filter { !isTrusted(it) }.sortedByDescending { riskRank(it) } + val trusted = trackers.filter { isTrusted(it) } + val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING } + val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS } - StatusBanner(running, alerting, suspicious) - Spacer(Modifier.height(16.dp)) - - Button( - onClick = onStartStop, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors( - containerColor = if (running) MaterialTheme.colorScheme.surfaceVariant - else MaterialTheme.colorScheme.primary + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Shield, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.size(8.dp)) + Text("VIGIL", fontWeight = FontWeight.Bold) + } + }, + actions = { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 12.dp)) { + Icon( + Icons.Filled.Lock, null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.size(4.dp)) + Text( + "on-device", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground ) - ) { - Text(if (running) "STOP WATCHING" else "START WATCHING") + ) + } + ) { pad -> + LazyColumn( + modifier = Modifier.fillMaxWidth().padding(pad).padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { Spacer(Modifier.height(4.dp)) } + item { StatusHero(running, alerting, suspicious) } + item { + Button( + onClick = onStartStop, + modifier = Modifier.fillMaxWidth().height(52.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (running) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.primary, + contentColor = if (running) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon(if (running) Icons.Filled.Radar else Icons.Filled.Shield, null) + Spacer(Modifier.size(8.dp)) + Text(if (running) "STOP WATCHING" else "START WATCHING", fontWeight = FontWeight.Bold) + } } permissionMessage?.let { - Spacer(Modifier.height(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = VigilPeach) + item { + Text(it, style = MaterialTheme.typography.bodySmall, color = VigilPeach) + } } + item { SensitivitySelector(sensitivity, onSetSensitivity) } - Spacer(Modifier.height(20.dp)) - Text("Sensitivity", style = MaterialTheme.typography.labelLarge) - Spacer(Modifier.height(6.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Sensitivity.entries.forEach { s -> - FilterChip( - selected = s == sensitivity, - onClick = { onSetSensitivity(s) }, - label = { Text(s.name.lowercase().replaceFirstChar { it.uppercase() }) } + item { + SectionHeader( + if (active.isEmpty()) "Nothing following you" else "Active (${active.size})" + ) + } + if (active.isEmpty()) { + item { + Text( + if (running) "Listening… no trackers are moving with you." + else "Not scanning. Tap Start Watching.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - - Spacer(Modifier.height(20.dp)) - Text("Trackers seen (${trackers.size})", style = MaterialTheme.typography.labelLarge) - Spacer(Modifier.height(6.dp)) - if (trackers.isEmpty()) { - Text( - if (running) "Listening… nothing near you yet." else "Not scanning.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items(trackers, key = { it.stableId }) { t -> TrackerRow(t, onApprove) } + items(active, key = { it.stableId }) { t -> + TrackerCard(t, onClick = { detail = t }, onApprove = onApprove) } } + + if (trusted.isNotEmpty()) { + item { SectionHeader("Trusted (${trusted.size})") } + items(trusted, key = { it.stableId }) { t -> + TrackerCard(t, onClick = { detail = t }, onApprove = onApprove) + } + } + + item { + Text( + "VIGIL ${BuildConfig.VERSION_NAME} · no network, no accounts, no data leaves this phone", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp) + ) + } + } + } + + detail?.let { t -> + val sheetState = rememberModalBottomSheetState() + ModalBottomSheet( + onDismissRequest = { detail = null }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface + ) { + TrackerDetail(t, onApprove = { id, a -> onApprove(id, a); detail = null }) } } } @Composable -private fun StatusBanner(running: Boolean, alerting: Int, suspicious: Int) { - val (label, color) = when { - !running -> "Idle" to MaterialTheme.colorScheme.surfaceVariant - alerting > 0 -> "$alerting tracker(s) following you" to VigilRed - suspicious > 0 -> "Watching $suspicious possible follower(s)" to VigilPeach - else -> "Clear — nothing is following you" to VigilGreen +private fun StatusHero(running: Boolean, alerting: Int, suspicious: Int) { + data class Look(val title: String, val subtitle: String, val icon: ImageVector, val color: Color) + + val look = when { + !running -> Look("Idle", "Tap Start Watching to begin.", Icons.Filled.Shield, MaterialTheme.colorScheme.surfaceVariant) + alerting > 0 -> Look( + if (alerting == 1) "A tracker is following you" else "$alerting trackers following you", + "Moving with you across places and time.", Icons.Filled.Warning, VigilRed + ) + suspicious > 0 -> Look( + "Keeping watch", + "$suspicious possible follower(s) — not yet confirmed.", Icons.Filled.GppMaybe, VigilPeach + ) + else -> Look("You're clear", "Nothing has been following you.", Icons.Filled.CheckCircle, VigilGreen) } + Card( Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = color) + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = look.color) ) { + Row( + Modifier.fillMaxWidth().padding(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + Modifier.size(56.dp).clip(CircleShape).background(Color(0x22111111)), + contentAlignment = Alignment.Center + ) { + Icon(look.icon, null, tint = Color(0xFF11111B), modifier = Modifier.size(32.dp)) + } + Spacer(Modifier.size(16.dp)) + Column { + Text( + look.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = Color(0xFF11111B) + ) + Text(look.subtitle, style = MaterialTheme.typography.bodyMedium, color = Color(0xCC11111B)) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SensitivitySelector(current: Sensitivity, onSet: (Sensitivity) -> Unit) { + Column { + SectionHeader("Sensitivity") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Sensitivity.entries.forEach { s -> + FilterChip( + selected = s == current, + onClick = { onSet(s) }, + label = { Text(s.name.lowercase().replaceFirstChar { it.uppercase() }) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary + ) + ) + } + } + Spacer(Modifier.height(4.dp)) Text( - label, - Modifier.padding(16.dp), - color = Color(0xFF11111B), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold + when (current) { + Sensitivity.HIGH -> "Alerts fastest — more early warnings, more false alarms." + Sensitivity.MEDIUM -> "Balanced — the recommended default." + Sensitivity.LOW -> "Alerts only on strong, sustained evidence." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } @Composable -private fun TrackerRow(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) { +private fun SectionHeader(text: String) { + Text( + text.uppercase(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp) + ) +} + +@Composable +private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (String, Boolean) -> Unit) { val status = statusOf(t) - Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { - Row( - Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { + val accent = statusColor(status) + Card( + Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(40.dp).clip(CircleShape).background(accent.copy(alpha = 0.18f)), + contentAlignment = Alignment.Center + ) { + Icon(statusIcon(status), null, tint = accent, modifier = Modifier.size(22.dp)) + } + Spacer(Modifier.size(12.dp)) Column(Modifier.weight(1f)) { Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold) Text( @@ -144,15 +307,79 @@ private fun TrackerRow(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) { color = MaterialTheme.colorScheme.onSurfaceVariant ) } - if (status == TrackerStatus.SAFE_APPROVED) { - TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Un-approve") } - } else if (status != TrackerStatus.SAFE_BASELINE) { - TextButton(onClick = { onApprove(t.stableId, true) }) { Text("This is mine") } + when (status) { + TrackerStatus.SAFE_APPROVED -> + TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Undo") } + TrackerStatus.SAFE_BASELINE -> Icon( + Icons.Filled.Verified, null, + tint = VigilGreen, modifier = Modifier.size(20.dp) + ) + else -> TextButton(onClick = { onApprove(t.stableId, true) }) { Text("It's mine") } } } } } +@Composable +private fun TrackerDetail(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) { + val status = statusOf(t) + Column(Modifier.fillMaxWidth().padding(24.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Bluetooth, null, tint = statusColor(status)) + Spacer(Modifier.size(10.dp)) + Text(ecosystemDisplay(t.ecosystem), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + Spacer(Modifier.height(4.dp)) + Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(16.dp)) + + DetailRow("Sightings", t.sightingCount.toString()) + DetailRow("First seen", relative(t.firstSeen)) + DetailRow("Last seen", relative(t.lastSeen)) + if (t.anchorDayCount > 0) { + DetailRow("Days seen at your places", "${t.anchorDayCount} (trusted at 3)") + } + DetailRow("Identity", t.stableId.take(22) + "…") + + Spacer(Modifier.height(16.dp)) + Text( + explanation(status), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(20.dp)) + if (status == TrackerStatus.SAFE_APPROVED) { + Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) { + Text("Remove from approved") + } + } else if (status != TrackerStatus.SAFE_BASELINE) { + Button(onClick = { onApprove(t.stableId, true) }, modifier = Modifier.fillMaxWidth()) { + Text("This is mine — stop alerting") + } + } + Spacer(Modifier.height(24.dp)) + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween) { + Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) + Text(value, fontWeight = FontWeight.Medium, style = MaterialTheme.typography.bodyMedium) + } +} + +// ---- pure helpers ---------------------------------------------------------- + +private fun isTrusted(t: TrackerEntity) = t.approved || t.baselineSafe + +private fun riskRank(t: TrackerEntity): Int = when (statusOf(t)) { + TrackerStatus.ALERTING -> 3 + TrackerStatus.SUSPICIOUS -> 2 + TrackerStatus.OBSERVED -> 1 + else -> 0 +} + private fun statusOf(t: TrackerEntity): TrackerStatus = when { t.approved -> TrackerStatus.SAFE_APPROVED t.baselineSafe -> TrackerStatus.SAFE_BASELINE @@ -163,12 +390,34 @@ private fun statusOf(t: TrackerEntity): TrackerStatus = when { } } +private fun statusColor(s: TrackerStatus): Color = when (s) { + TrackerStatus.ALERTING -> VigilRed + TrackerStatus.SUSPICIOUS -> VigilPeach + TrackerStatus.SAFE_APPROVED, TrackerStatus.SAFE_BASELINE -> VigilGreen + TrackerStatus.OBSERVED -> Color(0xFF89B4FA) +} + +private fun statusIcon(s: TrackerStatus): ImageVector = when (s) { + TrackerStatus.ALERTING -> Icons.Filled.Warning + TrackerStatus.SUSPICIOUS -> Icons.Filled.GppMaybe + TrackerStatus.SAFE_APPROVED, TrackerStatus.SAFE_BASELINE -> Icons.Filled.Verified + TrackerStatus.OBSERVED -> Icons.Filled.Bluetooth +} + private fun statusLabel(s: TrackerStatus): String = when (s) { TrackerStatus.SAFE_APPROVED -> "Approved (yours)" - TrackerStatus.SAFE_BASELINE -> "Known (home)" - TrackerStatus.ALERTING -> "⚠ Following you" + TrackerStatus.SAFE_BASELINE -> "Known (around home)" + TrackerStatus.ALERTING -> "Following you" TrackerStatus.SUSPICIOUS -> "Watching" - TrackerStatus.OBSERVED -> "Seen once" + TrackerStatus.OBSERVED -> "Seen nearby" +} + +private fun explanation(s: TrackerStatus): String = when (s) { + TrackerStatus.ALERTING -> "This tracker has been close to you across several distinct places over a sustained window — the signature of something travelling with you. If it isn't yours, treat it seriously." + TrackerStatus.SUSPICIOUS -> "Seen repeatedly and close, but not yet across enough places/time to confirm it's following you. VIGIL keeps watching." + TrackerStatus.OBSERVED -> "Seen nearby but with no co-movement pattern — most likely ambient." + TrackerStatus.SAFE_APPROVED -> "You marked this as yours, so VIGIL won't alert on it." + TrackerStatus.SAFE_BASELINE -> "Regularly around the places you live — learned as part of your normal environment, entirely on-device." } private fun ecosystemDisplay(name: String): String = diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 944983c..e88e191 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,9 +2,9 @@ agp = "8.7.3" kotlin = "2.0.21" ksp = "2.0.21-1.0.28" -coreKtx = "1.13.1" +coreKtx = "1.15.0" lifecycle = "2.8.7" -activityCompose = "1.9.3" +activityCompose = "1.10.1" composeBom = "2024.12.01" material3 = "1.3.1" playServicesLocation = "21.3.0"