Compare commits
6 Commits
6e5b565e5e
...
v0.1.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 24a2dc83e5 | |||
| 1c0a6f216d | |||
| a71a83891a | |||
| b72faea8ce | |||
| ee13e10173 | |||
| 51cd474573 |
@@ -28,6 +28,18 @@ that idea.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/main.png" width="230" alt="VIGIL watch list — whether anything has been following you" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/finder-searching.png" width="230" alt="Hot/cold finder searching for a tracker" />
|
||||||
|
|
||||||
|
<img src="docs/screenshots/finder-close.png" width="230" alt="Finder homing in on a device, with Make it ring" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><sub>The watch list · the hot/cold finder searching · homing in on a tracker, with “Make it ring”</sub></p>
|
||||||
|
|
||||||
## Privacy first — fully offline
|
## Privacy first — fully offline
|
||||||
|
|
||||||
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
|
VIGIL requests **no `INTERNET` permission at all.** There is no server, no account,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "org.soulstone.vigil"
|
applicationId = "org.soulstone.vigil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 8
|
versionCode = 9
|
||||||
versionName = "0.1.7"
|
versionName = "0.1.8"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
// Fixed debug keystore committed to the repo (a debug key is non-secret — its
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.activity.result.contract.ActivityResultContracts
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -24,6 +26,8 @@ import org.soulstone.vigil.ring.TrackerRinger
|
|||||||
import org.soulstone.vigil.data.settings.Settings
|
import org.soulstone.vigil.data.settings.Settings
|
||||||
import org.soulstone.vigil.service.ScanService
|
import org.soulstone.vigil.service.ScanService
|
||||||
import org.soulstone.vigil.ui.MainScreen
|
import org.soulstone.vigil.ui.MainScreen
|
||||||
|
import org.soulstone.vigil.ui.OnboardingScreen
|
||||||
|
import org.soulstone.vigil.ui.SafetyScreen
|
||||||
import org.soulstone.vigil.ui.theme.VigilTheme
|
import org.soulstone.vigil.ui.theme.VigilTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -68,6 +72,15 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
VigilTheme {
|
VigilTheme {
|
||||||
|
val onboarded by settings.onboarded.collectAsState()
|
||||||
|
var showSafety by remember { mutableStateOf(false) }
|
||||||
|
when {
|
||||||
|
!onboarded -> OnboardingScreen(onFinish = {
|
||||||
|
settings.setOnboarded(true)
|
||||||
|
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
|
||||||
|
})
|
||||||
|
showSafety -> SafetyScreen(onBack = { showSafety = false })
|
||||||
|
else -> {
|
||||||
val running by ScanService.running.collectAsState()
|
val running by ScanService.running.collectAsState()
|
||||||
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
|
val trackers by repo.observeTrackers().collectAsState(initial = emptyList())
|
||||||
val sensitivity by settings.sensitivity.collectAsState()
|
val sensitivity by settings.sensitivity.collectAsState()
|
||||||
@@ -96,11 +109,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
lifecycleScope.launch { repo.clearBaseline(id) }
|
lifecycleScope.launch { repo.clearBaseline(id) }
|
||||||
},
|
},
|
||||||
onRing = { tracker -> ringTracker(tracker) },
|
onRing = { tracker -> ringTracker(tracker) },
|
||||||
onClearAll = { lifecycleScope.launch { repo.clearAll() } }
|
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
||||||
|
onOpenSafety = { showSafety = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
|||||||
@@ -25,8 +25,16 @@ class Settings private constructor(context: Context) {
|
|||||||
_sensitivity.value = value
|
_sensitivity.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val _onboarded = MutableStateFlow(prefs.getBoolean(KEY_ONBOARDED, false))
|
||||||
|
val onboarded: StateFlow<Boolean> = _onboarded.asStateFlow()
|
||||||
|
fun setOnboarded(value: Boolean) {
|
||||||
|
prefs.edit().putBoolean(KEY_ONBOARDED, value).apply()
|
||||||
|
_onboarded.value = value
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SENSITIVITY = "sensitivity"
|
private const val KEY_SENSITIVITY = "sensitivity"
|
||||||
|
private const val KEY_ONBOARDED = "onboarded"
|
||||||
|
|
||||||
@Volatile private var INSTANCE: Settings? = null
|
@Volatile private var INSTANCE: Settings? = null
|
||||||
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
|
fun get(context: Context): Settings = INSTANCE ?: synchronized(this) {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import androidx.compose.material.icons.filled.Bluetooth
|
|||||||
import androidx.compose.material.icons.filled.CheckCircle
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
import androidx.compose.material.icons.filled.DeleteSweep
|
import androidx.compose.material.icons.filled.DeleteSweep
|
||||||
import androidx.compose.material.icons.filled.GppMaybe
|
import androidx.compose.material.icons.filled.GppMaybe
|
||||||
|
import androidx.compose.material.icons.filled.HealthAndSafety
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Radar
|
import androidx.compose.material.icons.filled.Radar
|
||||||
import androidx.compose.material.icons.filled.Shield
|
import androidx.compose.material.icons.filled.Shield
|
||||||
@@ -90,7 +91,8 @@ fun MainScreen(
|
|||||||
onApprove: (String, Boolean) -> Unit,
|
onApprove: (String, Boolean) -> Unit,
|
||||||
onDistrust: (String) -> Unit,
|
onDistrust: (String) -> Unit,
|
||||||
onRing: (TrackerEntity) -> Unit,
|
onRing: (TrackerEntity) -> Unit,
|
||||||
onClearAll: () -> Unit
|
onClearAll: () -> Unit,
|
||||||
|
onOpenSafety: () -> Unit
|
||||||
) {
|
) {
|
||||||
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
var detail by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
var finding by remember { mutableStateOf<TrackerEntity?>(null) }
|
||||||
@@ -119,6 +121,13 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenSafety) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.HealthAndSafety,
|
||||||
|
contentDescription = "Safety & help",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
if (trackers.isNotEmpty()) {
|
if (trackers.isNotEmpty()) {
|
||||||
IconButton(onClick = onClearAll) {
|
IconButton(onClick = onClearAll) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -228,7 +237,8 @@ fun MainScreen(
|
|||||||
onApprove = { id, a -> onApprove(id, a); detail = null },
|
onApprove = { id, a -> onApprove(id, a); detail = null },
|
||||||
onDistrust = { id -> onDistrust(id); detail = null },
|
onDistrust = { id -> onDistrust(id); detail = null },
|
||||||
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
onFind = { dev -> ScanService.setFinderTarget(dev.stableId); finding = dev; detail = null },
|
||||||
onRing = onRing
|
onRing = onRing,
|
||||||
|
onSafety = { onOpenSafety(); detail = null }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,7 +388,8 @@ private fun TrackerDetail(
|
|||||||
onApprove: (String, Boolean) -> Unit,
|
onApprove: (String, Boolean) -> Unit,
|
||||||
onDistrust: (String) -> Unit,
|
onDistrust: (String) -> Unit,
|
||||||
onFind: (TrackerEntity) -> Unit,
|
onFind: (TrackerEntity) -> Unit,
|
||||||
onRing: (TrackerEntity) -> Unit
|
onRing: (TrackerEntity) -> Unit,
|
||||||
|
onSafety: () -> Unit
|
||||||
) {
|
) {
|
||||||
val status = statusOf(t)
|
val status = statusOf(t)
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
@@ -389,6 +400,18 @@ private fun TrackerDetail(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
|
Text(statusLabel(status), color = statusColor(status), fontWeight = FontWeight.SemiBold)
|
||||||
|
if (status == TrackerStatus.ALERTING || status == TrackerStatus.SUSPICIOUS) {
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onSafety,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.HealthAndSafety, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text("What should I do?", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
|
DetailRow("Signal (last / peak)", "${t.lastRssi} / ${t.peakRssi} dBm")
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Sensors
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
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.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.soulstone.vigil.R
|
||||||
|
|
||||||
|
/** First-run: what VIGIL does, its offline promise, and why it needs its permissions. */
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScreen(onFinish: () -> Unit) {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Image(painterResource(R.drawable.vigil_logo), null, modifier = Modifier.size(76.dp))
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Text("VIGIL", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
"Know what's been following you.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(36.dp))
|
||||||
|
|
||||||
|
Point(
|
||||||
|
Icons.Filled.Sensors,
|
||||||
|
"Finds what follows you",
|
||||||
|
"Detects AirTags, Tile, Samsung SmartTags and Find My trackers that travel with you over time — not just whatever happens to be nearby."
|
||||||
|
)
|
||||||
|
Point(
|
||||||
|
Icons.Filled.Lock,
|
||||||
|
"Stays on your phone",
|
||||||
|
"No account, no internet, no telemetry. Everything VIGIL learns lives on this device and never leaves it."
|
||||||
|
)
|
||||||
|
Point(
|
||||||
|
Icons.Filled.LocationOn,
|
||||||
|
"Why it needs permissions",
|
||||||
|
"Bluetooth to hear trackers, location to tell one is moving with you across places, and notifications to warn you. That's the whole reason it asks."
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(onClick = onFinish, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||||
|
Text("Get started", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Point(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||||
|
Icon(icon, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(26.dp).padding(top = 2.dp))
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
Text(title, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.OpenInNew
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.soulstone.vigil.ui.theme.VigilRed
|
||||||
|
|
||||||
|
/** Survivor-centred guidance shown when a tracker may be following you. Every
|
||||||
|
* action hands off to the dialer / messages / browser — VIGIL stays offline. */
|
||||||
|
@Composable
|
||||||
|
fun SafetyScreen(onBack: () -> Unit) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
val ctx = LocalContext.current
|
||||||
|
fun go(intent: Intent) = runCatching { ctx.startActivity(intent) }
|
||||||
|
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text("If a tracker is following you", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"VIGIL is a detection tool, not a substitute for professional help. If you're in immediate danger, call emergency services.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Step(1, "Get somewhere safe", "If you feel in danger, head to a public place or someone you trust before doing anything else.")
|
||||||
|
Step(2, "Think before you remove it", "Turning a tracker off can alert whoever placed it and escalate the risk. If you might be in danger, consider documenting it and getting help first.")
|
||||||
|
Step(3, "Document what you can", "Save when and where it was seen (use Export), and photograph the device if you find it. A dated record helps police and advocates.")
|
||||||
|
Step(4, "Report it", "Contact local police — 911 if you're in immediate danger. If this involves a partner or ex, a domestic-violence advocate can help you make a safety plan.")
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Text("Get help", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:911"))) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(50.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = VigilRed, contentColor = Color(0xFF11111B))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Call, null); Spacer(Modifier.size(8.dp)); Text("Call 911 (emergency)", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Text("U.S. National Domestic Violence Hotline", fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("Free, confidential, 24/7 — help with stalking and abuse, including safety planning.",
|
||||||
|
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { go(Intent(Intent.ACTION_DIAL, Uri.parse("tel:18007997233"))) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Call, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Call")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { go(Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:88788")).putExtra("sms_body", "START")) }, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Chat, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(6.dp)); Text("Text START")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { go(Intent(Intent.ACTION_VIEW, Uri.parse("https://www.stalkingawareness.org/help-for-victims/"))) },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.OpenInNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.size(8.dp)); Text("Stalking help & resources (SPARC)")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"These open your phone, messages, or browser — VIGIL itself never connects to the internet. Resources shown are U.S.; check what's available where you are.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Step(n: Int, title: String, body: String) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 9.dp)) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(26.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { Text("$n", color = MaterialTheme.colorScheme.onPrimary, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.labelMedium) }
|
||||||
|
Spacer(Modifier.size(14.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(title, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(body, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -21,7 +21,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css?v=2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
<a href="#detects">What it detects</a>
|
<a href="#detects">What it detects</a>
|
||||||
<a href="#decides">How it decides</a>
|
<a href="#decides">How it decides</a>
|
||||||
|
<a href="#screens">Screens</a>
|
||||||
<a href="#clones">Clones</a>
|
<a href="#clones">Clones</a>
|
||||||
<a href="#download">Download</a>
|
<a href="#download">Download</a>
|
||||||
<a class="nav-github" href="https://github.com/KaraZajac/VIGIL" aria-label="GitHub">
|
<a class="nav-github" href="https://github.com/KaraZajac/VIGIL" aria-label="GitHub">
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="phone-hero">
|
<div class="phone-hero">
|
||||||
<img class="phone" src="img/vigil-main.png" width="1096" height="2560"
|
<img class="phone" src="img/vigil-main.png?v=2" width="1096" height="2280"
|
||||||
alt="VIGIL main screen: a green "You're clear — nothing has been following you" card, a Stop Watching button, and a High/Medium/Low sensitivity selector." loading="eager">
|
alt="VIGIL main screen: a green "You're clear — nothing has been following you" card, a Stop Watching button, and a High/Medium/Low sensitivity selector." loading="eager">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,6 +224,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ──────────────── SCREENSHOTS ──────────────── -->
|
||||||
|
<section id="screens" class="section reveal">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-tag">screenshots</span>
|
||||||
|
<h2>Spot it, then walk it down.</h2>
|
||||||
|
<p class="lead">No account, no map tiles, no cloud — it all runs on the
|
||||||
|
phone. See what's moving with you, wave off your own gear, and when
|
||||||
|
something's left over, home in on it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shots">
|
||||||
|
<figure class="shot">
|
||||||
|
<img class="phone" src="img/vigil-active.png" width="1096" height="2560"
|
||||||
|
alt="VIGIL active list: an ACTIVE (6) list of nearby Apple Find My and Google Find My Device trackers, each showing places seen, signal in dBm, time since last seen, and an It's-mine button." loading="lazy">
|
||||||
|
<figcaption><strong>The active list</strong>Every tracker seen moving with you — signal, places, and how long ago. Tap “It's mine” and your own gear drops out.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img class="phone" src="img/vigil-locate.png" width="1096" height="2560"
|
||||||
|
alt="VIGIL locate screen for an Apple Find My tracker: a large pulsing radar icon reading Searching… walk around, no signal yet, with a Make-it-ring button." loading="lazy">
|
||||||
|
<figcaption><strong>Locate mode</strong>Pick a suspect and walk — the ripple strengthens as you close in. No signal yet? Keep moving.</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="shot">
|
||||||
|
<img class="phone" src="img/vigil-signal.png" width="1096" height="2560"
|
||||||
|
alt="VIGIL locate screen for a Google Find My Device tracker reading Close, minus 52 dBm, with a Make-it-ring button and a Ringing… listen for the tracker toast." loading="lazy">
|
||||||
|
<figcaption><strong>Getting warmer</strong>Live signal strength as you approach — −52 dBm is close. “Make it ring” forces a silent tracker to give itself up.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ──────────────── CLONES ──────────────── -->
|
<!-- ──────────────── CLONES ──────────────── -->
|
||||||
<section id="clones" class="section section-feature reveal">
|
<section id="clones" class="section section-feature reveal">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 213 KiB |
@@ -1089,3 +1089,35 @@ pre.code .prompt { color: var(--accent-hot); user-select: none; }
|
|||||||
/* VIGIL: logo mark in nav/footer (img instead of a glyph) */
|
/* VIGIL: logo mark in nav/footer (img instead of a glyph) */
|
||||||
.nav-logo { display: inline-block; vertical-align: middle; border-radius: 7px; }
|
.nav-logo { display: inline-block; vertical-align: middle; border-radius: 7px; }
|
||||||
.nav-brand .nav-logo { margin-right: 3px; }
|
.nav-brand .nav-logo { margin-right: 3px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SCREENSHOTS GALLERY
|
||||||
|
============================================================ */
|
||||||
|
.shots {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 2.6rem;
|
||||||
|
margin-top: 2.8rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.shots .shot { margin: 0; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.shots .phone { width: 260px; max-width: 100%; }
|
||||||
|
.shots figcaption {
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
max-width: 260px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.shots figcaption strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.shots { grid-template-columns: 1fr; gap: 3rem; }
|
||||||
|
.shots .phone { width: 256px; }
|
||||||
|
}
|
||||||
|
|||||||