feat: wigle wardriving UI — account linking and stats
This commit is contained in:
parent
cec594cd96
commit
8c1e284db4
4 changed files with 269 additions and 3 deletions
|
|
@ -672,4 +672,37 @@ class BeeApiClient(
|
||||||
return@withContext ApiResult.Error("Exception: ${e.message}")
|
return@withContext ApiResult.Error("Exception: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WiGLE Wardriving API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun getWigleStatus(): ApiResult<com.adamaps.varroa.data.WigleStatus> = withContext(Dispatchers.IO) {
|
||||||
|
when (val r = getRaw("/api/1/wigle/status")) {
|
||||||
|
is ApiResult.Success -> try {
|
||||||
|
ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WigleStatus::class.java))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error("Parse error: ${e.message}")
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getWigleStats(): ApiResult<com.adamaps.varroa.data.WigleStats> = withContext(Dispatchers.IO) {
|
||||||
|
when (val r = getRaw("/api/1/wigle/stats")) {
|
||||||
|
is ApiResult.Success -> try {
|
||||||
|
ApiResult.Success(gson.fromJson(r.data, com.adamaps.varroa.data.WigleStats::class.java))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ApiResult.Error("Parse error: ${e.message}")
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setWigleConfig(enabled: Boolean, apiName: String, apiToken: String): ApiResult<String> {
|
||||||
|
val json = gson.toJson(mapOf(
|
||||||
|
"enabled" to enabled,
|
||||||
|
"api_name" to apiName,
|
||||||
|
"api_token" to apiToken
|
||||||
|
))
|
||||||
|
return postRaw("/api/1/wigle/config", json)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,24 @@ data class FrameKmTotal(
|
||||||
@SerializedName("count") val count: Int? = null
|
@SerializedName("count") val count: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── WiGLE Wardriving ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
data class WigleStatus(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
@SerializedName("api_name") val apiName: String = "",
|
||||||
|
@SerializedName("total_networks") val totalNetworks: Int = 0,
|
||||||
|
@SerializedName("pending_upload") val pendingUpload: Int = 0,
|
||||||
|
@SerializedName("last_scan") val lastScan: Long? = null,
|
||||||
|
@SerializedName("last_upload") val lastUpload: Long? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WigleStats(
|
||||||
|
@SerializedName("total_networks") val totalNetworks: Int = 0,
|
||||||
|
@SerializedName("uploaded_networks") val uploadedNetworks: Int = 0,
|
||||||
|
@SerializedName("pending_upload") val pendingUpload: Int = 0,
|
||||||
|
@SerializedName("scans_today") val scansToday: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
// ── App state ─────────────────────────────────────────────────────────────────
|
// ── App state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class SessionStats(
|
data class SessionStats(
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,9 @@ import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
import androidx.compose.material.icons.filled.Wifi
|
import androidx.compose.material.icons.filled.Wifi
|
||||||
import androidx.compose.material.icons.filled.Terminal
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.filled.NetworkCheck
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.adamaps.varroa.data.VarroaSettings
|
import com.adamaps.varroa.data.VarroaSettings
|
||||||
import com.adamaps.varroa.ui.theme.*
|
import com.adamaps.varroa.ui.theme.*
|
||||||
|
|
@ -65,6 +68,14 @@ fun SettingsScreen(
|
||||||
val sshStatus by vm.sshStatus.collectAsState()
|
val sshStatus by vm.sshStatus.collectAsState()
|
||||||
val wifiStatus by vm.wifiStatus.collectAsState()
|
val wifiStatus by vm.wifiStatus.collectAsState()
|
||||||
val wifiConnectResult by vm.wifiConnectResult.collectAsState()
|
val wifiConnectResult by vm.wifiConnectResult.collectAsState()
|
||||||
|
val wigleStatus by vm.wigleStatus.collectAsState()
|
||||||
|
val wigleStats by vm.wigleStats.collectAsState()
|
||||||
|
val wigleConfigResult by vm.wigleConfigResult.collectAsState()
|
||||||
|
|
||||||
|
// WiGLE config input state
|
||||||
|
var wigleEnabled by remember(wigleStatus) { mutableStateOf(wigleStatus?.enabled ?: false) }
|
||||||
|
var wigleApiName by remember { mutableStateOf("") }
|
||||||
|
var wigleApiToken by remember { mutableStateOf("") }
|
||||||
|
|
||||||
// WiFi config input state
|
// WiFi config input state
|
||||||
var homeWifiSsid by remember { mutableStateOf("") }
|
var homeWifiSsid by remember { mutableStateOf("") }
|
||||||
|
|
@ -296,6 +307,132 @@ fun SettingsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WiGLE Wardriving ──────────────────────────────────────────────
|
||||||
|
SettingsSection("WIGLE WARDRIVING") {
|
||||||
|
if (!isPaired) {
|
||||||
|
Text("Pair device first to configure WiGLE",
|
||||||
|
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
} else {
|
||||||
|
// Enable toggle
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.NetworkCheck, contentDescription = null,
|
||||||
|
tint = if (wigleStatus?.enabled == true) Amber else Color.Gray,
|
||||||
|
modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text("WiFi Scanning",
|
||||||
|
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 12.sp)
|
||||||
|
Text("Scan networks & upload to WiGLE",
|
||||||
|
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = wigleEnabled,
|
||||||
|
onCheckedChange = { wigleEnabled = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedThumbColor = Background, checkedTrackColor = Amber)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// API credentials
|
||||||
|
Text("WiGLE Account", color = Amber, fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold, fontSize = 10.sp, letterSpacing = 1.sp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (wigleStatus?.apiName?.isNotBlank() == true) {
|
||||||
|
Text("Configured: ${wigleStatus?.apiName}",
|
||||||
|
color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wigleApiName,
|
||||||
|
onValueChange = { wigleApiName = it },
|
||||||
|
label = { Text("API Name", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
||||||
|
placeholder = { Text("From wigle.net → Account → API", color = Color.Gray, fontSize = 10.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = Amber, unfocusedBorderColor = SurfaceVariant,
|
||||||
|
focusedLabelColor = Amber, unfocusedLabelColor = Color.Gray,
|
||||||
|
cursorColor = Amber, focusedTextColor = OnSurface, unfocusedTextColor = OnSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = wigleApiToken,
|
||||||
|
onValueChange = { wigleApiToken = it },
|
||||||
|
label = { Text("API Token", fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
||||||
|
placeholder = { Text("●●●●●●●●●●●●", color = Color.Gray, fontSize = 10.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = Amber, unfocusedBorderColor = SurfaceVariant,
|
||||||
|
focusedLabelColor = Amber, unfocusedLabelColor = Color.Gray,
|
||||||
|
cursorColor = Amber, focusedTextColor = OnSurface, unfocusedTextColor = OnSurface
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
wigleConfigResult?.let {
|
||||||
|
Text(it, color = if (it.startsWith("Failed")) Color.Red else Amber,
|
||||||
|
fontFamily = FontFamily.Monospace, fontSize = 11.sp)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { vm.setWigleConfig(wigleEnabled, wigleApiName, wigleApiToken) },
|
||||||
|
enabled = wigleApiName.isNotBlank() && wigleApiToken.isNotBlank(),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Amber, contentColor = Background),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text("Save WiGLE Config", fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats (when enabled)
|
||||||
|
if (wigleStatus?.enabled == true) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
HorizontalDivider(color = SurfaceVariant)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text("Statistics", color = Amber, fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Bold, fontSize = 10.sp, letterSpacing = 1.sp)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Networks Found", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
Text("${wigleStats?.totalNetworks ?: wigleStatus?.totalNetworks ?: 0}",
|
||||||
|
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Pending Upload", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
Text("${wigleStats?.pendingUpload ?: wigleStatus?.pendingUpload ?: 0}",
|
||||||
|
color = Color.White, fontFamily = FontFamily.Monospace, fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
wigleStatus?.lastScan?.let { ts ->
|
||||||
|
val ago = formatTimeAgo(ts)
|
||||||
|
Text("Last scan: $ago", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
wigleStatus?.lastUpload?.let { ts ->
|
||||||
|
val ago = formatTimeAgo(ts)
|
||||||
|
Text("Last upload: $ago", color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 10.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SettingsSection("ADAMAPS") {
|
SettingsSection("ADAMAPS") {
|
||||||
SettingsField(
|
SettingsField(
|
||||||
label = "ADAMaps API URL",
|
label = "ADAMaps API URL",
|
||||||
|
|
@ -384,7 +521,8 @@ private fun SettingsField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
hint: String = "",
|
hint: String = "",
|
||||||
numeric: Boolean = false
|
numeric: Boolean = false,
|
||||||
|
keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text
|
||||||
) {
|
) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = value,
|
value = value,
|
||||||
|
|
@ -392,8 +530,7 @@ private fun SettingsField(
|
||||||
label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) },
|
||||||
placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
|
placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number)
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||||
else KeyboardOptions.Default,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
focusedBorderColor = Amber,
|
focusedBorderColor = Amber,
|
||||||
|
|
@ -576,3 +713,14 @@ private fun WalletLinkingSection(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun formatTimeAgo(timestampSeconds: Long): String {
|
||||||
|
val now = System.currentTimeMillis() / 1000
|
||||||
|
val diff = now - timestampSeconds
|
||||||
|
return when {
|
||||||
|
diff < 60 -> "just now"
|
||||||
|
diff < 3600 -> "${diff / 60} minutes ago"
|
||||||
|
diff < 86400 -> "${diff / 3600} hours ago"
|
||||||
|
else -> "${diff / 86400} days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import com.adamaps.varroa.data.ApiResult
|
||||||
import com.adamaps.varroa.data.SettingsDataStore
|
import com.adamaps.varroa.data.SettingsDataStore
|
||||||
import com.adamaps.varroa.data.SshStatus
|
import com.adamaps.varroa.data.SshStatus
|
||||||
import com.adamaps.varroa.data.VarroaSettings
|
import com.adamaps.varroa.data.VarroaSettings
|
||||||
|
import com.adamaps.varroa.data.WigleStatus
|
||||||
|
import com.adamaps.varroa.data.WigleStats
|
||||||
import com.adamaps.varroa.data.WifiStatus
|
import com.adamaps.varroa.data.WifiStatus
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
|
@ -64,6 +66,16 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private val _wifiConnectResult = MutableStateFlow<String?>(null)
|
private val _wifiConnectResult = MutableStateFlow<String?>(null)
|
||||||
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
|
val wifiConnectResult: StateFlow<String?> = _wifiConnectResult.asStateFlow()
|
||||||
|
|
||||||
|
// WiGLE state
|
||||||
|
private val _wigleStatus = MutableStateFlow<WigleStatus?>(null)
|
||||||
|
val wigleStatus: StateFlow<WigleStatus?> = _wigleStatus.asStateFlow()
|
||||||
|
|
||||||
|
private val _wigleStats = MutableStateFlow<WigleStats?>(null)
|
||||||
|
val wigleStats: StateFlow<WigleStats?> = _wigleStats.asStateFlow()
|
||||||
|
|
||||||
|
private val _wigleConfigResult = MutableStateFlow<String?>(null)
|
||||||
|
val wigleConfigResult: StateFlow<String?> = _wigleConfigResult.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Initialize BeeApiClient with stored settings and token
|
// Initialize BeeApiClient with stored settings and token
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -78,6 +90,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
if (s.isPaired) {
|
if (s.isPaired) {
|
||||||
refreshSshStatus()
|
refreshSshStatus()
|
||||||
refreshWifiStatus()
|
refreshWifiStatus()
|
||||||
|
refreshWigleStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -260,4 +273,58 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
fun clearSshResult() {
|
fun clearSshResult() {
|
||||||
_sshToggleResult.value = null
|
_sshToggleResult.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WiGLE ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh WiGLE status from device.
|
||||||
|
*/
|
||||||
|
fun refreshWigleStatus() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val client = beeClient ?: return@launch
|
||||||
|
when (val result = client.getWigleStatus()) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_wigleStatus.value = result.data
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.w(TAG, "Failed to get WiGLE status: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val result = client.getWigleStats()) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_wigleStats.value = result.data
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
Log.w(TAG, "Failed to get WiGLE stats: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set WiGLE configuration.
|
||||||
|
*/
|
||||||
|
fun setWigleConfig(enabled: Boolean, apiName: String, apiToken: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val client = beeClient ?: run {
|
||||||
|
_wigleConfigResult.value = "Not connected to device"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
_wigleConfigResult.value = null
|
||||||
|
|
||||||
|
when (val result = client.setWigleConfig(enabled, apiName, apiToken)) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
_wigleConfigResult.value = "WiGLE configuration saved"
|
||||||
|
refreshWigleStatus()
|
||||||
|
}
|
||||||
|
is ApiResult.Error -> {
|
||||||
|
_wigleConfigResult.value = "Failed: ${result.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearWigleResult() {
|
||||||
|
_wigleConfigResult.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue