diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eba13ff..84da2fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.adamaps.varroa" minSdk = 26 targetSdk = 34 - versionCode = 12 - versionName = "1.7.6" + versionCode = 13 + versionName = "1.7.7" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/adamaps/varroa/Navigation.kt b/app/src/main/java/com/adamaps/varroa/Navigation.kt index 20bb168..e23ca05 100644 --- a/app/src/main/java/com/adamaps/varroa/Navigation.kt +++ b/app/src/main/java/com/adamaps/varroa/Navigation.kt @@ -5,11 +5,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.adamaps.varroa.ui.dashboard.DashboardScreen +import com.adamaps.varroa.ui.settings.DeviceStatusScreen import com.adamaps.varroa.ui.settings.SettingsScreen object Routes { const val DASHBOARD = "dashboard" const val SETTINGS = "settings" + const val DEVICE_STATUS = "device_status" } @Composable @@ -20,7 +22,13 @@ fun VarroaNavGraph() { DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) }) } composable(Routes.SETTINGS) { - SettingsScreen(onBack = { nav.popBackStack() }) + SettingsScreen( + onBack = { nav.popBackStack() }, + onNavigateToDeviceStatus = { nav.navigate(Routes.DEVICE_STATUS) } + ) + } + composable(Routes.DEVICE_STATUS) { + DeviceStatusScreen(onBack = { nav.popBackStack() }) } } } 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 721911a..a5ed89a 100644 --- a/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt +++ b/app/src/main/java/com/adamaps/varroa/api/BeeApiClient.kt @@ -8,7 +8,11 @@ import android.util.Log import com.adamaps.varroa.data.ApiResult import com.adamaps.varroa.data.BeeDetection import com.adamaps.varroa.data.BeeDeviceInfo +import com.adamaps.varroa.data.BeePlugin import com.adamaps.varroa.data.GnssData +import com.adamaps.varroa.data.GnssStatus +import com.adamaps.varroa.data.StorageStatus +import com.adamaps.varroa.data.WifiConfig import com.google.gson.Gson import com.google.gson.reflect.TypeToken import kotlinx.coroutines.Dispatchers @@ -256,6 +260,105 @@ class BeeApiClient( } } + // ── v7.7 Settings API endpoints ─────────────────────────────────────────── + + suspend fun getWifiConfig(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/wifi/status")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, WifiConfig::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun setWifiConfig(ssid: String, password: String): ApiResult = withContext(Dispatchers.IO) { + try { + val jsonBody = gson.toJson(mapOf("ssid" to ssid, "password" to password)) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) + val request = Request.Builder() + .url("$apiUrl/api/1/wifi/connect") + .post(requestBody) + .build() + + client.newCall(request).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + ApiResult.Success(body) + } else { + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun getStorageStatus(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/storage/usage")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, StorageStatus::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getGnssStatus(): ApiResult = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/gnss/status")) { + is ApiResult.Success -> try { + ApiResult.Success(gson.fromJson(r.data, GnssStatus::class.java)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun getPlugins(): ApiResult> = withContext(Dispatchers.IO) { + when (val r = getRaw("/api/1/plugin/list")) { + is ApiResult.Success -> try { + val type = object : TypeToken>() {}.type + ApiResult.Success(gson.fromJson(r.data, type)) + } catch (e: Exception) { + ApiResult.Error("Parse error: ${e.message}") + } + is ApiResult.Error -> r + } + } + + suspend fun setUploadMode(mode: String): ApiResult = withContext(Dispatchers.IO) { + try { + val jsonBody = gson.toJson(mapOf("mode" to mode)) + val requestBody = okhttp3.RequestBody.create( + "application/json".toMediaType(), + jsonBody + ) + val request = Request.Builder() + .url("$apiUrl/api/1/config/uploadMode") + .post(requestBody) + .build() + + client.newCall(request).execute().use { resp -> + val body = resp.body?.string() ?: "" + if (resp.isSuccessful) { + ApiResult.Success(body) + } else { + ApiResult.Error("HTTP ${resp.code}: ${resp.message}", resp.code) + } + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error") + } + } + + // ── End v7.7 Settings API ───────────────────────────────────────────────── + suspend fun getDeviceIdViaSsh(): ApiResult = withContext(Dispatchers.IO) { try { Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22") 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 cff3c94..f9a9e64 100644 --- a/app/src/main/java/com/adamaps/varroa/data/Models.kt +++ b/app/src/main/java/com/adamaps/varroa/data/Models.kt @@ -87,6 +87,41 @@ fun BeeDetection.toAdaMapsDetection(deviceId: String, walletAddress: String? = n walletAddress = walletAddress?.takeIf { it.isNotBlank() } ) +// ── Device status (for Settings v7.7) ───────────────────────────────────────── + +data class WifiConfig( + @SerializedName("ssid") val ssid: String? = null, + @SerializedName("password") val password: String? = null, + @SerializedName("connected") val connected: Boolean? = null, + @SerializedName("ip") val ip: String? = null +) + +data class StorageStatus( + @SerializedName("total_bytes") val totalBytes: Long? = null, + @SerializedName("used_bytes") val usedBytes: Long? = null, + @SerializedName("free_bytes") val freeBytes: Long? = null, + @SerializedName("percent_used") val percentUsed: Double? = null, + @SerializedName("recording_hours_available") val recordingHoursAvailable: Double? = null +) + +data class GnssStatus( + @SerializedName("has_lock") val hasLock: Boolean? = null, + @SerializedName("satellites") val satellites: Int? = null, + @SerializedName("hdop") val hdop: Double? = null, + @SerializedName("lat") val lat: Double? = null, + @SerializedName("lon") val lon: Double? = null, + @SerializedName("alt") val alt: Double? = null, + @SerializedName("speed_kmh") val speedKmh: Double? = null, + @SerializedName("last_fix_age_sec") val lastFixAgeSec: Int? = null +) + +data class BeePlugin( + @SerializedName("name") val name: String? = null, + @SerializedName("version") val version: String? = null, + @SerializedName("enabled") val enabled: Boolean? = null, + @SerializedName("running") val running: Boolean? = null +) + // ── App state ───────────────────────────────────────────────────────────────── data class SessionStats( diff --git a/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt new file mode 100644 index 0000000..0403e38 --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/ui/settings/DeviceStatusScreen.kt @@ -0,0 +1,520 @@ +package com.adamaps.varroa.ui.settings + +import androidx.compose.foundation.layout.* +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.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 androidx.lifecycle.viewmodel.compose.viewModel +import com.adamaps.varroa.data.BeeDeviceInfo +import com.adamaps.varroa.data.BeePlugin +import com.adamaps.varroa.data.GnssStatus +import com.adamaps.varroa.data.StorageStatus +import com.adamaps.varroa.data.WifiConfig +import com.adamaps.varroa.ui.theme.* +import com.adamaps.varroa.viewmodel.DeviceStatusViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceStatusScreen( + vm: DeviceStatusViewModel = viewModel(), + onBack: () -> Unit +) { + val state by vm.state.collectAsState() + val wifiResult by vm.wifiSaveResult.collectAsState() + val uploadResult by vm.uploadModeResult.collectAsState() + + // Refresh on first load + LaunchedEffect(Unit) { + vm.refresh() + } + + // Snackbar for results + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(wifiResult) { + wifiResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearWifiResult() + } + } + LaunchedEffect(uploadResult) { + uploadResult?.let { + snackbarHostState.showSnackbar(it) + vm.clearUploadModeResult() + } + } + + Scaffold( + containerColor = Background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { + Text( + "DEVICE STATUS", + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + letterSpacing = 3.sp + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = Amber) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Surface), + actions = { + IconButton(onClick = { vm.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh", tint = Amber) + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = Amber + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Connection status + ConnectionStatusCard(state.isConnected, state.error) + + if (state.isConnected) { + // Device Info + state.deviceInfo?.let { DeviceInfoCard(it) } + + // WiFi Config + WifiConfigCard(state.wifiConfig, vm) + + // Storage Status + state.storageStatus?.let { StorageStatusCard(it) } + + // GPS/GNSS Status + GnssStatusCard(state.gnssStatus, state.deviceInfo?.hasGnssLock) + + // Upload Mode + UploadModeCard(state.deviceInfo?.uploadMode, vm) + + // Plugins + if (state.plugins.isNotEmpty()) { + PluginsCard(state.plugins) + } + } + } + } + } + } +} + +@Composable +private fun StatusCard(title: String, content: @Composable ColumnScope.() -> Unit) { + Card( + colors = CardDefaults.cardColors(containerColor = Surface), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Text( + title, + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 2.sp + ) + Spacer(Modifier.height(10.dp)) + content() + } + } +} + +@Composable +private fun StatusRow(label: String, value: String?, valueColor: Color = OnSurface) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp + ) + Text( + value ?: "—", + color = valueColor, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } +} + +@Composable +private fun ConnectionStatusCard(isConnected: Boolean, error: String?) { + StatusCard("CONNECTION") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (isConnected) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (isConnected) Color.Green else Color.Red, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (isConnected) "Connected to Bee" else "Disconnected", + color = if (isConnected) Color.Green else Color.Red, + fontFamily = FontFamily.Monospace, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) + } + } + error?.let { + Spacer(Modifier.height(8.dp)) + Text( + it, + color = Color.Red, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + } + } +} + +@Composable +private fun DeviceInfoCard(info: BeeDeviceInfo) { + StatusCard("DEVICE INFO") { + StatusRow("Firmware", info.firmwareVersion) + Spacer(Modifier.height(4.dp)) + StatusRow("API Version", info.apiVersion) + Spacer(Modifier.height(4.dp)) + StatusRow("Build Date", info.buildDate) + Spacer(Modifier.height(4.dp)) + StatusRow("Serial", info.serial) + Spacer(Modifier.height(4.dp)) + StatusRow("Device ID", info.deviceId?.take(16)?.plus("...")) + Spacer(Modifier.height(4.dp)) + StatusRow("Model", info.model) + info.uptime?.let { + Spacer(Modifier.height(4.dp)) + val hours = it / 3600 + val mins = (it % 3600) / 60 + StatusRow("Uptime", "${hours}h ${mins}m") + } + } +} + +@Composable +private fun WifiConfigCard(wifi: WifiConfig?, vm: DeviceStatusViewModel) { + var ssid by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var showPassword by remember { mutableStateOf(false) } + + StatusCard("WIFI CLIENT") { + if (wifi != null) { + StatusRow( + "Status", + if (wifi.connected == true) "Connected" else "Disconnected", + if (wifi.connected == true) Color.Green else Color.Yellow + ) + wifi.ssid?.let { + Spacer(Modifier.height(4.dp)) + StatusRow("Current SSID", it) + } + wifi.ip?.let { + Spacer(Modifier.height(4.dp)) + StatusRow("IP Address", it) + } + Spacer(Modifier.height(12.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(12.dp)) + } + + Text( + "Configure WiFi", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp + ) + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = ssid, + onValueChange = { ssid = it }, + label = { Text("SSID", fontFamily = FontFamily.Monospace, fontSize = 11.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 = password, + onValueChange = { password = it }, + label = { Text("Password", fontFamily = FontFamily.Monospace, fontSize = 11.sp) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + visualTransformation = if (showPassword) + androidx.compose.ui.text.input.VisualTransformation.None + else + androidx.compose.ui.text.input.PasswordVisualTransformation(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = Amber, + unfocusedBorderColor = SurfaceVariant, + focusedLabelColor = Amber, + unfocusedLabelColor = Color.Gray, + cursorColor = Amber, + focusedTextColor = OnSurface, + unfocusedTextColor = OnSurface + ) + ) + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { vm.setWifiConfig(ssid, password) }, + enabled = ssid.isNotBlank(), + colors = ButtonDefaults.buttonColors(containerColor = Amber) + ) { + Text("Save WiFi Config", color = Background) + } + } +} + +@Composable +private fun StorageStatusCard(storage: StorageStatus) { + StatusCard("STORAGE") { + val usedGb = (storage.usedBytes ?: 0) / 1_000_000_000.0 + val totalGb = (storage.totalBytes ?: 0) / 1_000_000_000.0 + val freeGb = (storage.freeBytes ?: 0) / 1_000_000_000.0 + val percent = storage.percentUsed ?: 0.0 + + StatusRow("Used", "%.1f GB".format(usedGb)) + Spacer(Modifier.height(4.dp)) + StatusRow("Free", "%.1f GB".format(freeGb), if (freeGb < 10) Color.Yellow else OnSurface) + Spacer(Modifier.height(4.dp)) + StatusRow("Total", "%.1f GB".format(totalGb)) + Spacer(Modifier.height(8.dp)) + + LinearProgressIndicator( + progress = { (percent / 100.0).toFloat().coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(8.dp), + color = when { + percent > 90 -> Color.Red + percent > 75 -> Color.Yellow + else -> Color.Green + }, + trackColor = SurfaceVariant + ) + + Spacer(Modifier.height(4.dp)) + Text( + "%.1f%% used".format(percent), + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + + storage.recordingHoursAvailable?.let { + Spacer(Modifier.height(8.dp)) + StatusRow("Recording Available", "%.1f hours".format(it)) + } + } +} + +@Composable +private fun GnssStatusCard(gnss: GnssStatus?, hasLock: Boolean?) { + StatusCard("GPS / GNSS") { + val hasGps = gnss?.hasLock == true || hasLock == true + + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + if (hasGps) Icons.Default.CheckCircle else Icons.Default.Warning, + contentDescription = null, + tint = if (hasGps) Color.Green else Color.Yellow, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + if (hasGps) "GPS Lock Acquired" else "Searching for GPS...", + color = if (hasGps) Color.Green else Color.Yellow, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + } + + if (gnss != null) { + Spacer(Modifier.height(8.dp)) + gnss.satellites?.let { StatusRow("Satellites", it.toString()) } + gnss.hdop?.let { + Spacer(Modifier.height(4.dp)) + StatusRow("HDOP", "%.2f".format(it), if (it > 5) Color.Yellow else OnSurface) + } + if (gnss.lat != null && gnss.lon != null) { + Spacer(Modifier.height(4.dp)) + StatusRow("Position", "%.5f, %.5f".format(gnss.lat, gnss.lon)) + } + gnss.alt?.let { + Spacer(Modifier.height(4.dp)) + StatusRow("Altitude", "%.1f m".format(it)) + } + gnss.speedKmh?.let { + Spacer(Modifier.height(4.dp)) + StatusRow("Speed", "%.1f km/h".format(it)) + } + gnss.lastFixAgeSec?.let { + Spacer(Modifier.height(4.dp)) + StatusRow("Last Fix", "${it}s ago", if (it > 30) Color.Yellow else OnSurface) + } + } + } +} + +@Composable +private fun UploadModeCard(currentMode: String?, vm: DeviceStatusViewModel) { + var selectedMode by remember(currentMode) { mutableStateOf(currentMode ?: "auto") } + + StatusCard("UPLOAD MODE") { + StatusRow("Current Mode", currentMode ?: "unknown") + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf("auto", "wifi", "lte", "off").forEach { mode -> + FilterChip( + selected = selectedMode == mode, + onClick = { + selectedMode = mode + vm.setUploadMode(mode) + }, + label = { + Text( + mode.uppercase(), + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Amber, + selectedLabelColor = Background + ) + ) + } + } + + Spacer(Modifier.height(8.dp)) + Text( + when (selectedMode) { + "auto" -> "Uploads via WiFi when available, falls back to LTE" + "wifi" -> "Only uploads when connected to WiFi" + "lte" -> "Uploads via cellular data" + "off" -> "Uploads disabled" + else -> "" + }, + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } +} + +@Composable +private fun PluginsCard(plugins: List) { + StatusCard("PLUGINS") { + plugins.forEachIndexed { index, plugin -> + if (index > 0) { + Spacer(Modifier.height(8.dp)) + Divider(color = SurfaceVariant) + Spacer(Modifier.height(8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + plugin.name ?: "Unknown", + color = OnSurface, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + plugin.version?.let { + Text( + "v$it", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (plugin.enabled == true) { + Text( + "ENABLED", + color = Color.Green, + fontFamily = FontFamily.Monospace, + fontSize = 9.sp + ) + } + if (plugin.running == true) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Running", + tint = Color.Green, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } +} 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 7bd14de..1698807 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 @@ -9,7 +9,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.PhoneAndroid import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material3.* import androidx.compose.runtime.* @@ -37,7 +39,8 @@ import com.journeyapps.barcodescanner.ScanOptions @Composable fun SettingsScreen( vm: SettingsViewModel = viewModel(), - onBack: () -> Unit + onBack: () -> Unit, + onNavigateToDeviceStatus: () -> Unit = {} ) { val currentSettings by vm.settings.collectAsState() val saved by vm.saved.collectAsState() @@ -108,6 +111,52 @@ fun SettingsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + // Device Status navigation card + Card( + onClick = onNavigateToDeviceStatus, + colors = CardDefaults.cardColors(containerColor = Surface), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.PhoneAndroid, + contentDescription = null, + tint = Amber, + modifier = Modifier.size(24.dp) + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + "DEVICE STATUS", + color = Amber, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + fontSize = 11.sp, + letterSpacing = 2.sp + ) + Text( + "WiFi, storage, GPS, plugins", + color = Color.Gray, + fontFamily = FontFamily.Monospace, + fontSize = 10.sp + ) + } + } + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + tint = Color.Gray + ) + } + } + SettingsSection("BEE DEVICE") { SettingsField( label = "Bee API URL", diff --git a/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt new file mode 100644 index 0000000..09ab8be --- /dev/null +++ b/app/src/main/java/com/adamaps/varroa/viewmodel/DeviceStatusViewModel.kt @@ -0,0 +1,139 @@ +package com.adamaps.varroa.viewmodel + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.adamaps.varroa.api.BeeApiClient +import com.adamaps.varroa.data.ApiResult +import com.adamaps.varroa.data.BeeDeviceInfo +import com.adamaps.varroa.data.BeePlugin +import com.adamaps.varroa.data.GnssStatus +import com.adamaps.varroa.data.SettingsDataStore +import com.adamaps.varroa.data.StorageStatus +import com.adamaps.varroa.data.WifiConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +data class DeviceStatusState( + val isLoading: Boolean = false, + val isConnected: Boolean = false, + val deviceInfo: BeeDeviceInfo? = null, + val wifiConfig: WifiConfig? = null, + val storageStatus: StorageStatus? = null, + val gnssStatus: GnssStatus? = null, + val plugins: List = emptyList(), + val error: String? = null +) + +class DeviceStatusViewModel(app: Application) : AndroidViewModel(app) { + + companion object { + private const val TAG = "DeviceStatusVM" + } + + private val store = SettingsDataStore(app) + private var beeClient: BeeApiClient? = null + + private val _state = MutableStateFlow(DeviceStatusState()) + val state: StateFlow = _state.asStateFlow() + + private val _wifiSaveResult = MutableStateFlow(null) + val wifiSaveResult: StateFlow = _wifiSaveResult.asStateFlow() + + private val _uploadModeResult = MutableStateFlow(null) + val uploadModeResult: StateFlow = _uploadModeResult.asStateFlow() + + fun refresh() { + viewModelScope.launch { + _state.value = _state.value.copy(isLoading = true, error = null) + + try { + val settings = store.settings.first() + val client = BeeApiClient(settings.beeApiUrl) + beeClient = client + + // Fetch all status in parallel + val deviceInfoResult = client.getDeviceInfo() + val wifiResult = client.getWifiConfig() + val storageResult = client.getStorageStatus() + val gnssResult = client.getGnssStatus() + val pluginsResult = client.getPlugins() + + val deviceInfo = (deviceInfoResult as? ApiResult.Success)?.data + val wifi = (wifiResult as? ApiResult.Success)?.data + val storage = (storageResult as? ApiResult.Success)?.data + val gnss = (gnssResult as? ApiResult.Success)?.data + val plugins = (pluginsResult as? ApiResult.Success)?.data ?: emptyList() + + val isConnected = deviceInfo != null + + _state.value = DeviceStatusState( + isLoading = false, + isConnected = isConnected, + deviceInfo = deviceInfo, + wifiConfig = wifi, + storageStatus = storage, + gnssStatus = gnss, + plugins = plugins, + error = if (!isConnected) "Cannot connect to Bee device" else null + ) + + Log.i(TAG, "Device status refreshed: connected=$isConnected, plugins=${plugins.size}") + + } catch (e: Exception) { + Log.e(TAG, "Failed to refresh device status", e) + _state.value = DeviceStatusState( + isLoading = false, + isConnected = false, + error = e.message ?: "Unknown error" + ) + } + } + } + + fun setWifiConfig(ssid: String, password: String) { + viewModelScope.launch { + val client = beeClient ?: return@launch + _wifiSaveResult.value = null + + when (val result = client.setWifiConfig(ssid, password)) { + is ApiResult.Success -> { + _wifiSaveResult.value = "WiFi configuration saved" + refresh() // Refresh to show new status + } + is ApiResult.Error -> { + _wifiSaveResult.value = "Failed: ${result.message}" + } + } + } + } + + fun setUploadMode(mode: String) { + viewModelScope.launch { + val client = beeClient ?: return@launch + _uploadModeResult.value = null + + when (val result = client.setUploadMode(mode)) { + is ApiResult.Success -> { + _uploadModeResult.value = "Upload mode set to $mode" + refresh() + } + is ApiResult.Error -> { + _uploadModeResult.value = "Failed: ${result.message}" + } + } + } + } + + fun clearWifiResult() { + _wifiSaveResult.value = null + } + + fun clearUploadModeResult() { + _uploadModeResult.value = null + } +}