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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
Kara Zajac
2026-07-15 00:20:01 -04:00
parent 817a4ac1c9
commit 6a3c39d16d
2 changed files with 324 additions and 75 deletions
@@ -1,31 +1,60 @@
package org.soulstone.vigil.ui package org.soulstone.vigil.ui
import android.text.format.DateUtils 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Text
import androidx.compose.material3.TextButton 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.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp 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.data.db.TrackerEntity
import org.soulstone.vigil.model.Sensitivity import org.soulstone.vigil.model.Sensitivity
import org.soulstone.vigil.model.TrackerEcosystem 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.VigilPeach
import org.soulstone.vigil.ui.theme.VigilRed import org.soulstone.vigil.ui.theme.VigilRed
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
running: Boolean, running: Boolean,
@@ -44,98 +74,231 @@ fun MainScreen(
onSetSensitivity: (Sensitivity) -> Unit, onSetSensitivity: (Sensitivity) -> Unit,
onApprove: (String, Boolean) -> Unit onApprove: (String, Boolean) -> Unit
) { ) {
val alerting = trackers.count { statusOf(it) == TrackerStatus.ALERTING } var detail by remember { mutableStateOf<TrackerEntity?>(null) }
val suspicious = trackers.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { val active = trackers.filter { !isTrusted(it) }.sortedByDescending { riskRank(it) }
Column(Modifier.fillMaxSize().padding(16.dp)) { val trusted = trackers.filter { isTrusted(it) }
Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold) val alerting = active.count { statusOf(it) == TrackerStatus.ALERTING }
val suspicious = active.count { statusOf(it) == TrackerStatus.SUSPICIOUS }
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( Text(
"what's been following you", "on-device",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
titleContentColor = MaterialTheme.colorScheme.onBackground
)
)
}
) { 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 {
item {
Text(it, style = MaterialTheme.typography.bodySmall, color = VigilPeach)
}
}
item { SensitivitySelector(sensitivity, onSetSensitivity) }
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, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(Modifier.height(16.dp)) }
} else {
items(active, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
}
}
StatusBanner(running, alerting, suspicious) if (trusted.isNotEmpty()) {
Spacer(Modifier.height(16.dp)) item { SectionHeader("Trusted (${trusted.size})") }
items(trusted, key = { it.stableId }) { t ->
TrackerCard(t, onClick = { detail = t }, onApprove = onApprove)
}
}
Button( item {
onClick = onStartStop, Text(
modifier = Modifier.fillMaxWidth(), "VIGIL ${BuildConfig.VERSION_NAME} · no network, no accounts, no data leaves this phone",
colors = ButtonDefaults.buttonColors( style = MaterialTheme.typography.labelSmall,
containerColor = if (running) MaterialTheme.colorScheme.surfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant,
else MaterialTheme.colorScheme.primary modifier = Modifier.padding(vertical = 16.dp)
) )
) {
Text(if (running) "STOP WATCHING" else "START WATCHING")
} }
permissionMessage?.let { }
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodySmall, color = VigilPeach)
} }
Spacer(Modifier.height(20.dp)) detail?.let { t ->
Text("Sensitivity", style = MaterialTheme.typography.labelLarge) val sheetState = rememberModalBottomSheetState()
Spacer(Modifier.height(6.dp)) ModalBottomSheet(
onDismissRequest = { detail = null },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
TrackerDetail(t, onApprove = { id, a -> onApprove(id, a); detail = null })
}
}
}
@Composable
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(),
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)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Sensitivity.entries.forEach { s -> Sensitivity.entries.forEach { s ->
FilterChip( FilterChip(
selected = s == sensitivity, selected = s == current,
onClick = { onSetSensitivity(s) }, onClick = { onSet(s) },
label = { Text(s.name.lowercase().replaceFirstChar { it.uppercase() }) } label = { Text(s.name.lowercase().replaceFirstChar { it.uppercase() }) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primary,
selectedLabelColor = MaterialTheme.colorScheme.onPrimary
)
) )
} }
} }
Spacer(Modifier.height(4.dp))
Spacer(Modifier.height(20.dp))
Text("Trackers seen (${trackers.size})", style = MaterialTheme.typography.labelLarge)
Spacer(Modifier.height(6.dp))
if (trackers.isEmpty()) {
Text( Text(
if (running) "Listening… nothing near you yet." else "Not scanning.", when (current) {
style = MaterialTheme.typography.bodyMedium, 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 color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(trackers, key = { it.stableId }) { t -> TrackerRow(t, onApprove) }
}
}
}
} }
} }
@Composable @Composable
private fun StatusBanner(running: Boolean, alerting: Int, suspicious: Int) { private fun SectionHeader(text: String) {
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
}
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = color)
) {
Text( Text(
label, text.uppercase(),
Modifier.padding(16.dp), style = MaterialTheme.typography.labelMedium,
color = Color(0xFF11111B), fontWeight = FontWeight.Bold,
style = MaterialTheme.typography.titleMedium, letterSpacing = 1.sp,
fontWeight = FontWeight.Bold color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp)
) )
}
} }
@Composable @Composable
private fun TrackerRow(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) { private fun TrackerCard(t: TrackerEntity, onClick: () -> Unit, onApprove: (String, Boolean) -> Unit) {
val status = statusOf(t) val status = statusOf(t)
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) { val accent = statusColor(status)
Row( Card(
Modifier.fillMaxWidth().padding(12.dp), Modifier.fillMaxWidth().clickable(onClick = onClick),
verticalAlignment = Alignment.CenterVertically 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)) { Column(Modifier.weight(1f)) {
Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold) Text(ecosystemDisplay(t.ecosystem), fontWeight = FontWeight.SemiBold)
Text( Text(
@@ -144,15 +307,79 @@ private fun TrackerRow(t: TrackerEntity, onApprove: (String, Boolean) -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
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) { if (status == TrackerStatus.SAFE_APPROVED) {
TextButton(onClick = { onApprove(t.stableId, false) }) { Text("Un-approve") } Button(onClick = { onApprove(t.stableId, false) }, modifier = Modifier.fillMaxWidth()) {
Text("Remove from approved")
}
} else if (status != TrackerStatus.SAFE_BASELINE) { } else if (status != TrackerStatus.SAFE_BASELINE) {
TextButton(onClick = { onApprove(t.stableId, true) }) { Text("This is mine") } 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 { private fun statusOf(t: TrackerEntity): TrackerStatus = when {
t.approved -> TrackerStatus.SAFE_APPROVED t.approved -> TrackerStatus.SAFE_APPROVED
t.baselineSafe -> TrackerStatus.SAFE_BASELINE 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) { private fun statusLabel(s: TrackerStatus): String = when (s) {
TrackerStatus.SAFE_APPROVED -> "Approved (yours)" TrackerStatus.SAFE_APPROVED -> "Approved (yours)"
TrackerStatus.SAFE_BASELINE -> "Known (home)" TrackerStatus.SAFE_BASELINE -> "Known (around home)"
TrackerStatus.ALERTING -> "Following you" TrackerStatus.ALERTING -> "Following you"
TrackerStatus.SUSPICIOUS -> "Watching" 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 = private fun ecosystemDisplay(name: String): String =
+2 -2
View File
@@ -2,9 +2,9 @@
agp = "8.7.3" agp = "8.7.3"
kotlin = "2.0.21" kotlin = "2.0.21"
ksp = "2.0.21-1.0.28" ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1" coreKtx = "1.15.0"
lifecycle = "2.8.7" lifecycle = "2.8.7"
activityCompose = "1.9.3" activityCompose = "1.10.1"
composeBom = "2024.12.01" composeBom = "2024.12.01"
material3 = "1.3.1" material3 = "1.3.1"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"