v7.7: Device Status screen with WiFi, storage, GPS, plugins
- Add DeviceStatusScreen with full device info display - Add WiFi client configuration - Add storage status with usage bar - Add GPS/GNSS status display - Add upload mode toggle (auto/wifi/lte/off) - Add plugin list display - New API endpoints in BeeApiClient for device settings - Navigate to Device Status from Settings screen
This commit is contained in:
parent
5d8cacfacb
commit
0f22c155cd
7 changed files with 858 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<WifiConfig> = 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<String> = 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<StorageStatus> = 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<GnssStatus> = 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<List<BeePlugin>> = withContext(Dispatchers.IO) {
|
||||
when (val r = getRaw("/api/1/plugin/list")) {
|
||||
is ApiResult.Success -> try {
|
||||
val type = object : TypeToken<List<BeePlugin>>() {}.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<String> = 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<String> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<BeePlugin>) {
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<BeePlugin> = 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<DeviceStatusState> = _state.asStateFlow()
|
||||
|
||||
private val _wifiSaveResult = MutableStateFlow<String?>(null)
|
||||
val wifiSaveResult: StateFlow<String?> = _wifiSaveResult.asStateFlow()
|
||||
|
||||
private val _uploadModeResult = MutableStateFlow<String?>(null)
|
||||
val uploadModeResult: StateFlow<String?> = _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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue