Survivor side (2/4): alert history + evidence export (report + GPX)
Build APK / build (push) Waiting to run
Build APK / build (push) Waiting to run
- Room 'alerts' table records each new ALERTING event (ecosystem, time, distinct places, peak RSSI, location). - History screen: log of past alerts + a one-tap Export that shares a timestamped text report and a GPX of every geotagged sighting (open in a maps app). Files written to cache and shared via FileProvider — no network. - History icon added to the header. Release 0.1.9. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0187UiEtasyowhEBYs6s9iF5
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "org.soulstone.vigil"
|
applicationId = "org.soulstone.vigil"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 9
|
versionCode = 10
|
||||||
versionName = "0.1.8"
|
versionName = "0.1.9"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -67,5 +67,16 @@
|
|||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="connectedDevice|location" />
|
android:foregroundServiceType="connectedDevice|location" />
|
||||||
|
|
||||||
|
<!-- Shares exported evidence files (report + GPX) with other apps. No network. -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -17,14 +17,17 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import org.soulstone.vigil.data.TrackerRepository
|
import org.soulstone.vigil.data.TrackerRepository
|
||||||
import org.soulstone.vigil.data.db.TrackerEntity
|
import org.soulstone.vigil.data.db.TrackerEntity
|
||||||
import org.soulstone.vigil.data.db.VigilDatabase
|
import org.soulstone.vigil.data.db.VigilDatabase
|
||||||
import org.soulstone.vigil.ring.TrackerRinger
|
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.HistoryScreen
|
||||||
import org.soulstone.vigil.ui.MainScreen
|
import org.soulstone.vigil.ui.MainScreen
|
||||||
import org.soulstone.vigil.ui.OnboardingScreen
|
import org.soulstone.vigil.ui.OnboardingScreen
|
||||||
import org.soulstone.vigil.ui.SafetyScreen
|
import org.soulstone.vigil.ui.SafetyScreen
|
||||||
@@ -74,12 +77,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
VigilTheme {
|
VigilTheme {
|
||||||
val onboarded by settings.onboarded.collectAsState()
|
val onboarded by settings.onboarded.collectAsState()
|
||||||
var showSafety by remember { mutableStateOf(false) }
|
var showSafety by remember { mutableStateOf(false) }
|
||||||
|
var showHistory by remember { mutableStateOf(false) }
|
||||||
when {
|
when {
|
||||||
!onboarded -> OnboardingScreen(onFinish = {
|
!onboarded -> OnboardingScreen(onFinish = {
|
||||||
settings.setOnboarded(true)
|
settings.setOnboarded(true)
|
||||||
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
|
if (!hasEssentialPermissions()) permissionLauncher.launch(requiredPermissions)
|
||||||
})
|
})
|
||||||
showSafety -> SafetyScreen(onBack = { showSafety = false })
|
showSafety -> SafetyScreen(onBack = { showSafety = false })
|
||||||
|
showHistory -> {
|
||||||
|
val alerts by repo.observeAlerts().collectAsState(initial = emptyList())
|
||||||
|
HistoryScreen(
|
||||||
|
alerts = alerts,
|
||||||
|
onExport = { exportEvidence() },
|
||||||
|
onClear = { lifecycleScope.launch { repo.clearAlerts() } },
|
||||||
|
onBack = { showHistory = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {
|
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())
|
||||||
@@ -110,7 +123,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
},
|
},
|
||||||
onRing = { tracker -> ringTracker(tracker) },
|
onRing = { tracker -> ringTracker(tracker) },
|
||||||
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
onClearAll = { lifecycleScope.launch { repo.clearAll() } },
|
||||||
onOpenSafety = { showSafety = true }
|
onOpenSafety = { showSafety = true },
|
||||||
|
onOpenHistory = { showHistory = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +149,34 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun exportEvidence() {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val report = repo.buildTextReport()
|
||||||
|
if (report == null) {
|
||||||
|
Toast.makeText(this@MainActivity, "No alerts to export yet.", Toast.LENGTH_SHORT).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
val dir = File(cacheDir, "exports").apply { mkdirs() }
|
||||||
|
val txt = File(dir, "vigil-report.txt").apply { writeText(report) }
|
||||||
|
val gpx = File(dir, "vigil-track.gpx").apply { writeText(repo.buildGpx()) }
|
||||||
|
val auth = "$packageName.fileprovider"
|
||||||
|
val uris = arrayListOf(
|
||||||
|
FileProvider.getUriForFile(this@MainActivity, auth, txt),
|
||||||
|
FileProvider.getUriForFile(this@MainActivity, auth, gpx)
|
||||||
|
)
|
||||||
|
val share = Intent(Intent.ACTION_SEND_MULTIPLE).apply {
|
||||||
|
type = "*/*"
|
||||||
|
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(share, "Export VIGIL evidence"))
|
||||||
|
}.onFailure {
|
||||||
|
Toast.makeText(this@MainActivity, "Export failed: ${it.message}", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
private fun openAppSettings() {
|
private fun openAppSettings() {
|
||||||
startActivity(
|
startActivity(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.soulstone.vigil.data
|
|||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import org.soulstone.vigil.data.db.AlertEntity
|
||||||
import org.soulstone.vigil.data.db.SightingEntity
|
import org.soulstone.vigil.data.db.SightingEntity
|
||||||
import org.soulstone.vigil.data.db.TrackerEntity
|
import org.soulstone.vigil.data.db.TrackerEntity
|
||||||
import org.soulstone.vigil.data.db.VigilDatabase
|
import org.soulstone.vigil.data.db.VigilDatabase
|
||||||
@@ -11,8 +12,13 @@ import org.soulstone.vigil.detect.CoMovementEvaluator
|
|||||||
import org.soulstone.vigil.model.RiskState
|
import org.soulstone.vigil.model.RiskState
|
||||||
import org.soulstone.vigil.model.SeparatedState
|
import org.soulstone.vigil.model.SeparatedState
|
||||||
import org.soulstone.vigil.model.Sensitivity
|
import org.soulstone.vigil.model.Sensitivity
|
||||||
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
import org.soulstone.vigil.model.TrackerObservation
|
import org.soulstone.vigil.model.TrackerObservation
|
||||||
import org.soulstone.vigil.util.Geohash
|
import org.soulstone.vigil.util.Geohash
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The temporal core. Persists sightings, maintains the learned baseline, and runs
|
* The temporal core. Persists sightings, maintains the learned baseline, and runs
|
||||||
@@ -133,10 +139,91 @@ class TrackerRepository(private val db: VigilDatabase) {
|
|||||||
lastMac = obs.mac
|
lastMac = obs.mac
|
||||||
)
|
)
|
||||||
db.trackerDao().upsert(updated)
|
db.trackerDao().upsert(updated)
|
||||||
|
if (newlyAlerting) {
|
||||||
|
db.alertDao().insert(
|
||||||
|
AlertEntity(
|
||||||
|
trackerId = obs.stableId,
|
||||||
|
ecosystem = obs.ecosystem.name,
|
||||||
|
label = obs.label,
|
||||||
|
timestamp = now,
|
||||||
|
distinctPlaces = assessment.distinctPlaces,
|
||||||
|
peakRssi = updated.peakRssi,
|
||||||
|
lat = lat, lon = lon
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
RecordResult(updated, newlyAlerting)
|
RecordResult(updated, newlyAlerting)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun observeAlerts(): Flow<List<AlertEntity>> = db.alertDao().observeAll()
|
||||||
|
|
||||||
|
suspend fun clearAlerts() = db.alertDao().clear()
|
||||||
|
|
||||||
|
/** Human-readable evidence report; null if there are no alerts yet. */
|
||||||
|
suspend fun buildTextReport(): String? {
|
||||||
|
val alerts = db.alertDao().all()
|
||||||
|
if (alerts.isEmpty()) return null
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US)
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.appendLine("VIGIL — tracker evidence report")
|
||||||
|
sb.appendLine("Generated: ${fmt.format(Date())}")
|
||||||
|
sb.appendLine("Times are this phone's local time; locations are approximate (phone GPS).")
|
||||||
|
sb.appendLine("=".repeat(52))
|
||||||
|
for ((trackerId, group) in alerts.groupBy { it.trackerId }) {
|
||||||
|
val head = group.first()
|
||||||
|
sb.appendLine()
|
||||||
|
sb.appendLine("${ecoLabel(head.ecosystem)} — ${head.label}")
|
||||||
|
sb.appendLine("Identity: $trackerId")
|
||||||
|
sb.appendLine("Flagged ${group.size} time(s):")
|
||||||
|
for (a in group) {
|
||||||
|
val where = if (a.lat != null && a.lon != null) "%.5f, %.5f".format(a.lat, a.lon) else "no location"
|
||||||
|
sb.appendLine(" - ${fmt.format(Date(a.timestamp))} : ${a.distinctPlaces} places, peak ${a.peakRssi} dBm, at $where")
|
||||||
|
}
|
||||||
|
val geo = db.sightingDao().allFor(trackerId).filter { it.lat != null && it.lon != null }
|
||||||
|
if (geo.isNotEmpty()) {
|
||||||
|
sb.appendLine(" Seen with you at ${geo.size} location(s):")
|
||||||
|
for (s in geo) {
|
||||||
|
sb.appendLine(" ${fmt.format(Date(s.timestamp))} ${"%.5f, %.5f".format(s.lat, s.lon)} ${s.rssi} dBm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.appendLine()
|
||||||
|
sb.appendLine("=".repeat(52))
|
||||||
|
sb.appendLine("Recorded by VIGIL, an offline anti-tracking app — a log of Bluetooth trackers detected moving with this phone.")
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPX of every geotagged sighting, to open in a maps app. */
|
||||||
|
suspend fun buildGpx(): String {
|
||||||
|
val fmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
val sb = StringBuilder()
|
||||||
|
sb.appendLine("""<?xml version="1.0" encoding="UTF-8"?>""")
|
||||||
|
sb.appendLine("""<gpx version="1.1" creator="VIGIL" xmlns="http://www.topografix.com/GPX/1/1">""")
|
||||||
|
for (s in db.sightingDao().allGeotagged()) {
|
||||||
|
sb.appendLine(""" <wpt lat="${s.lat}" lon="${s.lon}">""")
|
||||||
|
sb.appendLine(" <time>${fmt.format(Date(s.timestamp))}</time>")
|
||||||
|
sb.appendLine(" <name>${trackerShortName(s.trackerId)} ${s.rssi}dBm</name>")
|
||||||
|
sb.appendLine(" </wpt>")
|
||||||
|
}
|
||||||
|
sb.appendLine("</gpx>")
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val RETENTION_DAYS = 14
|
const val RETENTION_DAYS = 14
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun ecoLabel(name: String): String =
|
||||||
|
runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||||
|
|
||||||
|
private fun trackerShortName(stableId: String): String = when (stableId.substringBefore(':')) {
|
||||||
|
"apple" -> "AppleFindMy"
|
||||||
|
"fmdn" -> "GoogleFMD"
|
||||||
|
"samsung" -> "SmartTag"
|
||||||
|
"tile" -> "Tile"
|
||||||
|
"dult" -> "DULT"
|
||||||
|
else -> "Tracker"
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ data class PlaceEntity(
|
|||||||
val anchor: Boolean = false
|
val anchor: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A recorded alert — when a tracker first crossed into ALERTING. Kept for the
|
||||||
|
* history list and the evidence export. */
|
||||||
|
@Entity(tableName = "alerts")
|
||||||
|
data class AlertEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||||
|
val trackerId: String,
|
||||||
|
val ecosystem: String,
|
||||||
|
val label: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
val distinctPlaces: Int,
|
||||||
|
val peakRssi: Int,
|
||||||
|
val lat: Double? = null,
|
||||||
|
val lon: Double? = null
|
||||||
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface TrackerDao {
|
interface TrackerDao {
|
||||||
@Query("SELECT * FROM trackers WHERE stableId = :id")
|
@Query("SELECT * FROM trackers WHERE stableId = :id")
|
||||||
@@ -104,6 +119,12 @@ interface SightingDao {
|
|||||||
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
|
@Query("SELECT * FROM sightings WHERE trackerId = :id AND timestamp >= :since ORDER BY timestamp")
|
||||||
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
|
suspend fun recentFor(id: String, since: Long): List<SightingEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sightings WHERE trackerId = :id ORDER BY timestamp")
|
||||||
|
suspend fun allFor(id: String): List<SightingEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM sightings WHERE lat IS NOT NULL ORDER BY timestamp")
|
||||||
|
suspend fun allGeotagged(): List<SightingEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
@Query("DELETE FROM sightings WHERE timestamp < :cutoff")
|
||||||
suspend fun prune(cutoff: Long)
|
suspend fun prune(cutoff: Long)
|
||||||
|
|
||||||
@@ -120,15 +141,31 @@ interface PlaceDao {
|
|||||||
suspend fun upsert(place: PlaceEntity)
|
suspend fun upsert(place: PlaceEntity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface AlertDao {
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(alert: AlertEntity)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||||
|
fun observeAll(): Flow<List<AlertEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM alerts ORDER BY timestamp DESC")
|
||||||
|
suspend fun all(): List<AlertEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM alerts")
|
||||||
|
suspend fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class],
|
entities = [TrackerEntity::class, SightingEntity::class, PlaceEntity::class, AlertEntity::class],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class VigilDatabase : RoomDatabase() {
|
abstract class VigilDatabase : RoomDatabase() {
|
||||||
abstract fun trackerDao(): TrackerDao
|
abstract fun trackerDao(): TrackerDao
|
||||||
abstract fun sightingDao(): SightingDao
|
abstract fun sightingDao(): SightingDao
|
||||||
abstract fun placeDao(): PlaceDao
|
abstract fun placeDao(): PlaceDao
|
||||||
|
abstract fun alertDao(): AlertDao
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@Volatile private var INSTANCE: VigilDatabase? = null
|
@Volatile private var INSTANCE: VigilDatabase? = null
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package org.soulstone.vigil.ui
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.soulstone.vigil.data.db.AlertEntity
|
||||||
|
import org.soulstone.vigil.model.TrackerEcosystem
|
||||||
|
|
||||||
|
/** Log of past alerts, with a one-tap evidence export (report + GPX). */
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(
|
||||||
|
alerts: List<AlertEntity>,
|
||||||
|
onExport: () -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onBack: () -> Unit
|
||||||
|
) {
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, "Back") }
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text("Alert history", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onExport, modifier = Modifier.weight(1f)) {
|
||||||
|
Icon(Icons.Filled.Share, null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(6.dp))
|
||||||
|
Text("Export evidence")
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = onClear, enabled = alerts.isNotEmpty()) { Text("Clear") }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
"Export saves a timestamped report and a GPX track (open it in a maps app). Files stay on your phone until you share them.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
if (alerts.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No alerts yet. When a tracker is confirmed following you, it'll be logged here.",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(alerts, key = { it.id }) { a -> AlertRow(a) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AlertRow(a: AlertEntity) {
|
||||||
|
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)) {
|
||||||
|
Column(Modifier.padding(14.dp)) {
|
||||||
|
Text(ecoName(a.ecosystem), fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
"${a.distinctPlaces} places · peak ${a.peakRssi} dBm · ${rel(a.timestamp)}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ecoName(name: String) = runCatching { TrackerEcosystem.valueOf(name).display }.getOrDefault(name)
|
||||||
|
private fun rel(ts: Long) = DateUtils.getRelativeTimeSpanString(ts, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS).toString()
|
||||||
@@ -33,6 +33,7 @@ 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.HealthAndSafety
|
||||||
|
import androidx.compose.material.icons.filled.History
|
||||||
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
|
||||||
@@ -92,7 +93,8 @@ fun MainScreen(
|
|||||||
onDistrust: (String) -> Unit,
|
onDistrust: (String) -> Unit,
|
||||||
onRing: (TrackerEntity) -> Unit,
|
onRing: (TrackerEntity) -> Unit,
|
||||||
onClearAll: () -> Unit,
|
onClearAll: () -> Unit,
|
||||||
onOpenSafety: () -> Unit
|
onOpenSafety: () -> Unit,
|
||||||
|
onOpenHistory: () -> 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) }
|
||||||
@@ -121,6 +123,13 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(onClick = onOpenHistory) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.History,
|
||||||
|
contentDescription = "Alert history",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
IconButton(onClick = onOpenSafety) {
|
IconButton(onClick = onOpenSafety) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.HealthAndSafety,
|
Icons.Filled.HealthAndSafety,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="exports" path="exports/" />
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user