Add OVERWATCH v0.1.0 — full detection engine + polish

Phase 1 (BLE), Phase 2 (WiFi BSSID/SSID), Phase 3 (DeFlock map proximity),
Phase 4 (Waze live POLICE alerts), and Phase 5 polish all wired through one
DetectionStore. Confidence engine scores 0-100; UI maps to 4-tier circle.

Polish:
  - Stylized two-line app title: [DЯΣΛMMΛKΣЯ] // 0VΣЯW4TCH
  - Modal bottom sheet for source drill-down (tap circle)
  - Settings screen: per-source toggles, proximity sliders, theme select
  - SharedPreferences-backed Settings with StateFlow exposure
  - DetectionService respects per-source toggles at start time
  - Scanners read proximity overrides via supplier lambdas

README documents all sources, architecture, build steps, permissions, and
the legal disclaimer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KaraZajac
2026-04-28 21:10:57 -04:00
parent 1e195605df
commit 87b1f0b887
45 changed files with 3283 additions and 0 deletions
@@ -0,0 +1,336 @@
package org.soulstone.overwatch.ui
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
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.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
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.rememberCoroutineScope
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.soulstone.overwatch.fusion.DetectionEvent
import org.soulstone.overwatch.fusion.DetectionSource
import org.soulstone.overwatch.fusion.ThreatLevel
import org.soulstone.overwatch.ui.theme.ThreatColors
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
running: Boolean,
threat: ThreatLevel,
score: Int,
events: List<DetectionEvent>,
onStartStop: () -> Unit,
onOpenSettings: () -> Unit,
canStart: Boolean,
permissionMessage: String?
) {
var showSheet by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetScope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 24.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = "[DЯΣΛMMΛKΣЯ]",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Text(
text = " . //0VΣЯW4TCH",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
}
IconButton(onClick = onOpenSettings) {
Icon(
Icons.Filled.Settings,
contentDescription = "Settings",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(Modifier.height(40.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
ThreatCircle(level = threat, animating = running, onTap = { showSheet = true })
Spacer(Modifier.height(12.dp))
Text(
text = "tap circle for source details",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
Spacer(Modifier.height(24.dp))
StatusText(running = running, threat = threat, score = score, events = events)
Spacer(Modifier.height(24.dp))
Button(
onClick = onStartStop,
enabled = canStart,
colors = ButtonDefaults.buttonColors(
containerColor = if (running) ThreatColors.Red else ThreatColors.Green,
contentColor = Color.White
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
) {
Text(
text = if (running) "STOP" else "START",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
if (permissionMessage != null) {
Spacer(Modifier.height(12.dp))
Text(
text = permissionMessage,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp
)
}
}
}
if (showSheet) {
ModalBottomSheet(
onDismissRequest = { showSheet = false },
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.background
) {
Column(modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)) {
Text(
text = "Detection sources",
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(Modifier.height(8.dp))
SourcesPanel(events = events)
Spacer(Modifier.height(16.dp))
}
}
}
}
@Composable
private fun ThreatCircle(level: ThreatLevel, animating: Boolean, onTap: () -> Unit) {
val color = when (level) {
ThreatLevel.GREEN -> ThreatColors.Green
ThreatLevel.YELLOW -> ThreatColors.Yellow
ThreatLevel.ORANGE -> ThreatColors.Orange
ThreatLevel.RED -> ThreatColors.Red
}
val transition = rememberInfiniteTransition(label = "pulse")
val pulse by transition.animateFloat(
initialValue = if (animating) 0.6f else 1.0f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200),
repeatMode = RepeatMode.Reverse
),
label = "pulse"
)
val alpha = if (animating) pulse else 1.0f
Box(
modifier = Modifier
.size(220.dp)
.clip(CircleShape)
.background(
Brush.radialGradient(
colors = listOf(
color.copy(alpha = alpha),
color.copy(alpha = alpha * 0.6f)
)
)
)
.clickable(onClick = onTap),
contentAlignment = Alignment.Center
) {
Text(
text = level.name,
color = Color.White,
fontSize = 28.sp,
fontWeight = FontWeight.Black,
fontFamily = FontFamily.Monospace
)
}
}
@Composable
private fun StatusText(
running: Boolean,
threat: ThreatLevel,
score: Int,
events: List<DetectionEvent>
) {
val text = when {
!running -> "Idle — press START to begin scanning"
events.isEmpty() -> "All clear"
threat == ThreatLevel.GREEN -> "Scanning… (${events.size} weak signals)"
else -> {
val top = events.first()
"${top.label}${top.score}"
}
}
Text(
text = text,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace
)
if (running) {
Spacer(Modifier.height(4.dp))
Text(
text = "Max score: $score",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp
)
}
}
@Composable
private fun SourcesPanel(events: List<DetectionEvent>) {
Column(modifier = Modifier.fillMaxWidth()) {
DetectionSource.values().forEach { source ->
val sourceEvents = events.filter { it.source == source }
SourceRow(source = source, events = sourceEvents)
Spacer(Modifier.height(8.dp))
}
}
}
@Composable
private fun SourceRow(source: DetectionSource, events: List<DetectionEvent>) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
shape = RoundedCornerShape(8.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = source.name,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
val maxScore = events.maxOfOrNull { it.score } ?: 0
val statusColor = when {
maxScore >= ThreatLevel.RED.minScore -> ThreatColors.Red
maxScore >= ThreatLevel.ORANGE.minScore -> ThreatColors.Orange
maxScore >= ThreatLevel.YELLOW.minScore -> ThreatColors.Yellow
else -> ThreatColors.Green
}
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(statusColor)
)
}
if (events.isEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
text = "no detections",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp
)
} else {
Spacer(Modifier.height(4.dp))
events.take(3).forEach { e ->
Text(
text = "${e.score}${e.label}${e.matchedMethods}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace
)
}
if (events.size > 3) {
Text(
text = "+${events.size - 3} more",
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 11.sp
)
}
}
}
}
}
@@ -0,0 +1,201 @@
package org.soulstone.overwatch.ui
import androidx.compose.foundation.background
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.soulstone.overwatch.data.settings.Settings
@Composable
fun SettingsScreen(
settings: Settings,
onBack: () -> Unit
) {
val ble by settings.bleEnabled.collectAsState()
val wifi by settings.wifiEnabled.collectAsState()
val deflock by settings.deflockEnabled.collectAsState()
val waze by settings.wazeEnabled.collectAsState()
val deflockProx by settings.deflockProximityM.collectAsState()
val wazeProx by settings.wazeProximityM.collectAsState()
val theme by settings.themeMode.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text(
text = "Settings",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onBackground
)
}
Spacer(Modifier.height(8.dp))
SectionLabel("Detection sources")
SourceToggle("BLE • Bluetooth Low Energy", ble) { settings.setBleEnabled(it) }
SourceToggle("WIFI • WiFi BSSID + SSID", wifi) { settings.setWifiEnabled(it) }
SourceToggle("DEFLOCK • ALPR map (cdn.deflock.me)", deflock) { settings.setDeflockEnabled(it) }
SourceToggle("WAZE • Live police reports", waze) { settings.setWazeEnabled(it) }
Spacer(Modifier.height(8.dp))
Text(
"Source toggles take effect on next Start.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
SectionLabel("Proximity thresholds")
SliderRow(
label = "DeFlock alert distance",
valueLabel = "${deflockProx} m",
value = deflockProx.toFloat(),
range = 50f..1600f,
steps = 30,
onChange = { settings.setDeflockProximityM(it.toInt()) }
)
SliderRow(
label = "Waze alert distance",
valueLabel = "${wazeProx} m",
value = wazeProx.toFloat(),
range = 100f..5000f,
steps = 48,
onChange = { settings.setWazeProximityM(it.toInt()) }
)
Spacer(Modifier.height(16.dp))
SectionLabel("Appearance")
ThemeRadio("System default", theme == Settings.ThemeMode.SYSTEM) {
settings.setThemeMode(Settings.ThemeMode.SYSTEM)
}
ThemeRadio("Dark", theme == Settings.ThemeMode.DARK) {
settings.setThemeMode(Settings.ThemeMode.DARK)
}
ThemeRadio("Light", theme == Settings.ThemeMode.LIGHT) {
settings.setThemeMode(Settings.ThemeMode.LIGHT)
}
Spacer(Modifier.height(24.dp))
}
}
@Composable
private fun SectionLabel(text: String) {
Text(
text = text.uppercase(),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(vertical = 8.dp)
)
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
}
@Composable
private fun SourceToggle(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Switch(checked = value, onCheckedChange = onChange)
}
}
@Composable
private fun SliderRow(
label: String,
valueLabel: String,
value: Float,
range: ClosedFloatingPointRange<Float>,
steps: Int,
onChange: (Float) -> Unit
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Text(
text = valueLabel,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
Slider(
value = value,
onValueChange = onChange,
valueRange = range,
steps = steps
)
}
}
@Composable
private fun ThemeRadio(label: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(selected = selected, onClick = onClick)
Text(
text = label,
color = MaterialTheme.colorScheme.onBackground,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
}
}
@@ -0,0 +1,52 @@
package org.soulstone.overwatch.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import org.soulstone.overwatch.data.settings.Settings
private val DarkColors = darkColorScheme(
primary = Color(0xFF1FAA59),
onPrimary = Color.White,
background = Color(0xFF0B0E12),
onBackground = Color(0xFFF4F6FA),
surface = Color(0xFF161A21),
onSurface = Color(0xFFF4F6FA),
surfaceVariant = Color(0xFF1E232C),
onSurfaceVariant = Color(0xFF9AA3B2)
)
private val LightColors = lightColorScheme(
primary = Color(0xFF1FAA59),
onPrimary = Color.White,
background = Color(0xFFF4F6FA),
onBackground = Color(0xFF0B0E12),
surface = Color.White,
onSurface = Color(0xFF0B0E12)
)
object ThreatColors {
val Green = Color(0xFF1FAA59)
val Yellow = Color(0xFFF4C20D)
val Orange = Color(0xFFF26B0F)
val Red = Color(0xFFD7263D)
}
@Composable
fun OverwatchTheme(
mode: Settings.ThemeMode = Settings.ThemeMode.DARK,
content: @Composable () -> Unit
) {
val dark = when (mode) {
Settings.ThemeMode.DARK -> true
Settings.ThemeMode.LIGHT -> false
Settings.ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
MaterialTheme(
colorScheme = if (dark) DarkColors else LightColors,
content = content
)
}