diff --git a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt index 6215393..a6fcfeb 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -672,4 +672,37 @@ class BeeApiClient( return@withContext ApiResult.Error("Exception: ${e.message}") } } + + // ── WiGLE Wardriving API ────────────────────────────────────────────────── + + suspend fun getWigleStatus(): ApiResult = 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 = 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 { + val json = gson.toJson(mapOf( + "enabled" to enabled, + "api_name" to apiName, + "api_token" to apiToken + )) + return postRaw("/api/1/wigle/config", json) + } } diff --git a/app/src/main/java/com/adamaps/varroa/data/Models.kt b/app/src/main/java/com/adamaps/varroa/data/Models.kt index af348ae..3575c3d 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -217,6 +217,24 @@ data class FrameKmTotal( @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 ───────────────────────────────────────────────────────────────── data class SessionStats( diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt index 76605f4..c6402f5 100644 --- a/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/SettingsScreen.kt @@ -30,6 +30,9 @@ import androidx.compose.ui.unit.sp import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Wifi 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 com.adamaps.varroa.data.VarroaSettings import com.adamaps.varroa.ui.theme.* @@ -65,6 +68,14 @@ fun SettingsScreen( val sshStatus by vm.sshStatus.collectAsState() val wifiStatus by vm.wifiStatus.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 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") { SettingsField( label = "ADAMaps API URL", @@ -384,7 +521,8 @@ private fun SettingsField( value: String, onValueChange: (String) -> Unit, hint: String = "", - numeric: Boolean = false + numeric: Boolean = false, + keyboardType: KeyboardType = if (numeric) KeyboardType.Number else KeyboardType.Text ) { OutlinedTextField( value = value, @@ -392,8 +530,7 @@ private fun SettingsField( label = { Text(label, fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, placeholder = { Text(hint, color = Color.Gray, fontFamily = FontFamily.Monospace, fontSize = 12.sp) }, singleLine = true, - keyboardOptions = if (numeric) KeyboardOptions(keyboardType = KeyboardType.Number) - else KeyboardOptions.Default, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( 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" + } +} diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt index 5d1ef53..70a3cfa 100644 --- a/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/SettingsViewModel.kt @@ -9,6 +9,8 @@ import com.adamaps.varroa.data.ApiResult import com.adamaps.varroa.data.SettingsDataStore import com.adamaps.varroa.data.SshStatus 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 kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -64,6 +66,16 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { private val _wifiConnectResult = MutableStateFlow(null) val wifiConnectResult: StateFlow = _wifiConnectResult.asStateFlow() + // WiGLE state + private val _wigleStatus = MutableStateFlow(null) + val wigleStatus: StateFlow = _wigleStatus.asStateFlow() + + private val _wigleStats = MutableStateFlow(null) + val wigleStats: StateFlow = _wigleStats.asStateFlow() + + private val _wigleConfigResult = MutableStateFlow(null) + val wigleConfigResult: StateFlow = _wigleConfigResult.asStateFlow() + init { // Initialize BeeApiClient with stored settings and token viewModelScope.launch { @@ -78,6 +90,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { if (s.isPaired) { refreshSshStatus() refreshWifiStatus() + refreshWigleStatus() } } } @@ -260,4 +273,58 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { fun clearSshResult() { _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 + } }