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
afefd01053
commit
b46ef2c65a
7 changed files with 858 additions and 4 deletions
|
|
@ -13,8 +13,8 @@ android {
|
||||||
applicationId = "com.adamaps.varroa"
|
applicationId = "com.adamaps.varroa"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 12
|
versionCode = 13
|
||||||
versionName = "1.7.6"
|
versionName = "1.7.7"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.adamaps.varroa.ui.dashboard.DashboardScreen
|
import com.adamaps.varroa.ui.dashboard.DashboardScreen
|
||||||
|
import com.adamaps.varroa.ui.settings.DeviceStatusScreen
|
||||||
import com.adamaps.varroa.ui.settings.SettingsScreen
|
import com.adamaps.varroa.ui.settings.SettingsScreen
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val DASHBOARD = "dashboard"
|
const val DASHBOARD = "dashboard"
|
||||||
const val SETTINGS = "settings"
|
const val SETTINGS = "settings"
|
||||||
|
const val DEVICE_STATUS = "device_status"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -20,7 +22,13 @@ fun VarroaNavGraph() {
|
||||||
DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) })
|
DashboardScreen(onNavigateToSettings = { nav.navigate(Routes.SETTINGS) })
|
||||||
}
|
}
|
||||||
composable(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.ApiResult
|
||||||
import com.adamaps.varroa.data.BeeDetection
|
import com.adamaps.varroa.data.BeeDetection
|
||||||
import com.adamaps.varroa.data.BeeDeviceInfo
|
import com.adamaps.varroa.data.BeeDeviceInfo
|
||||||
|
import com.adamaps.varroa.data.BeePlugin
|
||||||
import com.adamaps.varroa.data.GnssData
|
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.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
import kotlinx.coroutines.Dispatchers
|
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) {
|
suspend fun getDeviceIdViaSsh(): ApiResult<String> = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
Log.d(TAG, "Attempting SSH connection to root@192.168.0.10:22")
|
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() }
|
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 ─────────────────────────────────────────────────────────────────
|
// ── App state ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class SessionStats(
|
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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Clear
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
|
@ -37,7 +39,8 @@ import com.journeyapps.barcodescanner.ScanOptions
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
vm: SettingsViewModel = viewModel(),
|
vm: SettingsViewModel = viewModel(),
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit,
|
||||||
|
onNavigateToDeviceStatus: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val currentSettings by vm.settings.collectAsState()
|
val currentSettings by vm.settings.collectAsState()
|
||||||
val saved by vm.saved.collectAsState()
|
val saved by vm.saved.collectAsState()
|
||||||
|
|
@ -108,6 +111,52 @@ fun SettingsScreen(
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(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") {
|
SettingsSection("BEE DEVICE") {
|
||||||
SettingsField(
|
SettingsField(
|
||||||
label = "Bee API URL",
|
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