mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(colors): consolidate colors in UI components (#2520)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
c61d31c3b8
commit
6fd444c077
14 changed files with 1028 additions and 1386 deletions
|
|
@ -19,51 +19,39 @@ package com.geeksville.mesh.model
|
|||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.geeksville.mesh.TelemetryProtos.Telemetry
|
||||
import com.geeksville.mesh.ui.common.theme.InfantryBlue
|
||||
import com.geeksville.mesh.ui.common.theme.Orange
|
||||
import com.geeksville.mesh.ui.common.theme.Pink
|
||||
import com.geeksville.mesh.ui.common.theme.Purple
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.InfantryBlue
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Orange
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Pink
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Purple
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
|
||||
import com.geeksville.mesh.util.UnitConversions
|
||||
|
||||
enum class Environment(val color: Color) {
|
||||
TEMPERATURE(Color.Red) {
|
||||
override fun getValue(telemetry: Telemetry): Float {
|
||||
return telemetry.environmentMetrics.temperature
|
||||
}
|
||||
TEMPERATURE(Red) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.temperature
|
||||
},
|
||||
HUMIDITY(InfantryBlue) {
|
||||
override fun getValue(telemetry: Telemetry): Float {
|
||||
return telemetry.environmentMetrics.relativeHumidity
|
||||
}
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.relativeHumidity
|
||||
},
|
||||
SOIL_TEMPERATURE(Pink) {
|
||||
override fun getValue(telemetry: Telemetry): Float {
|
||||
return telemetry.environmentMetrics.soilTemperature
|
||||
}
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.soilTemperature
|
||||
},
|
||||
SOIL_MOISTURE(Purple) {
|
||||
override fun getValue(telemetry: Telemetry): Float {
|
||||
return telemetry.environmentMetrics.soilMoisture.toFloat()
|
||||
}
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.soilMoisture.toFloat()
|
||||
},
|
||||
IAQ(Color.Green) {
|
||||
override fun getValue(telemetry: Telemetry): Float {
|
||||
return telemetry.environmentMetrics.iaq.toFloat()
|
||||
}
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.iaq.toFloat()
|
||||
},
|
||||
BAROMETRIC_PRESSURE(Orange) {
|
||||
override fun getValue(telemetry: Telemetry): Float {
|
||||
return telemetry.environmentMetrics.barometricPressure
|
||||
}
|
||||
};
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.barometricPressure
|
||||
}, ;
|
||||
|
||||
abstract fun getValue(telemetry: Telemetry): Float
|
||||
}
|
||||
|
||||
/**
|
||||
* @param metrics the filtered [List]
|
||||
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric
|
||||
* should be plotted
|
||||
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted
|
||||
* @param leftMinMax [Pair] with the min and max of the barometric pressure
|
||||
* @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ
|
||||
* @param times [Pair] with the oldest and newest times in that order
|
||||
|
|
@ -73,12 +61,10 @@ data class EnvironmentGraphingData(
|
|||
val shouldPlot: List<Boolean>,
|
||||
val leftMinMax: Pair<Float, Float> = Pair(0f, 0f),
|
||||
val rightMinMax: Pair<Float, Float> = Pair(0f, 0f),
|
||||
val times: Pair<Int, Int> = Pair(0, 0)
|
||||
val times: Pair<Int, Int> = Pair(0, 0),
|
||||
)
|
||||
|
||||
data class EnvironmentMetricsState(
|
||||
val environmentMetrics: List<Telemetry> = emptyList(),
|
||||
) {
|
||||
data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emptyList()) {
|
||||
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
|
||||
|
||||
/**
|
||||
|
|
@ -99,10 +85,11 @@ data class EnvironmentMetricsState(
|
|||
/* Grab the combined min and max for temp, humidity, soil_Temperature, soilMoisture and iaq. */
|
||||
val minValues = mutableListOf<Float>()
|
||||
val maxValues = mutableListOf<Float>()
|
||||
val (minTemp, maxTemp) = Pair(
|
||||
telemetries.minBy { it.environmentMetrics.temperature },
|
||||
telemetries.maxBy { it.environmentMetrics.temperature }
|
||||
)
|
||||
val (minTemp, maxTemp) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.temperature },
|
||||
telemetries.maxBy { it.environmentMetrics.temperature },
|
||||
)
|
||||
var minTempValue = minTemp.environmentMetrics.temperature
|
||||
var maxTempValue = maxTemp.environmentMetrics.temperature
|
||||
if (useFahrenheit) {
|
||||
|
|
@ -115,12 +102,15 @@ data class EnvironmentMetricsState(
|
|||
shouldPlot[Environment.TEMPERATURE.ordinal] = true
|
||||
}
|
||||
|
||||
val (minHumidity, maxHumidity) = Pair(
|
||||
telemetries.minBy { it.environmentMetrics.relativeHumidity },
|
||||
telemetries.maxBy { it.environmentMetrics.relativeHumidity }
|
||||
)
|
||||
if (minHumidity.environmentMetrics.relativeHumidity != 0f ||
|
||||
maxHumidity.environmentMetrics.relativeHumidity != 0f) {
|
||||
val (minHumidity, maxHumidity) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.relativeHumidity },
|
||||
telemetries.maxBy { it.environmentMetrics.relativeHumidity },
|
||||
)
|
||||
if (
|
||||
minHumidity.environmentMetrics.relativeHumidity != 0f ||
|
||||
maxHumidity.environmentMetrics.relativeHumidity != 0f
|
||||
) {
|
||||
minValues.add(minHumidity.environmentMetrics.relativeHumidity)
|
||||
maxValues.add(maxHumidity.environmentMetrics.relativeHumidity)
|
||||
shouldPlot[Environment.HUMIDITY.ordinal] = true
|
||||
|
|
@ -132,29 +122,29 @@ data class EnvironmentMetricsState(
|
|||
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
|
||||
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
|
||||
}
|
||||
if (minTemp.environmentMetrics.soilTemperature != 0f ||
|
||||
maxTemp.environmentMetrics.soilTemperature != 0f) {
|
||||
if (minTemp.environmentMetrics.soilTemperature != 0f || maxTemp.environmentMetrics.soilTemperature != 0f) {
|
||||
minValues.add(minSoilTemperatureValue)
|
||||
maxValues.add(maxSoilTemperatureValue)
|
||||
shouldPlot[Environment.SOIL_TEMPERATURE.ordinal] = true
|
||||
}
|
||||
|
||||
val (minSoilMoisture, maxSoilMoisture) = Pair(
|
||||
telemetries.minBy { it.environmentMetrics.soilMoisture },
|
||||
telemetries.maxBy { it.environmentMetrics.soilMoisture }
|
||||
)
|
||||
val (minSoilMoisture, maxSoilMoisture) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.soilMoisture },
|
||||
telemetries.maxBy { it.environmentMetrics.soilMoisture },
|
||||
)
|
||||
val soilMoistureRange = 0..100
|
||||
if (minSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange ||
|
||||
maxSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange) {
|
||||
if (
|
||||
minSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange ||
|
||||
maxSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange
|
||||
) {
|
||||
minValues.add(minSoilMoisture.environmentMetrics.soilMoisture.toFloat())
|
||||
maxValues.add(maxSoilMoisture.environmentMetrics.soilMoisture.toFloat())
|
||||
shouldPlot[Environment.SOIL_MOISTURE.ordinal] = true
|
||||
}
|
||||
|
||||
val (minIAQ, maxIAQ) = Pair(
|
||||
telemetries.minBy { it.environmentMetrics.iaq },
|
||||
telemetries.maxBy { it.environmentMetrics.iaq }
|
||||
)
|
||||
val (minIAQ, maxIAQ) =
|
||||
Pair(telemetries.minBy { it.environmentMetrics.iaq }, telemetries.maxBy { it.environmentMetrics.iaq })
|
||||
if (minIAQ.environmentMetrics.iaq != 0 || maxIAQ.environmentMetrics.iaq != 0) {
|
||||
minValues.add(minIAQ.environmentMetrics.iaq.toFloat())
|
||||
maxValues.add(maxIAQ.environmentMetrics.iaq.toFloat())
|
||||
|
|
@ -164,28 +154,29 @@ data class EnvironmentMetricsState(
|
|||
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
|
||||
val max = if (maxValues.isEmpty()) 0f else maxValues.maxOf { it }
|
||||
|
||||
val (minPressure, maxPressure) = Pair(
|
||||
telemetries.minBy { it.environmentMetrics.barometricPressure },
|
||||
telemetries.maxBy { it.environmentMetrics.barometricPressure }
|
||||
)
|
||||
if (minPressure.environmentMetrics.barometricPressure != 0.0F &&
|
||||
maxPressure.environmentMetrics.barometricPressure != 0.0F) {
|
||||
val (minPressure, maxPressure) =
|
||||
Pair(
|
||||
telemetries.minBy { it.environmentMetrics.barometricPressure },
|
||||
telemetries.maxBy { it.environmentMetrics.barometricPressure },
|
||||
)
|
||||
if (
|
||||
minPressure.environmentMetrics.barometricPressure != 0.0F &&
|
||||
maxPressure.environmentMetrics.barometricPressure != 0.0F
|
||||
) {
|
||||
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] = true
|
||||
}
|
||||
val (oldest, newest) = Pair(
|
||||
telemetries.minBy { it.time },
|
||||
telemetries.maxBy { it.time }
|
||||
)
|
||||
val (oldest, newest) = Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time })
|
||||
|
||||
return EnvironmentGraphingData(
|
||||
metrics = telemetries,
|
||||
shouldPlot = shouldPlot.toList(),
|
||||
leftMinMax = Pair(
|
||||
leftMinMax =
|
||||
Pair(
|
||||
minPressure.environmentMetrics.barometricPressure,
|
||||
maxPressure.environmentMetrics.barometricPressure
|
||||
maxPressure.environmentMetrics.barometricPressure,
|
||||
),
|
||||
rightMinMax = Pair(min, max),
|
||||
times = Pair(oldest.time, newest.time)
|
||||
times = Pair(oldest.time, newest.time),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipBox
|
||||
|
|
@ -96,6 +97,9 @@ import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
|
|||
import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
|
||||
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
|
||||
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
|
||||
import com.geeksville.mesh.ui.debug.DebugMenuActions
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
|
|
@ -117,10 +121,11 @@ enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector,
|
|||
MapRoutes.Map,
|
||||
ChannelsRoutes.Channels,
|
||||
ConnectionsRoutes.Connections,
|
||||
).any { this.hasRoute(it::class) }
|
||||
)
|
||||
.any { this.hasRoute(it::class) }
|
||||
|
||||
fun fromNavDestination(destination: NavDestination?): TopLevelDestination? = entries
|
||||
.find { dest -> destination?.hierarchy?.any { it.hasRoute(dest.route::class) } == true }
|
||||
fun fromNavDestination(destination: NavDestination?): TopLevelDestination? =
|
||||
entries.find { dest -> destination?.hierarchy?.any { it.hasRoute(dest.route::class) } == true }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,9 +142,7 @@ fun MainScreen(
|
|||
val localConfig by uIViewModel.localConfig.collectAsStateWithLifecycle()
|
||||
val requestChannelSet by uIViewModel.requestChannelSet.collectAsStateWithLifecycle()
|
||||
if (connectionState.isConnected()) {
|
||||
requestChannelSet?.let { newChannelSet ->
|
||||
ScannedQrCodeDialog(uIViewModel, newChannelSet)
|
||||
}
|
||||
requestChannelSet?.let { newChannelSet -> ScannedQrCodeDialog(uIViewModel, newChannelSet) }
|
||||
}
|
||||
|
||||
VersionChecks(uIViewModel)
|
||||
|
|
@ -176,9 +179,7 @@ fun MainScreen(
|
|||
}
|
||||
SimpleAlertDialog(
|
||||
title = R.string.client_notification,
|
||||
text = {
|
||||
Text(text = message)
|
||||
},
|
||||
text = { Text(text = message) },
|
||||
onConfirm = {
|
||||
if (compromisedKeys) {
|
||||
navController.navigate(RadioConfigRoutes.Security)
|
||||
|
|
@ -192,15 +193,12 @@ fun MainScreen(
|
|||
traceRouteResponse?.let { response ->
|
||||
SimpleAlertDialog(
|
||||
title = R.string.traceroute,
|
||||
text = {
|
||||
Text(text = response)
|
||||
},
|
||||
text = { Text(text = response) },
|
||||
dismissText = stringResource(id = R.string.okay),
|
||||
onDismiss = { uIViewModel.clearTracerouteResponse() }
|
||||
onDismiss = { uIViewModel.clearTracerouteResponse() },
|
||||
)
|
||||
}
|
||||
val navSuiteType =
|
||||
NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
|
||||
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
|
||||
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
|
||||
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
|
||||
NavigationSuiteScaffold(
|
||||
|
|
@ -224,7 +222,7 @@ fun MainScreen(
|
|||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
TopLevelNavIcon(destination, connectionState)
|
||||
}
|
||||
|
|
@ -237,26 +235,18 @@ fun MainScreen(
|
|||
},
|
||||
onClick = {
|
||||
navController.navigate(destination.route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
popUpTo(navController.graph.findStartDestination().id) { saveState = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
var sharedContact: Node? by remember { mutableStateOf(null) }
|
||||
if (sharedContact != null) {
|
||||
SharedContactDialog(
|
||||
contact = sharedContact,
|
||||
onDismiss = { sharedContact = null }
|
||||
)
|
||||
SharedContactDialog(contact = sharedContact, onDismiss = { sharedContact = null })
|
||||
}
|
||||
MainAppBar(
|
||||
viewModel = uIViewModel,
|
||||
|
|
@ -274,13 +264,11 @@ fun MainScreen(
|
|||
when (action) {
|
||||
is NodeMenuAction.MoreDetails -> {
|
||||
navController.navigate(
|
||||
NodesRoutes.NodeDetailGraph(
|
||||
action.node.num
|
||||
),
|
||||
NodesRoutes.NodeDetailGraph(action.node.num),
|
||||
{
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -291,11 +279,7 @@ fun MainScreen(
|
|||
},
|
||||
)
|
||||
NavGraph(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.recalculateWindowInsets()
|
||||
.safeDrawingPadding()
|
||||
.imePadding(),
|
||||
modifier = Modifier.fillMaxSize().recalculateWindowInsets().safeDrawingPadding().imePadding(),
|
||||
uIViewModel = uIViewModel,
|
||||
bluetoothViewModel = bluetoothViewModel,
|
||||
navController = navController,
|
||||
|
|
@ -305,14 +289,11 @@ fun MainScreen(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionChecks(
|
||||
viewModel: UIViewModel,
|
||||
) {
|
||||
private fun VersionChecks(viewModel: UIViewModel) {
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val myNodeInfo by viewModel.myNodeInfo.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val latestStableFirmwareRelease by
|
||||
viewModel.latestStableFirmwareRelease.collectAsState(DeviceVersion("2.6.4"))
|
||||
val latestStableFirmwareRelease by viewModel.latestStableFirmwareRelease.collectAsState(DeviceVersion("2.6.4"))
|
||||
// Check if the device is running an old app version or firmware version
|
||||
LaunchedEffect(connectionState, myNodeInfo) {
|
||||
if (connectionState == MeshService.ConnectionState.CONNECTED) {
|
||||
|
|
@ -327,7 +308,7 @@ private fun VersionChecks(
|
|||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
}
|
||||
},
|
||||
)
|
||||
} else if (curVer < MeshService.absoluteMinDeviceVersion) {
|
||||
val title = context.getString(R.string.firmware_too_old)
|
||||
|
|
@ -339,21 +320,12 @@ private fun VersionChecks(
|
|||
onConfirm = {
|
||||
val service = viewModel.meshService ?: return@showAlert
|
||||
MeshService.changeDeviceAddress(context, service, "n")
|
||||
}
|
||||
},
|
||||
)
|
||||
} else if (curVer < MeshService.minDeviceVersion) {
|
||||
val title = context.getString(R.string.should_update_firmware)
|
||||
val message =
|
||||
context.getString(
|
||||
R.string.should_update,
|
||||
latestStableFirmwareRelease.asString
|
||||
)
|
||||
viewModel.showAlert(
|
||||
title = title,
|
||||
message = message,
|
||||
dismissable = false,
|
||||
onConfirm = {}
|
||||
)
|
||||
val message = context.getString(R.string.should_update, latestStableFirmwareRelease.asString)
|
||||
viewModel.showAlert(title = title, message = message, dismissable = false, onConfirm = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -379,7 +351,7 @@ private fun MainAppBar(
|
|||
isManaged: Boolean,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
onAction: (Any?) -> Unit
|
||||
onAction: (Any?) -> Unit,
|
||||
) {
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = backStackEntry?.destination
|
||||
|
|
@ -393,19 +365,21 @@ private fun MainAppBar(
|
|||
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
|
||||
TopAppBar(
|
||||
title = {
|
||||
val title = when {
|
||||
currentDestination == null || currentDestination.isTopLevel() -> stringResource(id = R.string.app_name)
|
||||
val title =
|
||||
when {
|
||||
currentDestination == null || currentDestination.isTopLevel() ->
|
||||
stringResource(id = R.string.app_name)
|
||||
|
||||
currentDestination.hasRoute<Route.DebugPanel>() -> stringResource(id = R.string.debug_panel)
|
||||
currentDestination.hasRoute<Route.DebugPanel>() -> stringResource(id = R.string.debug_panel)
|
||||
|
||||
currentDestination.hasRoute<ContactsRoutes.QuickChat>() -> stringResource(id = R.string.quick_chat)
|
||||
currentDestination.hasRoute<ContactsRoutes.QuickChat>() -> stringResource(id = R.string.quick_chat)
|
||||
|
||||
currentDestination.hasRoute<ContactsRoutes.Share>() -> stringResource(id = R.string.share_to)
|
||||
currentDestination.hasRoute<ContactsRoutes.Share>() -> stringResource(id = R.string.share_to)
|
||||
|
||||
currentDestination.showLongNameTitle() -> title
|
||||
currentDestination.showLongNameTitle() -> title
|
||||
|
||||
else -> stringResource(id = R.string.app_name)
|
||||
}
|
||||
else -> stringResource(id = R.string.app_name)
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
maxLines = 1,
|
||||
|
|
@ -415,17 +389,12 @@ private fun MainAppBar(
|
|||
},
|
||||
subtitle = {
|
||||
if (currentDestination?.hasRoute<NodesRoutes.Nodes>() == true) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.node_count_template,
|
||||
onlineNodeCount,
|
||||
totalNodeCount
|
||||
),
|
||||
)
|
||||
Text(text = stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount))
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
navigationIcon = if (canNavigateBack && currentDestination?.isTopLevel() == false) {
|
||||
navigationIcon =
|
||||
if (canNavigateBack && currentDestination?.isTopLevel() == false) {
|
||||
{
|
||||
IconButton(onClick = navigateUp) {
|
||||
Icon(
|
||||
|
|
@ -436,10 +405,7 @@ private fun MainAppBar(
|
|||
}
|
||||
} else {
|
||||
{
|
||||
IconButton(
|
||||
enabled = false,
|
||||
onClick = { },
|
||||
) {
|
||||
IconButton(enabled = false, onClick = {}) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.app_icon),
|
||||
contentDescription = stringResource(id = R.string.application_icon),
|
||||
|
|
@ -452,7 +418,7 @@ private fun MainAppBar(
|
|||
viewModel = viewModel,
|
||||
currentDestination = currentDestination,
|
||||
isManaged = isManaged,
|
||||
onAction = onAction
|
||||
onAction = onAction,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
@ -463,27 +429,18 @@ private fun TopBarActions(
|
|||
viewModel: UIViewModel = hiltViewModel(),
|
||||
currentDestination: NavDestination?,
|
||||
isManaged: Boolean,
|
||||
onAction: (Any?) -> Unit
|
||||
onAction: (Any?) -> Unit,
|
||||
) {
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false)
|
||||
AnimatedVisibility(ourNode != null && currentDestination?.isTopLevel() == true && isConnected) {
|
||||
ourNode?.let {
|
||||
NodeChip(
|
||||
node = it,
|
||||
isThisNode = true,
|
||||
isConnected = isConnected,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
ourNode?.let { NodeChip(node = it, isThisNode = true, isConnected = isConnected, onAction = onAction) }
|
||||
}
|
||||
currentDestination?.let {
|
||||
when {
|
||||
it.isTopLevel() ->
|
||||
MainMenuActions(isManaged, onAction)
|
||||
it.isTopLevel() -> MainMenuActions(isManaged, onAction)
|
||||
|
||||
currentDestination.hasRoute<Route.DebugPanel>() ->
|
||||
DebugMenuActions()
|
||||
currentDestination.hasRoute<Route.DebugPanel>() -> DebugMenuActions()
|
||||
|
||||
currentDestination.hasRoute<RadioConfigRoutes.RadioConfig>() ->
|
||||
RadioConfigMenuActions(viewModel = viewModel)
|
||||
|
|
@ -494,16 +451,10 @@ private fun TopBarActions(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MainMenuActions(
|
||||
isManaged: Boolean,
|
||||
onAction: (MainMenuAction) -> Unit
|
||||
) {
|
||||
private fun MainMenuActions(isManaged: Boolean, onAction: (MainMenuAction) -> Unit) {
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.MoreVert,
|
||||
contentDescription = stringResource(R.string.overflow_menu),
|
||||
)
|
||||
Icon(imageVector = Icons.Default.MoreVert, contentDescription = stringResource(R.string.overflow_menu))
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
|
|
@ -518,7 +469,8 @@ private fun MainMenuActions(
|
|||
onAction(action)
|
||||
showMenu = false
|
||||
},
|
||||
enabled = when (action) {
|
||||
enabled =
|
||||
when (action) {
|
||||
MainMenuAction.RADIO_CONFIG -> !isManaged
|
||||
else -> true
|
||||
},
|
||||
|
|
@ -528,46 +480,35 @@ private fun MainMenuActions(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MeshService.ConnectionState.getConnectionColor(): Color {
|
||||
return when (this) {
|
||||
MeshService.ConnectionState.CONNECTED -> Color(color = 0xFF30C047)
|
||||
MeshService.ConnectionState.DEVICE_SLEEP -> MaterialTheme.colorScheme.tertiary
|
||||
MeshService.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.error
|
||||
}
|
||||
private fun MeshService.ConnectionState.getConnectionColor(): Color = when (this) {
|
||||
MeshService.ConnectionState.CONNECTED -> colorScheme.StatusGreen
|
||||
MeshService.ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow
|
||||
MeshService.ConnectionState.DISCONNECTED -> colorScheme.StatusRed
|
||||
}
|
||||
|
||||
private fun MeshService.ConnectionState.getConnectionIcon(): ImageVector {
|
||||
return when (this) {
|
||||
MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone
|
||||
MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload
|
||||
MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff
|
||||
}
|
||||
private fun MeshService.ConnectionState.getConnectionIcon(): ImageVector = when (this) {
|
||||
MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone
|
||||
MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload
|
||||
MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MeshService.ConnectionState.getTooltipString(): String {
|
||||
return when (this) {
|
||||
MeshService.ConnectionState.CONNECTED -> stringResource(R.string.connected)
|
||||
MeshService.ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
|
||||
MeshService.ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
|
||||
}
|
||||
private fun MeshService.ConnectionState.getTooltipString(): String = when (this) {
|
||||
MeshService.ConnectionState.CONNECTED -> stringResource(R.string.connected)
|
||||
MeshService.ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
|
||||
MeshService.ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopLevelNavIcon(
|
||||
dest: TopLevelDestination,
|
||||
connectionState: MeshService.ConnectionState
|
||||
) {
|
||||
private fun TopLevelNavIcon(dest: TopLevelDestination, connectionState: MeshService.ConnectionState) {
|
||||
when (dest) {
|
||||
TopLevelDestination.Connections -> Icon(
|
||||
imageVector = connectionState.getConnectionIcon(),
|
||||
contentDescription = stringResource(id = dest.label),
|
||||
tint = connectionState.getConnectionColor(),
|
||||
)
|
||||
TopLevelDestination.Connections ->
|
||||
Icon(
|
||||
imageVector = connectionState.getConnectionIcon(),
|
||||
contentDescription = stringResource(id = dest.label),
|
||||
tint = connectionState.getConnectionColor(),
|
||||
)
|
||||
|
||||
else -> Icon(
|
||||
imageVector = dest.icon,
|
||||
contentDescription = stringResource(id = dest.label),
|
||||
)
|
||||
else -> Icon(imageVector = dest.icon, contentDescription = stringResource(id = dest.label))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,42 +58,50 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQDangerouslyPolluted
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQExcellent
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQExtremelyPolluted
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQGood
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQHeavilyPolluted
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQLightlyPolluted
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQModeratelyPolluted
|
||||
import com.geeksville.mesh.ui.common.theme.IAQColors.IAQSeverelyPolluted
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class Iaq(val color: Color, val description: String, val range: IntRange) {
|
||||
Excellent(Color(0xFF00E400), "Excellent", 0..50),
|
||||
Good(Color(0xFF92D050), "Good", 51..100),
|
||||
LightlyPolluted(Color(0xFFFFFF00), "Lightly Polluted", 101..150),
|
||||
ModeratelyPolluted(Color(0xFFFF7300), "Moderately Polluted", 151..200),
|
||||
HeavilyPolluted(Color(0xFFFF0000), "Heavily Polluted", 201..300),
|
||||
SeverelyPolluted(Color(0xFF99004C), "Severely Polluted", 301..400),
|
||||
ExtremelyPolluted(Color(0xFF663300), "Extremely Polluted", 401..500),
|
||||
DangerouslyPolluted(Color(0xFF663300), "Dangerously Polluted", 501..Int.MAX_VALUE)
|
||||
Excellent(IAQExcellent, "Excellent", 0..50),
|
||||
Good(IAQGood, "Good", 51..100),
|
||||
LightlyPolluted(IAQLightlyPolluted, "Lightly Polluted", 101..150),
|
||||
ModeratelyPolluted(IAQModeratelyPolluted, "Moderately Polluted", 151..200),
|
||||
HeavilyPolluted(IAQHeavilyPolluted, "Heavily Polluted", 201..300),
|
||||
SeverelyPolluted(IAQSeverelyPolluted, "Severely Polluted", 301..400),
|
||||
ExtremelyPolluted(IAQExtremelyPolluted, "Extremely Polluted", 401..500),
|
||||
DangerouslyPolluted(IAQDangerouslyPolluted, "Dangerously Polluted", 501..Int.MAX_VALUE),
|
||||
}
|
||||
|
||||
fun getIaq(iaq: Int): Iaq {
|
||||
return when {
|
||||
iaq in Iaq.Excellent.range -> Iaq.Excellent
|
||||
iaq in Iaq.Good.range -> Iaq.Good
|
||||
iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted
|
||||
iaq in Iaq.ModeratelyPolluted.range -> Iaq.ModeratelyPolluted
|
||||
iaq in Iaq.HeavilyPolluted.range -> Iaq.HeavilyPolluted
|
||||
iaq in Iaq.SeverelyPolluted.range -> Iaq.SeverelyPolluted
|
||||
iaq in Iaq.ExtremelyPolluted.range -> Iaq.ExtremelyPolluted
|
||||
else -> Iaq.DangerouslyPolluted
|
||||
}
|
||||
fun getIaq(iaq: Int): Iaq = when {
|
||||
iaq in Iaq.Excellent.range -> Iaq.Excellent
|
||||
iaq in Iaq.Good.range -> Iaq.Good
|
||||
iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted
|
||||
iaq in Iaq.ModeratelyPolluted.range -> Iaq.ModeratelyPolluted
|
||||
iaq in Iaq.HeavilyPolluted.range -> Iaq.HeavilyPolluted
|
||||
iaq in Iaq.SeverelyPolluted.range -> Iaq.SeverelyPolluted
|
||||
iaq in Iaq.ExtremelyPolluted.range -> Iaq.ExtremelyPolluted
|
||||
else -> Iaq.DangerouslyPolluted
|
||||
}
|
||||
|
||||
private fun getIaqDescriptionWithRange(iaqEnum: Iaq): String {
|
||||
return if (iaqEnum.range.last == Int.MAX_VALUE) {
|
||||
"${iaqEnum.description} (${iaqEnum.range.first}+)"
|
||||
} else {
|
||||
"${iaqEnum.description} (${iaqEnum.range.first}-${iaqEnum.range.last})"
|
||||
}
|
||||
private fun getIaqDescriptionWithRange(iaqEnum: Iaq): String = if (iaqEnum.range.last == Int.MAX_VALUE) {
|
||||
"${iaqEnum.description} (${iaqEnum.range.first}+)"
|
||||
} else {
|
||||
"${iaqEnum.description} (${iaqEnum.range.first}-${iaqEnum.range.last})"
|
||||
}
|
||||
|
||||
enum class IaqDisplayMode {
|
||||
Pill, Dot, Text, Gauge, Gradient
|
||||
Pill,
|
||||
Dot,
|
||||
Text,
|
||||
Gauge,
|
||||
Gradient,
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "UnusedPrivateProperty")
|
||||
|
|
@ -101,36 +109,28 @@ enum class IaqDisplayMode {
|
|||
fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
|
||||
var isLegendOpen by remember { mutableStateOf(false) }
|
||||
val iaqEnum = getIaq(iaq)
|
||||
val gradient = Brush.linearGradient(
|
||||
colors = Iaq.entries.map { it.color },
|
||||
)
|
||||
val gradient = Brush.linearGradient(colors = Iaq.entries.map { it.color })
|
||||
|
||||
Column {
|
||||
when (displayMode) {
|
||||
IaqDisplayMode.Pill -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(10.dp))
|
||||
.background(iaqEnum.color)
|
||||
.width(125.dp)
|
||||
.height(30.dp)
|
||||
.clickable { isLegendOpen = true }
|
||||
.clickable { isLegendOpen = true },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "IAQ $iaq",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(text = "IAQ $iaq", color = Color.White, fontWeight = FontWeight.Bold)
|
||||
Icon(
|
||||
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
|
||||
contentDescription = stringResource(R.string.air_quality_icon),
|
||||
tint = Color.White
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -141,11 +141,7 @@ fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill
|
|||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = "$iaq")
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(iaqEnum.color, shape = CircleShape)
|
||||
)
|
||||
Box(modifier = Modifier.size(10.dp).background(iaqEnum.color, shape = CircleShape))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -154,18 +150,16 @@ fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill
|
|||
Text(
|
||||
text = getIaqDescriptionWithRange(iaqEnum),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.clickable { isLegendOpen = true }
|
||||
modifier = Modifier.clickable { isLegendOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
IaqDisplayMode.Gauge -> {
|
||||
CircularProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clickable { isLegendOpen = true },
|
||||
modifier = Modifier.size(60.dp).clickable { isLegendOpen = true },
|
||||
strokeWidth = 8.dp,
|
||||
color = iaqEnum.color
|
||||
color = iaqEnum.color,
|
||||
)
|
||||
Text(text = "$iaq")
|
||||
}
|
||||
|
|
@ -173,13 +167,11 @@ fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill
|
|||
IaqDisplayMode.Gradient -> {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.clickable { isLegendOpen = true }
|
||||
modifier = Modifier.clickable { isLegendOpen = true },
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
progress = iaq / 500f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(20.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(20.dp),
|
||||
color = iaqEnum.color,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
|
@ -191,14 +183,10 @@ fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill
|
|||
AlertDialog(
|
||||
onDismissRequest = { isLegendOpen = false },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
text = {
|
||||
IAQScale()
|
||||
},
|
||||
text = { IAQScale() },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { isLegendOpen = false }) {
|
||||
Text(text = stringResource(id = R.string.close))
|
||||
}
|
||||
}
|
||||
TextButton(onClick = { isLegendOpen = false }) { Text(text = stringResource(id = R.string.close)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -210,28 +198,17 @@ fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill
|
|||
|
||||
@Composable
|
||||
fun IAQScale(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Column(modifier = modifier.padding(16.dp), horizontalAlignment = Alignment.Start) {
|
||||
Text(
|
||||
text = stringResource(R.string.indoor_air_quality_iaq),
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold, textAlign = TextAlign.Center),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
for (iaq in Iaq.entries) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp, 15.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(iaq.color)
|
||||
)
|
||||
Box(modifier = Modifier.size(20.dp, 15.dp).clip(RoundedCornerShape(5.dp)).background(iaq.color))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(getIaqDescriptionWithRange(iaq), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,16 +36,22 @@ import androidx.compose.material.icons.filled.SignalCellularAlt1Bar
|
|||
import androidx.compose.material.icons.filled.SignalCellularAlt2Bar
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusOrange
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
|
||||
|
||||
private const val SNR_GOOD_THRESHOLD = -7f
|
||||
private const val SNR_FAIR_THRESHOLD = -15f
|
||||
|
|
@ -53,29 +59,27 @@ private const val SNR_FAIR_THRESHOLD = -15f
|
|||
private const val RSSI_GOOD_THRESHOLD = -115
|
||||
private const val RSSI_FAIR_THRESHOLD = -126
|
||||
|
||||
@Stable
|
||||
private enum class Quality(
|
||||
val nameRes: Int,
|
||||
val imageVector: ImageVector,
|
||||
val color: Color
|
||||
@Stable val nameRes: Int,
|
||||
@Stable val imageVector: ImageVector,
|
||||
@Stable val color: @Composable () -> Color,
|
||||
) {
|
||||
NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, Color.Red),
|
||||
BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, Color(red = 247, green = 147, blue = 26)),
|
||||
FAIR(R.string.fair, Icons.Default.SignalCellularAlt, Color(red = 255, green = 230, blue = 0)),
|
||||
GOOD(R.string.good, Icons.Default.SignalCellular4Bar, Color(0xFF30C047))
|
||||
NONE(R.string.none_quality, Icons.Default.SignalCellularAlt1Bar, { colorScheme.StatusRed }),
|
||||
BAD(R.string.bad, Icons.Default.SignalCellularAlt2Bar, { colorScheme.StatusOrange }),
|
||||
FAIR(R.string.fair, Icons.Default.SignalCellularAlt, { colorScheme.StatusYellow }),
|
||||
GOOD(R.string.good, Icons.Default.SignalCellular4Bar, { colorScheme.StatusGreen }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the `snr` and `rssi` color coded based on the signal quality, along with
|
||||
* a human readable description and related icon.
|
||||
* Displays the `snr` and `rssi` color coded based on the signal quality, along with a human readable description and
|
||||
* related icon.
|
||||
*/
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
|
||||
val quality = determineSignalQuality(snr, rssi)
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
maxLines = 1,
|
||||
) {
|
||||
FlowRow(modifier = modifier, maxLines = 1) {
|
||||
Snr(snr)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Rssi(rssi)
|
||||
|
|
@ -89,44 +93,34 @@ fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
|
|||
Icon(
|
||||
imageVector = quality.imageVector,
|
||||
contentDescription = stringResource(R.string.signal_quality),
|
||||
tint = quality.color
|
||||
tint = quality.color.invoke(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the `snr` and `rssi` with color depending on the values respectively.
|
||||
*/
|
||||
/** Displays the `snr` and `rssi` with color depending on the values respectively. */
|
||||
@Composable
|
||||
fun SnrAndRssi(snr: Float, rssi: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Snr(snr)
|
||||
Rssi(rssi)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a human readable description and icon representing the signal quality.
|
||||
*/
|
||||
/** Displays a human readable description and icon representing the signal quality. */
|
||||
@Composable
|
||||
fun LoraSignalIndicator(snr: Float, rssi: Int) {
|
||||
|
||||
val quality = determineSignalQuality(snr, rssi)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = quality.imageVector,
|
||||
contentDescription = stringResource(R.string.signal_quality),
|
||||
tint = quality.color
|
||||
tint = quality.color.invoke(),
|
||||
)
|
||||
Text(text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}")
|
||||
}
|
||||
|
|
@ -134,35 +128,29 @@ fun LoraSignalIndicator(snr: Float, rssi: Int) {
|
|||
|
||||
@Composable
|
||||
fun Snr(snr: Float, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fontSize) {
|
||||
val color: Color = if (snr > SNR_GOOD_THRESHOLD) {
|
||||
Quality.GOOD.color
|
||||
} else if (snr > SNR_FAIR_THRESHOLD) {
|
||||
Quality.FAIR.color
|
||||
} else {
|
||||
Quality.BAD.color
|
||||
}
|
||||
val color: Color =
|
||||
if (snr > SNR_GOOD_THRESHOLD) {
|
||||
Quality.GOOD.color.invoke()
|
||||
} else if (snr > SNR_FAIR_THRESHOLD) {
|
||||
Quality.FAIR.color.invoke()
|
||||
} else {
|
||||
Quality.BAD.color.invoke()
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr),
|
||||
color = color,
|
||||
fontSize = fontSize
|
||||
)
|
||||
Text(text = "%s %.2fdB".format(stringResource(id = R.string.snr), snr), color = color, fontSize = fontSize)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Rssi(rssi: Int, fontSize: TextUnit = MaterialTheme.typography.labelLarge.fontSize) {
|
||||
val color: Color = if (rssi > RSSI_GOOD_THRESHOLD) {
|
||||
Quality.GOOD.color
|
||||
} else if (rssi > RSSI_FAIR_THRESHOLD) {
|
||||
Quality.FAIR.color
|
||||
} else {
|
||||
Quality.BAD.color
|
||||
}
|
||||
Text(
|
||||
text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi),
|
||||
color = color,
|
||||
fontSize = fontSize
|
||||
)
|
||||
val color: Color =
|
||||
if (rssi > RSSI_GOOD_THRESHOLD) {
|
||||
Quality.GOOD.color.invoke()
|
||||
} else if (rssi > RSSI_FAIR_THRESHOLD) {
|
||||
Quality.FAIR.color.invoke()
|
||||
} else {
|
||||
Quality.BAD.color.invoke()
|
||||
}
|
||||
Text(text = "%s %ddBm".format(stringResource(id = R.string.rssi), rssi), color = color, fontSize = fontSize)
|
||||
}
|
||||
|
||||
private fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -61,12 +62,15 @@ import com.geeksville.mesh.AppOnlyProtos
|
|||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.model.getChannel
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
|
||||
|
||||
private const val PRECISE_POSITION_BITS = 32
|
||||
|
||||
/**
|
||||
* Represents the various visual states of the security icon as an enum.
|
||||
* Each enum constant encapsulates the icon, color, descriptive text, and optional badge details.
|
||||
* Represents the various visual states of the security icon as an enum. Each enum constant encapsulates the icon,
|
||||
* color, descriptive text, and optional badge details.
|
||||
*
|
||||
* @property icon The primary vector graphic for the icon.
|
||||
* @property color The tint color for the primary icon.
|
||||
|
|
@ -78,53 +82,54 @@ private const val PRECISE_POSITION_BITS = 32
|
|||
@Immutable
|
||||
enum class SecurityState(
|
||||
@Stable val icon: ImageVector,
|
||||
@Stable val color: Color,
|
||||
@Stable val color: @Composable () -> Color,
|
||||
@StringRes val descriptionResId: Int,
|
||||
@StringRes val helpTextResId: Int,
|
||||
@Stable val badgeIcon: ImageVector? = null,
|
||||
@Stable val badgeIconColor: Color? = null,
|
||||
@Stable val badgeIconColor: @Composable () -> Color? = { null },
|
||||
) {
|
||||
/** State for a secure channel (green lock). */
|
||||
SECURE(
|
||||
icon = Icons.Filled.Lock,
|
||||
color = Color.Green,
|
||||
color = { colorScheme.StatusGreen },
|
||||
descriptionResId = R.string.security_icon_secure,
|
||||
helpTextResId = R.string.security_icon_help_green_lock,
|
||||
),
|
||||
|
||||
/** State for an insecure channel,
|
||||
* not used for precise location,
|
||||
* and MQTT not the primary concern for a higher warning.
|
||||
* (yellow open lock) */
|
||||
/**
|
||||
* State for an insecure channel, not used for precise location, and MQTT not the primary concern for a higher
|
||||
* warning. (yellow open lock)
|
||||
*/
|
||||
INSECURE_NO_PRECISE(
|
||||
icon = Icons.Filled.LockOpen,
|
||||
color = Color.Yellow,
|
||||
color = { colorScheme.StatusYellow },
|
||||
descriptionResId = R.string.security_icon_insecure_no_precise,
|
||||
helpTextResId = R.string.security_icon_help_yellow_open_lock,
|
||||
),
|
||||
|
||||
/** State for an insecure channel
|
||||
* with precise location enabled,
|
||||
* but MQTT not causing the highest
|
||||
* warning. (red open lock) */
|
||||
/**
|
||||
* State for an insecure channel with precise location enabled, but MQTT not causing the highest warning. (red open
|
||||
* lock)
|
||||
*/
|
||||
INSECURE_PRECISE_ONLY(
|
||||
icon = Icons.Filled.LockOpen,
|
||||
color = Color.Red,
|
||||
color = { colorScheme.StatusRed },
|
||||
descriptionResId = R.string.security_icon_insecure_precise_only,
|
||||
helpTextResId = R.string.security_icon_help_red_open_lock,
|
||||
),
|
||||
|
||||
/** State indicating an insecure channel
|
||||
* with precise location and MQTT enabled
|
||||
* (red open lock with yellow warning badge). */
|
||||
/**
|
||||
* State indicating an insecure channel with precise location and MQTT enabled (red open lock with yellow warning
|
||||
* badge).
|
||||
*/
|
||||
INSECURE_PRECISE_MQTT_WARNING(
|
||||
icon = Icons.Filled.LockOpen,
|
||||
color = Color.Red,
|
||||
color = { colorScheme.StatusRed },
|
||||
descriptionResId = R.string.security_icon_warning_precise_mqtt,
|
||||
helpTextResId = R.string.security_icon_help_warning_precise_mqtt,
|
||||
badgeIcon = Icons.Filled.Warning,
|
||||
badgeIconColor = Color.Yellow,
|
||||
)
|
||||
badgeIconColor = { colorScheme.StatusYellow },
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,8 +160,7 @@ private fun SecurityIconDisplay(
|
|||
Icon(
|
||||
imageVector = badgeIcon,
|
||||
contentDescription = stringResource(R.string.security_icon_badge_warning_description),
|
||||
tint = badgeIconColor
|
||||
?: MaterialTheme.colorScheme.onError, // Default for contrast
|
||||
tint = badgeIconColor ?: MaterialTheme.colorScheme.onError, // Default for contrast
|
||||
modifier = Modifier.size(16.dp), // Adjusted badge icon size
|
||||
)
|
||||
}
|
||||
|
|
@ -164,17 +168,13 @@ private fun SecurityIconDisplay(
|
|||
},
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = mainIconTint,
|
||||
)
|
||||
Icon(imageVector = icon, contentDescription = contentDescription, tint = mainIconTint)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the [SecurityState] based on channel properties.
|
||||
* The priority of states is: MQTT warning, then secure, then insecure variations.
|
||||
* Determines the [SecurityState] based on channel properties. The priority of states is: MQTT warning, then secure,
|
||||
* then insecure variations.
|
||||
*
|
||||
* @param isLowEntropyKey True if the channel uses a low entropy key (not securely encrypted).
|
||||
* @param isPreciseLocation True if precise location is enabled.
|
||||
|
|
@ -196,15 +196,13 @@ private fun determineSecurityState(
|
|||
}
|
||||
|
||||
/**
|
||||
* Displays an icon representing the security status of a channel.
|
||||
* Clicking the icon shows a detailed help dialog.
|
||||
* Displays an icon representing the security status of a channel. Clicking the icon shows a detailed help dialog.
|
||||
*
|
||||
* @param securityState The current [SecurityState] to display.
|
||||
* @param baseContentDescription The base content description for the icon, to which the specific
|
||||
* state description will be appended. Defaults to a generic security icon description.
|
||||
* @param externalOnClick Optional lambda to be invoked when the icon is clicked,
|
||||
* in addition to its primary action (showing a help dialog).
|
||||
* This allows callers to inject custom side effects.
|
||||
* @param baseContentDescription The base content description for the icon, to which the specific state description will
|
||||
* be appended. Defaults to a generic security icon description.
|
||||
* @param externalOnClick Optional lambda to be invoked when the icon is clicked, in addition to its primary action
|
||||
* (showing a help dialog). This allows callers to inject custom side effects.
|
||||
*/
|
||||
@Composable
|
||||
fun SecurityIcon(
|
||||
|
|
@ -213,8 +211,7 @@ fun SecurityIcon(
|
|||
externalOnClick: (() -> Unit)? = null,
|
||||
) {
|
||||
var showHelpDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val fullContentDescription =
|
||||
baseContentDescription + " " + stringResource(id = securityState.descriptionResId)
|
||||
val fullContentDescription = baseContentDescription + " " + stringResource(id = securityState.descriptionResId)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
|
|
@ -224,18 +221,15 @@ fun SecurityIcon(
|
|||
) {
|
||||
SecurityIconDisplay(
|
||||
icon = securityState.icon,
|
||||
mainIconTint = securityState.color,
|
||||
mainIconTint = securityState.color.invoke(),
|
||||
contentDescription = fullContentDescription,
|
||||
badgeIcon = securityState.badgeIcon,
|
||||
badgeIconColor = securityState.badgeIconColor,
|
||||
badgeIconColor = securityState.badgeIconColor.invoke(),
|
||||
)
|
||||
}
|
||||
|
||||
if (showHelpDialog) {
|
||||
SecurityHelpDialog(
|
||||
securityState = securityState,
|
||||
onDismiss = { showHelpDialog = false },
|
||||
)
|
||||
SecurityHelpDialog(securityState = securityState, onDismiss = { showHelpDialog = false })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,9 +240,8 @@ fun SecurityIcon(
|
|||
* @param isPreciseLocation Whether the channel has precise location enabled. Defaults to false.
|
||||
* @param isMqttEnabled Whether MQTT is enabled for the channel. Defaults to false.
|
||||
* @param baseContentDescription The base content description for the icon.
|
||||
* @param externalOnClick Optional lambda to be invoked when the icon is clicked,
|
||||
* in addition to its primary action (showing a help dialog).
|
||||
* This allows callers to inject custom side effects.
|
||||
* @param externalOnClick Optional lambda to be invoked when the icon is clicked, in addition to its primary action
|
||||
* (showing a help dialog). This allows callers to inject custom side effects.
|
||||
*/
|
||||
@Composable
|
||||
fun SecurityIcon(
|
||||
|
|
@ -267,13 +260,16 @@ fun SecurityIcon(
|
|||
}
|
||||
|
||||
/** Extension property to check if the channel uses a low entropy PSK (not securely encrypted). */
|
||||
val Channel.isLowEntropyKey: Boolean get() = settings.psk.size() <= 1
|
||||
val Channel.isLowEntropyKey: Boolean
|
||||
get() = settings.psk.size() <= 1
|
||||
|
||||
/** Extension property to check if the channel has precise location enabled. */
|
||||
val Channel.isPreciseLocation: Boolean get() = settings.moduleSettings.positionPrecision == PRECISE_POSITION_BITS
|
||||
val Channel.isPreciseLocation: Boolean
|
||||
get() = settings.moduleSettings.positionPrecision == PRECISE_POSITION_BITS
|
||||
|
||||
/** Extension property to check if MQTT is enabled for the channel. */
|
||||
val Channel.isMqttEnabled: Boolean get() = settings.uplinkEnabled
|
||||
val Channel.isMqttEnabled: Boolean
|
||||
get() = settings.uplinkEnabled
|
||||
|
||||
/**
|
||||
* Overload for [SecurityIcon] that takes a [Channel] object to determine its security state.
|
||||
|
|
@ -296,8 +292,8 @@ fun SecurityIcon(
|
|||
)
|
||||
|
||||
/**
|
||||
* Overload for [SecurityIcon] that takes an [AppOnlyProtos.ChannelSet] and a channel index.
|
||||
* If the channel at the given index is not found, nothing is rendered.
|
||||
* Overload for [SecurityIcon] that takes an [AppOnlyProtos.ChannelSet] and a channel index. If the channel at the given
|
||||
* index is not found, nothing is rendered.
|
||||
*
|
||||
* @param channelSet The set of channels.
|
||||
* @param channelIndex The index of the channel within the set.
|
||||
|
|
@ -321,9 +317,9 @@ fun SecurityIcon(
|
|||
}
|
||||
|
||||
/**
|
||||
* Overload for [SecurityIcon] that takes an [AppOnlyProtos.ChannelSet] and a channel name.
|
||||
* If a channel with the given name is not found, nothing is rendered.
|
||||
* This overload optimizes lookup by name by memoizing a map of channel names to settings.
|
||||
* Overload for [SecurityIcon] that takes an [AppOnlyProtos.ChannelSet] and a channel name. If a channel with the given
|
||||
* name is not found, nothing is rendered. This overload optimizes lookup by name by memoizing a map of channel names to
|
||||
* settings.
|
||||
*
|
||||
* @param channelSet The set of channels.
|
||||
* @param channelName The name of the channel to find.
|
||||
|
|
@ -337,11 +333,8 @@ fun SecurityIcon(
|
|||
baseContentDescription: String = stringResource(id = R.string.security_icon_description),
|
||||
externalOnClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val channelByNameMap = remember(channelSet) {
|
||||
channelSet.settingsList.associateBy {
|
||||
Channel(it, channelSet.loraConfig).name
|
||||
}
|
||||
}
|
||||
val channelByNameMap =
|
||||
remember(channelSet) { channelSet.settingsList.associateBy { Channel(it, channelSet.loraConfig).name } }
|
||||
|
||||
channelByNameMap[channelName]?.let { channelSetting ->
|
||||
SecurityIcon(
|
||||
|
|
@ -353,21 +346,19 @@ fun SecurityIcon(
|
|||
}
|
||||
|
||||
/**
|
||||
* Displays a help dialog explaining the meaning of different security icons.
|
||||
* The dialog can show details for a specific [SecurityState] or a list of all states.
|
||||
* Displays a help dialog explaining the meaning of different security icons. The dialog can show details for a specific
|
||||
* [SecurityState] or a list of all states.
|
||||
*
|
||||
* @param securityState The initial security state to display contextually.
|
||||
* @param onDismiss Lambda invoked when the dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecurityHelpDialog(
|
||||
securityState: SecurityState,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
private fun SecurityHelpDialog(securityState: SecurityState, onDismiss: () -> Unit) {
|
||||
var showAll by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
modifier = if (showAll) {
|
||||
modifier =
|
||||
if (showAll) {
|
||||
Modifier.fillMaxSize()
|
||||
} else {
|
||||
Modifier
|
||||
|
|
@ -404,9 +395,7 @@ private fun SecurityHelpDialog(
|
|||
},
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.security_icon_help_dismiss))
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.security_icon_help_dismiss)) }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -422,24 +411,20 @@ private fun ContextualSecurityState(securityState: SecurityState) {
|
|||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
SecurityIconDisplay(
|
||||
icon = securityState.icon,
|
||||
mainIconTint = securityState.color,
|
||||
mainIconTint = securityState.color.invoke(),
|
||||
contentDescription = stringResource(securityState.descriptionResId),
|
||||
modifier = Modifier.size(48.dp),
|
||||
badgeIcon = securityState.badgeIcon,
|
||||
badgeIconColor = securityState.badgeIconColor,
|
||||
badgeIconColor = securityState.badgeIconColor.invoke(),
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(securityState.helpTextResId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(text = stringResource(securityState.helpTextResId), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a list of all possible security states with their icons and descriptions
|
||||
* within the help dialog. Iterates over `SecurityState.entries` which is provided
|
||||
* by the enum class.
|
||||
* Displays a list of all possible security states with their icons and descriptions within the help dialog. Iterates
|
||||
* over `SecurityState.entries` which is provided by the enum class.
|
||||
*/
|
||||
@Composable
|
||||
private fun AllSecurityStates() {
|
||||
|
|
@ -447,25 +432,20 @@ private fun AllSecurityStates() {
|
|||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
SecurityState.entries.forEach { state -> // Uses enum entries
|
||||
SecurityState.entries.forEach { state ->
|
||||
// Uses enum entries
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SecurityIconDisplay(
|
||||
icon = state.icon,
|
||||
mainIconTint = state.color,
|
||||
mainIconTint = state.color.invoke(),
|
||||
contentDescription = stringResource(state.descriptionResId),
|
||||
modifier = Modifier.size(48.dp),
|
||||
badgeIcon = state.badgeIcon,
|
||||
badgeIconColor = state.badgeIconColor,
|
||||
badgeIconColor = state.badgeIconColor.invoke(),
|
||||
)
|
||||
Column(modifier = Modifier.padding(start = 16.dp)) {
|
||||
Text(
|
||||
text = stringResource(state.descriptionResId),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(state.helpTextResId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(text = stringResource(state.descriptionResId), style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = stringResource(state.helpTextResId), style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
if (state != SecurityState.entries.lastOrNull()) {
|
||||
|
|
@ -505,7 +485,8 @@ private fun PreviewMqttEnabled() {
|
|||
@Composable
|
||||
private fun PreviewAllSecurityIconsWithDialog() {
|
||||
var showHelpDialogFor by remember { mutableStateOf<SecurityState?>(null) }
|
||||
val stateLabels = remember { // Using SecurityState.entries to build the map keys
|
||||
val stateLabels = remember {
|
||||
// Using SecurityState.entries to build the map keys
|
||||
mapOf(
|
||||
SecurityState.SECURE to "Secure",
|
||||
SecurityState.INSECURE_NO_PRECISE to "Insecure (No Precise Location)",
|
||||
|
|
@ -519,30 +500,16 @@ private fun PreviewAllSecurityIconsWithDialog() {
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Security Icons Preview (Click for Help)",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Text(text = "Security Icons Preview (Click for Help)", style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
SecurityState.entries.forEach { state -> // Iterate over enum entries
|
||||
val label =
|
||||
stateLabels[state] ?: "Unknown State (${state.name})" // Fallback to enum name
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SecurityIcon(
|
||||
securityState = state,
|
||||
externalOnClick = { showHelpDialogFor = state },
|
||||
)
|
||||
SecurityState.entries.forEach { state ->
|
||||
// Iterate over enum entries
|
||||
val label = stateLabels[state] ?: "Unknown State (${state.name})" // Fallback to enum name
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
SecurityIcon(securityState = state, externalOnClick = { showHelpDialogFor = state })
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
showHelpDialogFor?.let {
|
||||
SecurityHelpDialog(
|
||||
securityState = it,
|
||||
onDismiss = { showHelpDialogFor = null },
|
||||
)
|
||||
}
|
||||
showHelpDialogFor?.let { SecurityHelpDialog(securityState = it, onDismiss = { showHelpDialogFor = null }) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,18 +19,6 @@ package com.geeksville.mesh.ui.common.theme
|
|||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val MeshtasticGreen = Color(0xFF67EA94)
|
||||
val MeshtasticAlt = Color(0xFF2C2D3C)
|
||||
|
||||
val Yellow = Color(red = 255, green = 230, blue = 0)
|
||||
val Orange = Color(red = 247, green = 147, blue = 26)
|
||||
val Green = Color(0xFF30C047)
|
||||
|
||||
val HyperlinkBlue = Color(0xFF43C3B0)
|
||||
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
|
||||
val Purple = Color(0xFF9C27B0)
|
||||
val Pink = Color(red = 255, green = 102, blue = 204)
|
||||
|
||||
val primaryLight = Color(0xFF306A42)
|
||||
val onPrimaryLight = Color(0xFFFFFFFF)
|
||||
val primaryContainerLight = Color(0xFFB3F1BF)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
val MeshtasticGreen = Color(0xFF67EA94)
|
||||
val MeshtasticAlt = Color(0xFF2C2D3C)
|
||||
val HyperlinkBlue = Color(0xFF43C3B0)
|
||||
|
||||
object IAQColors {
|
||||
val IAQExcellent = Color(0xFF00E400)
|
||||
val IAQGood = Color(0xFF92D050)
|
||||
val IAQLightlyPolluted = Color(0xFFFFFF00)
|
||||
val IAQModeratelyPolluted = Color(0xFFFF7300)
|
||||
val IAQHeavilyPolluted = Color(0xFFFF0000)
|
||||
val IAQSeverelyPolluted = Color(0xFF99004C)
|
||||
val IAQExtremelyPolluted = Color(0xFF663300)
|
||||
val IAQDangerouslyPolluted = Color(0xFF663300)
|
||||
}
|
||||
|
||||
object GraphColors {
|
||||
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
|
||||
val Purple = Color(0xFF9C27B0)
|
||||
val Pink = Color(red = 255, green = 102, blue = 204)
|
||||
val Orange = Color(0xFFFF8800)
|
||||
|
||||
val Green = Color.Green
|
||||
val Red = Color.Red
|
||||
val Blue = Color.Blue
|
||||
val Yellow = Color.Yellow
|
||||
val Magenta = Color.Magenta
|
||||
val Cyan = Color.Cyan
|
||||
}
|
||||
|
||||
object StatusColors {
|
||||
val ColorScheme.StatusGreen: Color
|
||||
@Composable
|
||||
get() = // If it might change based on theme
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFF28A03B) // Example dark green
|
||||
} else {
|
||||
Color(0xFF30C047)
|
||||
}
|
||||
|
||||
val ColorScheme.StatusYellow: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFFFFC107)
|
||||
} else {
|
||||
Color(0xFFFFD54F)
|
||||
}
|
||||
|
||||
val ColorScheme.StatusOrange: Color
|
||||
@Composable
|
||||
get() =
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFFE07000)
|
||||
} else {
|
||||
Color(0xFFFF8800)
|
||||
}
|
||||
|
||||
val ColorScheme.StatusRed: Color
|
||||
@Composable
|
||||
get() = // If it might change based on theme
|
||||
if (isSystemInDarkTheme()) {
|
||||
Color(0xFFB00020)
|
||||
} else {
|
||||
Color(0xFFF44336)
|
||||
}
|
||||
}
|
||||
|
|
@ -35,11 +35,12 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.BTScanModel
|
||||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
|
|
@ -50,68 +51,70 @@ fun DeviceListItem(
|
|||
onSelect: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val icon = if (device.isBLE) {
|
||||
Icons.Default.Bluetooth
|
||||
} else if (device.isUSB) {
|
||||
Icons.Default.Usb
|
||||
} else if (device.isTCP) {
|
||||
Icons.Default.Wifi
|
||||
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
|
||||
Icons.Default.Cancel
|
||||
} else {
|
||||
Icons.Default.Add
|
||||
}
|
||||
|
||||
val contentDescription = if (device.isBLE) {
|
||||
stringResource(R.string.bluetooth)
|
||||
} else if (device.isUSB) {
|
||||
stringResource(R.string.serial)
|
||||
} else if (device.isTCP) {
|
||||
stringResource(R.string.network)
|
||||
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
|
||||
stringResource(R.string.disconnect)
|
||||
} else {
|
||||
stringResource(R.string.add)
|
||||
}
|
||||
|
||||
val colors = when {
|
||||
selected && device.isDisconnect -> {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
headlineColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
supportingColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
val icon =
|
||||
if (device.isBLE) {
|
||||
Icons.Default.Bluetooth
|
||||
} else if (device.isUSB) {
|
||||
Icons.Default.Usb
|
||||
} else if (device.isTCP) {
|
||||
Icons.Default.Wifi
|
||||
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
|
||||
Icons.Default.Cancel
|
||||
} else {
|
||||
Icons.Default.Add
|
||||
}
|
||||
|
||||
selected -> { // Standard selection for other device types
|
||||
ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
trailingIconColor = when (connectionState) {
|
||||
MeshService.ConnectionState.CONNECTED -> Color(color = 0xFF30C047)
|
||||
MeshService.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onPrimaryContainer // Fallback for other states (e.g. connecting)
|
||||
},
|
||||
)
|
||||
val contentDescription =
|
||||
if (device.isBLE) {
|
||||
stringResource(R.string.bluetooth)
|
||||
} else if (device.isUSB) {
|
||||
stringResource(R.string.serial)
|
||||
} else if (device.isTCP) {
|
||||
stringResource(R.string.network)
|
||||
} else if (device.isDisconnect) { // This is the "Disconnect" entry type
|
||||
stringResource(R.string.disconnect)
|
||||
} else {
|
||||
stringResource(R.string.add)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ListItemDefaults.colors()
|
||||
val colors =
|
||||
when {
|
||||
selected && device.isDisconnect -> {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
headlineColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
supportingColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
|
||||
selected -> { // Standard selection for other device types
|
||||
ListItemDefaults.colors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
headlineColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
trailingIconColor =
|
||||
when (connectionState) {
|
||||
MeshService.ConnectionState.CONNECTED -> MaterialTheme.colorScheme.StatusGreen
|
||||
MeshService.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.StatusRed
|
||||
else ->
|
||||
MaterialTheme.colorScheme
|
||||
.onPrimaryContainer // Fallback for other states (e.g. connecting)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
ListItemDefaults.colors()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val useSelectable = modifier == Modifier
|
||||
ListItem(
|
||||
modifier = if (useSelectable) {
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = selected,
|
||||
onClick = onSelect,
|
||||
)
|
||||
modifier =
|
||||
if (useSelectable) {
|
||||
modifier.fillMaxWidth().selectable(selected = selected, onClick = onSelect)
|
||||
} else {
|
||||
modifier.fillMaxWidth()
|
||||
},
|
||||
|
|
@ -119,7 +122,7 @@ fun DeviceListItem(
|
|||
leadingContent = {
|
||||
Icon(
|
||||
icon, // icon is already CloudOff if device.isDisconnect
|
||||
contentDescription
|
||||
contentDescription,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
|
|
@ -129,15 +132,9 @@ fun DeviceListItem(
|
|||
},
|
||||
trailingContent = {
|
||||
if (device.isDisconnect) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudOff,
|
||||
contentDescription = stringResource(R.string.disconnect),
|
||||
)
|
||||
Icon(imageVector = Icons.Default.CloudOff, contentDescription = stringResource(R.string.disconnect))
|
||||
} else if (connectionState == MeshService.ConnectionState.CONNECTED) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudDone,
|
||||
contentDescription = stringResource(R.string.connected),
|
||||
)
|
||||
Icon(imageVector = Icons.Default.CloudDone, contentDescription = stringResource(R.string.connected))
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CloudQueue,
|
||||
|
|
@ -145,6 +142,6 @@ fun DeviceListItem(
|
|||
)
|
||||
}
|
||||
},
|
||||
colors = colors
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import androidx.compose.ui.platform.LocalWindowInfo
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -67,75 +68,70 @@ import com.geeksville.mesh.ui.common.components.BatteryInfo
|
|||
import com.geeksville.mesh.ui.common.components.OptionLabel
|
||||
import com.geeksville.mesh.ui.common.components.SlidingSelector
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.common.theme.Orange
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Cyan
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Orange
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.util.GraphUtil
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import com.geeksville.mesh.util.GraphUtil.plotPoint
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
|
||||
private enum class Device(val color: Color) {
|
||||
BATTERY(Color.Green),
|
||||
CH_UTIL(Color.Magenta),
|
||||
AIR_UTIL(Color.Cyan)
|
||||
BATTERY(Green),
|
||||
CH_UTIL(Magenta),
|
||||
AIR_UTIL(Cyan),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private val LEGEND_DATA = listOf(
|
||||
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true),
|
||||
LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color),
|
||||
LegendData(nameRes = R.string.air_utilization, color = Device.AIR_UTIL.color),
|
||||
)
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true),
|
||||
LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color),
|
||||
LegendData(nameRes = R.string.air_utilization, color = Device.AIR_UTIL.color),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DeviceMetricsScreen(
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.deviceMetricsFiltered(selectedTimeFrame)
|
||||
|
||||
Column {
|
||||
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes = listOf(
|
||||
pairedRes =
|
||||
listOf(
|
||||
Pair(R.string.channel_utilization, R.string.ch_util_definition),
|
||||
Pair(R.string.air_utilization, R.string.air_util_definition)
|
||||
Pair(R.string.air_utilization, R.string.air_util_definition),
|
||||
),
|
||||
onDismiss = { displayInfoDialog = false }
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.33f),
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true }
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) }
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
/* Device Metric Cards */
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(data) { telemetry -> DeviceMetricsCard(telemetry) }
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,26 +141,20 @@ private fun DeviceMetricsChart(
|
|||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) return
|
||||
|
||||
val (oldest, newest) = remember(key1 = telemetries) {
|
||||
Pair(
|
||||
telemetries.minBy { it.time },
|
||||
telemetries.maxBy { it.time }
|
||||
)
|
||||
}
|
||||
val (oldest, newest) =
|
||||
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
|
||||
val timeDiff = newest.time - oldest.time
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong()))
|
||||
}
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
|
|
@ -180,21 +170,15 @@ private fun DeviceMetricsChart(
|
|||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(
|
||||
oldest = visibleTimeRange.first,
|
||||
newest = visibleTimeRange.second,
|
||||
)
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(state = scrollState, reverseScrolling = true)
|
||||
.weight(weight = 1f)
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
|
||||
) {
|
||||
|
||||
/*
|
||||
* The order of the colors are with respect to the ChUtil.
|
||||
* 25 - 49 Orange
|
||||
|
|
@ -205,16 +189,10 @@ private fun DeviceMetricsChart(
|
|||
lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor),
|
||||
)
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier.width(dp),
|
||||
oldest = oldest.time,
|
||||
newest = newest.time,
|
||||
selectedTime.lineInterval()
|
||||
)
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
||||
/* Plot Battery Line, ChUtil, and AirUtilTx */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
for (i in telemetries.indices) {
|
||||
|
|
@ -230,7 +208,7 @@ private fun DeviceMetricsChart(
|
|||
color = Device.CH_UTIL.color,
|
||||
x = x,
|
||||
value = telemetry.deviceMetrics.channelUtilization,
|
||||
divisor = MAX_PERCENT_VALUE
|
||||
divisor = MAX_PERCENT_VALUE,
|
||||
)
|
||||
|
||||
/* Air Utilization Transmit */
|
||||
|
|
@ -239,7 +217,7 @@ private fun DeviceMetricsChart(
|
|||
color = Device.AIR_UTIL.color,
|
||||
x = x,
|
||||
value = telemetry.deviceMetrics.airUtilTx,
|
||||
divisor = MAX_PERCENT_VALUE
|
||||
divisor = MAX_PERCENT_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -247,37 +225,30 @@ private fun DeviceMetricsChart(
|
|||
var index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index = createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold()
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Device.BATTERY.color,
|
||||
style = Stroke(
|
||||
width = GraphUtil.RADIUS,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
graphColor,
|
||||
minValue = 0f,
|
||||
maxValue = 100f
|
||||
)
|
||||
YAxisLabels(modifier = modifier.weight(weight = Y_AXIS_WEIGHT), graphColor, minValue = 0f, maxValue = 100f)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -291,25 +262,26 @@ private fun DeviceMetricsChart(
|
|||
@Composable
|
||||
private fun DeviceMetricsChartPreview() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val telemetries = List(20) { i ->
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(80 - i)
|
||||
.setVoltage(3.7f - i * 0.02f)
|
||||
.setChannelUtilization(10f + i * 2)
|
||||
.setAirUtilTx(5f + i)
|
||||
.setUptimeSeconds(3600 + i * 300)
|
||||
)
|
||||
.build()
|
||||
}
|
||||
val telemetries =
|
||||
List(20) { i ->
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(80 - i)
|
||||
.setVoltage(3.7f - i * 0.02f)
|
||||
.setChannelUtilization(10f + i * 2)
|
||||
.setAirUtilTx(5f + i)
|
||||
.setUptimeSeconds(3600 + i * 300),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
AppTheme {
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier.height(400.dp),
|
||||
telemetries = telemetries,
|
||||
selectedTime = TimeFrame.TWENTY_FOUR_HOURS,
|
||||
promptInfoDialog = {}
|
||||
promptInfoDialog = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -318,51 +290,32 @@ private fun DeviceMetricsChartPreview() {
|
|||
private fun DeviceMetricsCard(telemetry: Telemetry) {
|
||||
val deviceMetrics = telemetry.deviceMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
/* Time, Battery, and Voltage */
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
|
||||
BatteryInfo(
|
||||
batteryLevel = deviceMetrics.batteryLevel,
|
||||
voltage = deviceMetrics.voltage
|
||||
)
|
||||
BatteryInfo(batteryLevel = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
/* Channel Utilization and Air Utilization Tx */
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val text = stringResource(R.string.channel_air_util).format(
|
||||
deviceMetrics.channelUtilization,
|
||||
deviceMetrics.airUtilTx
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val text =
|
||||
stringResource(R.string.channel_air_util)
|
||||
.format(deviceMetrics.channelUtilization, deviceMetrics.airUtilTx)
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -376,20 +329,19 @@ private fun DeviceMetricsCard(telemetry: Telemetry) {
|
|||
@Composable
|
||||
private fun DeviceMetricsCardPreview() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val telemetry = Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(75)
|
||||
.setVoltage(3.65f)
|
||||
.setChannelUtilization(22.5f)
|
||||
.setAirUtilTx(12.0f)
|
||||
.setUptimeSeconds(7200)
|
||||
)
|
||||
.build()
|
||||
AppTheme {
|
||||
DeviceMetricsCard(telemetry = telemetry)
|
||||
}
|
||||
val telemetry =
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(75)
|
||||
.setVoltage(3.65f)
|
||||
.setChannelUtilization(22.5f)
|
||||
.setAirUtilTx(12.0f)
|
||||
.setUptimeSeconds(7200),
|
||||
)
|
||||
.build()
|
||||
AppTheme { DeviceMetricsCard(telemetry = telemetry) }
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
|
|
@ -397,19 +349,20 @@ private fun DeviceMetricsCardPreview() {
|
|||
@Composable
|
||||
private fun DeviceMetricsScreenPreview() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val telemetries = List(24) { i ->
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(85 - i * 2) // Battery decreases over time
|
||||
.setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly
|
||||
.setChannelUtilization(15f + i * 1.5f) // Channel utilization increases
|
||||
.setAirUtilTx(8f + i * 0.8f) // Air utilization increases
|
||||
.setUptimeSeconds(3600 + i * 3600) // Uptime increases by 1 hour each
|
||||
)
|
||||
.build()
|
||||
}
|
||||
val telemetries =
|
||||
List(24) { i ->
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(85 - i * 2) // Battery decreases over time
|
||||
.setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly
|
||||
.setChannelUtilization(15f + i * 1.5f) // Channel utilization increases
|
||||
.setAirUtilTx(8f + i * 0.8f) // Air utilization increases
|
||||
.setUptimeSeconds(3600 + i * 3600), // Uptime increases by 1 hour each
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
AppTheme {
|
||||
Surface {
|
||||
|
|
@ -418,35 +371,32 @@ private fun DeviceMetricsScreenPreview() {
|
|||
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes = listOf(
|
||||
pairedRes =
|
||||
listOf(
|
||||
Pair(R.string.channel_utilization, R.string.ch_util_definition),
|
||||
Pair(R.string.air_utilization, R.string.air_util_definition)
|
||||
Pair(R.string.air_utilization, R.string.air_util_definition),
|
||||
),
|
||||
onDismiss = { displayInfoDialog = false }
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.33f),
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries.reversed(),
|
||||
TimeFrame.TWENTY_FOUR_HOURS,
|
||||
promptInfoDialog = { displayInfoDialog = true }
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
TimeFrame.TWENTY_FOUR_HOURS,
|
||||
onOptionSelected = { /* Preview only */ }
|
||||
onOptionSelected = { /* Preview only */ },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
/* Device Metric Cards */
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,8 +68,13 @@ import com.geeksville.mesh.ui.common.components.IaqDisplayMode
|
|||
import com.geeksville.mesh.ui.common.components.IndoorAirQuality
|
||||
import com.geeksville.mesh.ui.common.components.OptionLabel
|
||||
import com.geeksville.mesh.ui.common.components.SlidingSelector
|
||||
import com.geeksville.mesh.ui.common.theme.Pink
|
||||
import com.geeksville.mesh.ui.common.theme.Purple
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Blue
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Pink
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Purple
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Yellow
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
|
|
@ -78,13 +83,13 @@ import com.geeksville.mesh.util.UnitConversions.celsiusToFahrenheit
|
|||
|
||||
@Suppress("MagicNumber")
|
||||
private enum class Environment(val color: Color) {
|
||||
TEMPERATURE(Color.Red),
|
||||
RELATIVE_HUMIDITY(Color.Blue),
|
||||
TEMPERATURE(Red),
|
||||
RELATIVE_HUMIDITY(Blue),
|
||||
SOIL_TEMPERATURE(Pink),
|
||||
SOIL_MOISTURE(Purple),
|
||||
BAROMETRIC_PRESSURE(Color.Green),
|
||||
GAS_RESISTANCE(Color.Yellow),
|
||||
IAQ(Color.Magenta)
|
||||
BAROMETRIC_PRESSURE(Green),
|
||||
GAS_RESISTANCE(Yellow),
|
||||
IAQ(Magenta),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
|
|
@ -92,105 +97,72 @@ private const val Y_AXIS_WEIGHT = 0.1f
|
|||
// EnvironmentMetrics can have 1 or 2 Y-axis labels depending on whether barometric pressure is plotted
|
||||
// We'll calculate this dynamically in the chart function
|
||||
|
||||
private val LEGEND_DATA_1 = listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.temperature,
|
||||
color = Environment.TEMPERATURE.color,
|
||||
isLine = true
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.humidity,
|
||||
color = Environment.HUMIDITY.color,
|
||||
isLine = true
|
||||
),
|
||||
)
|
||||
private val LEGEND_DATA_2 = listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.iaq,
|
||||
color = Environment.IAQ.color,
|
||||
isLine = true
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.baro_pressure,
|
||||
color = Environment.BAROMETRIC_PRESSURE.color,
|
||||
isLine = true
|
||||
private val LEGEND_DATA_1 =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.temperature, color = Environment.TEMPERATURE.color, isLine = true),
|
||||
LegendData(nameRes = R.string.humidity, color = Environment.HUMIDITY.color, isLine = true),
|
||||
)
|
||||
private val LEGEND_DATA_2 =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.iaq, color = Environment.IAQ.color, isLine = true),
|
||||
LegendData(nameRes = R.string.baro_pressure, color = Environment.BAROMETRIC_PRESSURE.color, isLine = true),
|
||||
)
|
||||
private val LEGEND_DATA_3 =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.soil_temperature, color = Environment.SOIL_TEMPERATURE.color, isLine = true),
|
||||
LegendData(nameRes = R.string.soil_moisture, color = Environment.SOIL_MOISTURE.color, isLine = true),
|
||||
)
|
||||
)
|
||||
private val LEGEND_DATA_3 = listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.soil_temperature,
|
||||
color = Environment.SOIL_TEMPERATURE.color,
|
||||
isLine = true
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.soil_moisture,
|
||||
color = Environment.SOIL_MOISTURE.color,
|
||||
isLine = true
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EnvironmentMetricsScreen(
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
|
||||
val data = graphData.metrics
|
||||
|
||||
val processedTelemetries: List<Telemetry> = if (state.isFahrenheit) {
|
||||
data.map { telemetry ->
|
||||
val temperatureFahrenheit =
|
||||
celsiusToFahrenheit(telemetry.environmentMetrics.temperature)
|
||||
val soilTemperatureFahrenheit =
|
||||
celsiusToFahrenheit(telemetry.environmentMetrics.soilTemperature)
|
||||
telemetry.copy {
|
||||
environmentMetrics = telemetry.environmentMetrics.copy {
|
||||
temperature = temperatureFahrenheit }
|
||||
environmentMetrics = telemetry.environmentMetrics.copy {
|
||||
soilTemperature = soilTemperatureFahrenheit }
|
||||
val processedTelemetries: List<Telemetry> =
|
||||
if (state.isFahrenheit) {
|
||||
data.map { telemetry ->
|
||||
val temperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.temperature)
|
||||
val soilTemperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.soilTemperature)
|
||||
telemetry.copy {
|
||||
environmentMetrics = telemetry.environmentMetrics.copy { temperature = temperatureFahrenheit }
|
||||
environmentMetrics =
|
||||
telemetry.environmentMetrics.copy { soilTemperature = soilTemperatureFahrenheit }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data
|
||||
}
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
Column {
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes = listOf(
|
||||
Pair(R.string.iaq, R.string.iaq_definition)
|
||||
),
|
||||
onDismiss = { displayInfoDialog = false }
|
||||
pairedRes = listOf(Pair(R.string.iaq, R.string.iaq_definition)),
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentMetricsChart(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.33f),
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = processedTelemetries.reversed(),
|
||||
graphData = graphData,
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true }
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) }
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(processedTelemetries) { telemetry ->
|
||||
EnvironmentMetricsCard(telemetry, state.isFahrenheit)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -203,7 +175,7 @@ private fun EnvironmentMetricsChart(
|
|||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
|
|
@ -215,9 +187,7 @@ private fun EnvironmentMetricsChart(
|
|||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong()))
|
||||
}
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
|
||||
|
|
@ -237,10 +207,7 @@ private fun EnvironmentMetricsChart(
|
|||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(
|
||||
oldest = visibleTimeRange.first,
|
||||
newest = visibleTimeRange.second
|
||||
)
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -257,26 +224,20 @@ private fun EnvironmentMetricsChart(
|
|||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Environment.BAROMETRIC_PRESSURE.color,
|
||||
minValue = pressureMin,
|
||||
maxValue = pressureMax
|
||||
maxValue = pressureMax,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(state = scrollState, reverseScrolling = true)
|
||||
.weight(weight = 1f)
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
|
||||
) {
|
||||
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = List(size = 5) { graphColor }
|
||||
)
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier = modifier.width(dp),
|
||||
oldest = oldest,
|
||||
newest = newest,
|
||||
selectedTime.lineInterval()
|
||||
selectedTime.lineInterval(),
|
||||
)
|
||||
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
|
|
@ -286,7 +247,6 @@ private fun EnvironmentMetricsChart(
|
|||
var index: Int
|
||||
var first: Int
|
||||
for (metric in Environment.entries) {
|
||||
|
||||
if (!shouldPlot[metric.ordinal]) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -298,26 +258,27 @@ private fun EnvironmentMetricsChart(
|
|||
while (index < telemetries.size) {
|
||||
first = index
|
||||
val path = Path()
|
||||
index = createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold()
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (metric.getValue(telemetry) - min) / diff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (metric.getValue(telemetry) - min) / diff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPathWithGradient(
|
||||
path = path,
|
||||
color = metric.color,
|
||||
height = height,
|
||||
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
|
||||
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width
|
||||
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -327,7 +288,7 @@ private fun EnvironmentMetricsChart(
|
|||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
graphColor,
|
||||
minValue = rightMin,
|
||||
maxValue = rightMax
|
||||
maxValue = rightMax,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -345,91 +306,72 @@ private fun EnvironmentMetricsChart(
|
|||
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
/* Time and Temperature */
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
Text(
|
||||
text = textFormat.format(
|
||||
stringResource(id = R.string.temperature),
|
||||
envMetrics.temperature
|
||||
),
|
||||
text = textFormat.format(stringResource(id = R.string.temperature), envMetrics.temperature),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
/* Humidity and Barometric Pressure */
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.2f%%".format(
|
||||
stringResource(id = R.string.humidity),
|
||||
envMetrics.relativeHumidity,
|
||||
),
|
||||
text =
|
||||
"%s %.2f%%".format(stringResource(id = R.string.humidity), envMetrics.relativeHumidity),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
if (envMetrics.barometricPressure > 0) {
|
||||
Text(
|
||||
text = "%.2f hPa".format(envMetrics.barometricPressure),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/* Soil Moisture and Soil Temperature */
|
||||
val soilMoistureRange = 0..100
|
||||
if (telemetry.environmentMetrics.hasSoilTemperature() ||
|
||||
telemetry.environmentMetrics.soilMoisture in soilMoistureRange) {
|
||||
if (
|
||||
telemetry.environmentMetrics.hasSoilTemperature() ||
|
||||
telemetry.environmentMetrics.soilMoisture in soilMoistureRange
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val soilTemperatureTextFormat =
|
||||
if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
val soilMoistureTextFormat = "%s %d%%"
|
||||
Text(
|
||||
text = soilMoistureTextFormat.format(
|
||||
text =
|
||||
soilMoistureTextFormat.format(
|
||||
stringResource(R.string.soil_moisture),
|
||||
envMetrics.soilMoisture
|
||||
envMetrics.soilMoisture,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(
|
||||
text = soilTemperatureTextFormat.format(
|
||||
text =
|
||||
soilTemperatureTextFormat.format(
|
||||
stringResource(R.string.soil_temperature),
|
||||
envMetrics.soilTemperature
|
||||
envMetrics.soilTemperature,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -437,21 +379,14 @@ private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahre
|
|||
if (telemetry.environmentMetrics.hasIaq()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
/* Air Quality */
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.iaq),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IndoorAirQuality(
|
||||
iaq = telemetry.environmentMetrics.iaq,
|
||||
displayMode = IaqDisplayMode.Dot
|
||||
)
|
||||
IndoorAirQuality(iaq = telemetry.environmentMetrics.iaq, displayMode = IaqDisplayMode.Dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,8 @@ import com.geeksville.mesh.model.MetricsViewModel
|
|||
import com.geeksville.mesh.model.TimeFrame
|
||||
import com.geeksville.mesh.ui.common.components.OptionLabel
|
||||
import com.geeksville.mesh.ui.common.components.SlidingSelector
|
||||
import com.geeksville.mesh.ui.common.theme.InfantryBlue
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.InfantryBlue
|
||||
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.util.GraphUtil
|
||||
|
|
@ -74,70 +75,57 @@ import com.geeksville.mesh.util.GraphUtil.createPath
|
|||
@Suppress("MagicNumber")
|
||||
private enum class Power(val color: Color, val min: Float, val max: Float) {
|
||||
CURRENT(InfantryBlue, -500f, 500f),
|
||||
VOLTAGE(Color.Red, 0f, 20f);
|
||||
VOLTAGE(Red, 0f, 20f),
|
||||
;
|
||||
|
||||
/**
|
||||
* Difference between the metrics `max` and `min` values.
|
||||
*/
|
||||
/** Difference between the metrics `max` and `min` values. */
|
||||
fun difference() = max - min
|
||||
}
|
||||
|
||||
private enum class PowerChannel(@StringRes val strRes: Int) {
|
||||
ONE(R.string.channel_1),
|
||||
TWO(R.string.channel_2),
|
||||
THREE(R.string.channel_3)
|
||||
THREE(R.string.channel_3),
|
||||
}
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
|
||||
|
||||
private val LEGEND_DATA = listOf(
|
||||
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true),
|
||||
LegendData(nameRes = R.string.voltage, color = Power.VOLTAGE.color, isLine = true),
|
||||
)
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true),
|
||||
LegendData(nameRes = R.string.voltage, color = Power.VOLTAGE.color, isLine = true),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PowerMetricsScreen(
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
|
||||
val data = state.powerMetricsFiltered(selectedTimeFrame)
|
||||
|
||||
Column {
|
||||
|
||||
PowerMetricsChart(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(fraction = 0.33f),
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
selectedChannel,
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
PowerChannel.entries.toList(),
|
||||
selectedChannel,
|
||||
onOptionSelected = { selectedChannel = it }
|
||||
) {
|
||||
SlidingSelector(PowerChannel.entries.toList(), selectedChannel, onOptionSelected = { selectedChannel = it }) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) }
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(data) { telemetry -> PowerMetricsCard(telemetry) }
|
||||
}
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,19 +142,16 @@ private fun PowerMetricsChart(
|
|||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) = remember(key1 = telemetries) {
|
||||
Pair(
|
||||
telemetries.minBy { it.time },
|
||||
telemetries.maxBy { it.time }
|
||||
)
|
||||
}
|
||||
val (oldest, newest) =
|
||||
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
|
||||
val timeDiff = newest.time - oldest.time
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
|
||||
}
|
||||
val dp by
|
||||
remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
|
||||
}
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
|
|
@ -182,10 +167,7 @@ private fun PowerMetricsChart(
|
|||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(
|
||||
oldest = visibleTimeRange.first,
|
||||
newest = visibleTimeRange.second
|
||||
)
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
|
|
@ -202,21 +184,11 @@ private fun PowerMetricsChart(
|
|||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier
|
||||
.horizontalScroll(state = scrollState, reverseScrolling = true)
|
||||
.weight(1f)
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = List(size = 5) { graphColor },
|
||||
)
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier.width(dp),
|
||||
oldest = oldest.time,
|
||||
newest = newest.time,
|
||||
selectedTime.lineInterval()
|
||||
)
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
||||
/* Plot */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
|
|
@ -226,57 +198,50 @@ private fun PowerMetricsChart(
|
|||
var index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index = createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold()
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = retrieveVoltage(selectedChannel, telemetry) / voltageDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = retrieveVoltage(selectedChannel, telemetry) / voltageDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Power.VOLTAGE.color,
|
||||
style = Stroke(
|
||||
width = GraphUtil.RADIUS,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
/* Current */
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index = createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold()
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (retrieveCurrent(
|
||||
selectedChannel,
|
||||
telemetry
|
||||
) - Power.CURRENT.min) / currentDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (retrieveCurrent(selectedChannel, telemetry) - Power.CURRENT.min) / currentDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Power.CURRENT.color,
|
||||
style = Stroke(
|
||||
width = GraphUtil.RADIUS,
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -299,51 +264,39 @@ private fun PowerMetricsChart(
|
|||
@Composable
|
||||
private fun PowerMetricsCard(telemetry: Telemetry) {
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
/* Time */
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) {
|
||||
PowerChannelColumn(
|
||||
R.string.channel_1,
|
||||
telemetry.powerMetrics.ch1Voltage,
|
||||
telemetry.powerMetrics.ch1Current
|
||||
telemetry.powerMetrics.ch1Current,
|
||||
)
|
||||
}
|
||||
if (telemetry.powerMetrics.hasCh2Current() || telemetry.powerMetrics.hasCh2Voltage()) {
|
||||
PowerChannelColumn(
|
||||
R.string.channel_2,
|
||||
telemetry.powerMetrics.ch2Voltage,
|
||||
telemetry.powerMetrics.ch2Current
|
||||
telemetry.powerMetrics.ch2Current,
|
||||
)
|
||||
}
|
||||
if (telemetry.powerMetrics.hasCh3Current() || telemetry.powerMetrics.hasCh3Voltage()) {
|
||||
PowerChannelColumn(
|
||||
R.string.channel_3,
|
||||
telemetry.powerMetrics.ch3Voltage,
|
||||
telemetry.powerMetrics.ch3Current
|
||||
telemetry.powerMetrics.ch3Current,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -360,39 +313,31 @@ private fun PowerChannelColumn(@StringRes titleRes: Int, voltage: Float, current
|
|||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(
|
||||
text = "%.2fV".format(voltage),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(
|
||||
text = "%.1fmA".format(current),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the appropriate voltage depending on `channelSelected`.
|
||||
*/
|
||||
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float {
|
||||
return when (channelSelected) {
|
||||
PowerChannel.ONE -> telemetry.powerMetrics.ch1Voltage
|
||||
PowerChannel.TWO -> telemetry.powerMetrics.ch2Voltage
|
||||
PowerChannel.THREE -> telemetry.powerMetrics.ch3Voltage
|
||||
}
|
||||
/** Retrieves the appropriate voltage depending on `channelSelected`. */
|
||||
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
|
||||
PowerChannel.ONE -> telemetry.powerMetrics.ch1Voltage
|
||||
PowerChannel.TWO -> telemetry.powerMetrics.ch2Voltage
|
||||
PowerChannel.THREE -> telemetry.powerMetrics.ch3Voltage
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the appropriate current depending on `channelSelected`.
|
||||
*/
|
||||
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float {
|
||||
return when (channelSelected) {
|
||||
PowerChannel.ONE -> telemetry.powerMetrics.ch1Current
|
||||
PowerChannel.TWO -> telemetry.powerMetrics.ch2Current
|
||||
PowerChannel.THREE -> telemetry.powerMetrics.ch3Current
|
||||
}
|
||||
/** Retrieves the appropriate current depending on `channelSelected`. */
|
||||
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
|
||||
PowerChannel.ONE -> telemetry.powerMetrics.ch1Current
|
||||
PowerChannel.TWO -> telemetry.powerMetrics.ch2Current
|
||||
PowerChannel.THREE -> telemetry.powerMetrics.ch3Current
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import androidx.compose.material3.HorizontalDivider
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
|
|
@ -142,9 +143,10 @@ import com.geeksville.mesh.service.ServiceAction
|
|||
import com.geeksville.mesh.ui.common.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.common.theme.Green
|
||||
import com.geeksville.mesh.ui.common.theme.Orange
|
||||
import com.geeksville.mesh.ui.common.theme.Yellow
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusOrange
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
|
||||
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.radioconfig.NavCard
|
||||
|
|
@ -188,23 +190,24 @@ fun NodeDetailScreen(
|
|||
val lastTracerouteTime by uiViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
|
||||
val ourNode by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
val availableLogs by remember(state, environmentState) {
|
||||
derivedStateOf {
|
||||
buildSet {
|
||||
if (state.hasDeviceMetrics()) add(LogsType.DEVICE)
|
||||
if (state.hasPositionLogs()) {
|
||||
add(LogsType.NODE_MAP)
|
||||
add(LogsType.POSITIONS)
|
||||
val availableLogs by
|
||||
remember(state, environmentState) {
|
||||
derivedStateOf {
|
||||
buildSet {
|
||||
if (state.hasDeviceMetrics()) add(LogsType.DEVICE)
|
||||
if (state.hasPositionLogs()) {
|
||||
add(LogsType.NODE_MAP)
|
||||
add(LogsType.POSITIONS)
|
||||
}
|
||||
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
|
||||
if (state.hasSignalMetrics()) add(LogsType.SIGNAL)
|
||||
if (state.hasPowerMetrics()) add(LogsType.POWER)
|
||||
if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
|
||||
if (state.hasHostMetrics()) add(LogsType.HOST)
|
||||
if (state.hasPaxMetrics()) add(LogsType.PAX)
|
||||
}
|
||||
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
|
||||
if (state.hasSignalMetrics()) add(LogsType.SIGNAL)
|
||||
if (state.hasPowerMetrics()) add(LogsType.POWER)
|
||||
if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
|
||||
if (state.hasHostMetrics()) add(LogsType.HOST)
|
||||
if (state.hasPaxMetrics()) add(LogsType.PAX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val node = state.node
|
||||
if (node != null) {
|
||||
|
|
@ -229,9 +232,7 @@ fun NodeDetailScreen(
|
|||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,17 +270,21 @@ private fun handleNodeAction(
|
|||
|
||||
sealed interface NodeDetailAction {
|
||||
data class Navigate(val route: Route) : NodeDetailAction
|
||||
|
||||
data class TriggerServiceAction(val action: ServiceAction) : NodeDetailAction
|
||||
|
||||
data class HandleNodeMenuAction(val action: NodeMenuAction) : NodeDetailAction
|
||||
|
||||
data object ShareContact : NodeDetailAction
|
||||
}
|
||||
|
||||
val Node.isEffectivelyUnmessageable: Boolean
|
||||
get() = if (user.hasIsUnmessagable()) {
|
||||
user.isUnmessagable
|
||||
} else {
|
||||
user.role?.isUnmessageableRole() == true
|
||||
}
|
||||
get() =
|
||||
if (user.hasIsUnmessagable()) {
|
||||
user.isUnmessagable
|
||||
} else {
|
||||
user.role?.isUnmessageableRole() == true
|
||||
}
|
||||
|
||||
private enum class LogsType(@StringRes val titleRes: Int, val icon: ImageVector, val route: Route) {
|
||||
DEVICE(R.string.device_metrics_log, Icons.Default.ChargingStation, NodeDetailRoutes.DeviceMetrics),
|
||||
|
|
@ -343,19 +348,14 @@ private fun NodeDetailList(
|
|||
|
||||
if (showFirmwareSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showFirmwareSheet = false },
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) {
|
||||
selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState())) {
|
||||
if (metricsState.deviceHardware != null) {
|
||||
PreferenceCategory(stringResource(R.string.device)) {
|
||||
DeviceDetailsContent(metricsState)
|
||||
}
|
||||
PreferenceCategory(stringResource(R.string.device)) { DeviceDetailsContent(metricsState) }
|
||||
}
|
||||
PreferenceCategory(stringResource(R.string.details)) {
|
||||
NodeDetailsContent(node, ourNode, metricsState.displayUnits)
|
||||
|
|
@ -406,11 +406,7 @@ private fun MetricsSection(
|
|||
PreferenceCategory(stringResource(id = R.string.logs)) {
|
||||
LogsType.entries.forEach { type ->
|
||||
if (availableLogs.contains(type)) {
|
||||
NavCard(
|
||||
title = stringResource(type.titleRes),
|
||||
icon = type.icon,
|
||||
enabled = true,
|
||||
) {
|
||||
NavCard(title = stringResource(type.titleRes), icon = type.icon, enabled = true) {
|
||||
onAction(NodeDetailAction.Navigate(type.route))
|
||||
}
|
||||
}
|
||||
|
|
@ -461,14 +457,14 @@ private fun AdministrationSection(
|
|||
label = stringResource(R.string.latest_stable_firmware),
|
||||
icon = Icons.Default.Memory,
|
||||
value = latestStable.id.substringBeforeLast(".").replace("v", ""),
|
||||
iconTint = Green,
|
||||
iconTint = colorScheme.StatusGreen,
|
||||
onClick = { onFirmwareSelected(latestStable) },
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.latest_alpha_firmware),
|
||||
icon = Icons.Default.Memory,
|
||||
value = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
|
||||
iconTint = Yellow,
|
||||
iconTint = colorScheme.StatusYellow,
|
||||
onClick = { onFirmwareSelected(latestAlpha) },
|
||||
)
|
||||
}
|
||||
|
|
@ -483,11 +479,11 @@ private fun DeviceVersion.determineFirmwareStatusColor(
|
|||
val stableVersion = latestStable.asDeviceVersion()
|
||||
val alphaVersion = latestAlpha.asDeviceVersion()
|
||||
return when {
|
||||
this < stableVersion -> MaterialTheme.colorScheme.error
|
||||
this == stableVersion -> Green
|
||||
this in stableVersion..alphaVersion -> Yellow
|
||||
this > alphaVersion -> Orange
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
this < stableVersion -> colorScheme.StatusRed
|
||||
this == stableVersion -> colorScheme.StatusGreen
|
||||
this in stableVersion..alphaVersion -> colorScheme.StatusYellow
|
||||
this > alphaVersion -> colorScheme.StatusOrange
|
||||
else -> colorScheme.onSurface
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -495,19 +491,13 @@ private fun DeviceVersion.determineFirmwareStatusColor(
|
|||
private fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
modifier = Modifier.verticalScroll(rememberScrollState()).padding(16.dp).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(text = firmwareRelease.title, style = MaterialTheme.typography.titleLarge)
|
||||
Text(text = "Version: ${firmwareRelease.id}", style = MaterialTheme.typography.bodyMedium)
|
||||
Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.pageUrl.toUri())
|
||||
|
|
@ -515,10 +505,7 @@ private fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease) {
|
|||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = stringResource(id = R.string.view_release),
|
||||
)
|
||||
Icon(imageVector = Icons.Default.Link, contentDescription = stringResource(id = R.string.view_release))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(id = R.string.view_release))
|
||||
}
|
||||
|
|
@ -529,10 +516,7 @@ private fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease) {
|
|||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(id = R.string.download),
|
||||
)
|
||||
Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = R.string.download))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = stringResource(id = R.string.download))
|
||||
}
|
||||
|
|
@ -550,12 +534,8 @@ private fun NodeDetailRow(
|
|||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.thenIf(onClick != null) {
|
||||
clickable(onClick = onClick!!)
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
modifier =
|
||||
modifier.fillMaxWidth().thenIf(onClick != null) { clickable(onClick = onClick!!) }.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
|
|
@ -624,11 +604,7 @@ private fun DeviceActions(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteDeviceActions(
|
||||
node: Node,
|
||||
lastTracerouteTime: Long?,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
) {
|
||||
private fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction: (NodeDetailAction) -> Unit) {
|
||||
if (!node.isEffectivelyUnmessageable) {
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.direct_message),
|
||||
|
|
@ -663,8 +639,8 @@ private fun DeviceDetailsContent(state: MetricsState) {
|
|||
val hwModelName = deviceHardware.displayName
|
||||
val isSupported = deviceHardware.activelySupported
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
modifier =
|
||||
Modifier.size(100.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color = Color(node.colors.second).copy(alpha = .5f), shape = CircleShape),
|
||||
|
|
@ -674,14 +650,15 @@ private fun DeviceDetailsContent(state: MetricsState) {
|
|||
}
|
||||
NodeDetailRow(label = stringResource(R.string.hardware), icon = Icons.Default.Router, value = hwModelName)
|
||||
NodeDetailRow(
|
||||
label = if (isSupported) {
|
||||
label =
|
||||
if (isSupported) {
|
||||
stringResource(R.string.supported)
|
||||
} else {
|
||||
stringResource(R.string.supported_by_community)
|
||||
},
|
||||
icon = if (isSupported) Icons.TwoTone.Verified else ImageVector.vectorResource(R.drawable.unverified),
|
||||
value = "",
|
||||
iconTint = if (isSupported) Color.Green else Color.Red,
|
||||
iconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -737,11 +714,7 @@ private fun EncryptionErrorContent() {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MainNodeDetails(
|
||||
node: Node,
|
||||
ourNode: Node?,
|
||||
displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits,
|
||||
) {
|
||||
private fun MainNodeDetails(node: Node, ourNode: Node?, displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits) {
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.long_name),
|
||||
icon = Icons.TwoTone.Person,
|
||||
|
|
@ -757,16 +730,8 @@ private fun MainNodeDetails(
|
|||
icon = Icons.Default.Numbers,
|
||||
value = node.num.toUInt().toString(),
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.user_id),
|
||||
icon = Icons.Default.Person,
|
||||
value = node.user.id,
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.role),
|
||||
icon = Icons.Default.Work,
|
||||
value = node.user.role.name,
|
||||
)
|
||||
NodeDetailRow(label = stringResource(R.string.user_id), icon = Icons.Default.Person, value = node.user.id)
|
||||
NodeDetailRow(label = stringResource(R.string.role), icon = Icons.Default.Work, value = node.user.role.name)
|
||||
if (node.isEffectivelyUnmessageable) {
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.unmonitored_or_infrastructure),
|
||||
|
|
@ -804,25 +769,12 @@ private fun MainNodeDetails(
|
|||
@Composable
|
||||
private fun InfoCard(icon: ImageVector, text: String, value: String, rotateIcon: Float = 0f) {
|
||||
Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(100.dp)
|
||||
.height(100.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
|
||||
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.thenIf(rotateIcon != 0f) {
|
||||
rotate(rotateIcon)
|
||||
},
|
||||
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
|
||||
)
|
||||
Text(
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
@ -843,32 +795,14 @@ private fun InfoCard(icon: ImageVector, text: String, value: String, rotateIcon:
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun DrawableInfoCard(
|
||||
@DrawableRes iconRes: Int,
|
||||
text: String,
|
||||
value: String,
|
||||
rotateIcon: Float = 0f,
|
||||
) {
|
||||
private fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
|
||||
Card(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(100.dp)
|
||||
.height(100.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Box(modifier = Modifier.padding(4.dp).width(100.dp).height(100.dp), contentAlignment = Alignment.Center) {
|
||||
Column(verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = text,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.thenIf(rotateIcon != 0f) {
|
||||
rotate(rotateIcon)
|
||||
},
|
||||
modifier = Modifier.size(24.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
|
||||
)
|
||||
Text(
|
||||
textAlign = TextAlign.Center,
|
||||
|
|
@ -895,118 +829,128 @@ private fun EnvironmentMetrics(
|
|||
isFahrenheit: Boolean = false,
|
||||
displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits,
|
||||
) {
|
||||
val vectorMetrics = remember(node.environmentMetrics, isFahrenheit, displayUnits) {
|
||||
buildList {
|
||||
with(node.environmentMetrics) {
|
||||
if (hasTemperature()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.temperature,
|
||||
temperature.toTempString(isFahrenheit),
|
||||
Icons.Default.Thermostat,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasRelativeHumidity()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.humidity,
|
||||
"%.0f%%".format(relativeHumidity),
|
||||
Icons.Default.WaterDrop,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasBarometricPressure()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.pressure,
|
||||
"%.0f hPa".format(barometricPressure),
|
||||
Icons.Default.Speed,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasGasResistance()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.gas_resistance,
|
||||
"%.0f MΩ".format(gasResistance),
|
||||
Icons.Default.BlurOn,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasVoltage()) add(VectorMetricInfo(R.string.voltage, "%.2fV".format(voltage), Icons.Default.Bolt))
|
||||
if (hasCurrent()) add(VectorMetricInfo(R.string.current, "%.1fmA".format(current), Icons.Default.Power))
|
||||
if (hasIaq()) add(VectorMetricInfo(R.string.iaq, iaq.toString(), Icons.Default.Air))
|
||||
if (hasDistance()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.distance,
|
||||
distance.toSmallDistanceString(displayUnits),
|
||||
Icons.Default.Height,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasLux()) add(VectorMetricInfo(R.string.lux, "%.0f lx".format(lux), Icons.Default.LightMode))
|
||||
if (hasUvLux()) add(VectorMetricInfo(R.string.uv_lux, "%.0f lx".format(uvLux), Icons.Default.LightMode))
|
||||
if (hasWindSpeed()) {
|
||||
@Suppress("MagicNumber")
|
||||
val normalizedBearing = (windDirection + 180) % 360
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.wind,
|
||||
windSpeed.toSpeedString(displayUnits),
|
||||
Icons.Outlined.Navigation,
|
||||
normalizedBearing.toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasWeight()) add(VectorMetricInfo(R.string.weight, "%.2f kg".format(weight), Icons.Default.Scale))
|
||||
}
|
||||
}
|
||||
}
|
||||
val drawableMetrics = remember(node.environmentMetrics, isFahrenheit) {
|
||||
buildList {
|
||||
with(node.environmentMetrics) {
|
||||
if (hasTemperature() && hasRelativeHumidity()) {
|
||||
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.dew_point,
|
||||
dewPoint.toTempString(isFahrenheit),
|
||||
R.drawable.ic_outlined_dew_point_24,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasSoilTemperature()) {
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.soil_temperature,
|
||||
soilTemperature.toTempString(isFahrenheit),
|
||||
R.drawable.soil_temperature,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasSoilMoisture()) {
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.soil_moisture,
|
||||
"%d%%".format(soilMoisture),
|
||||
R.drawable.soil_moisture,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasRadiation()) {
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.radiation,
|
||||
"%.1f µR/h".format(radiation),
|
||||
R.drawable.ic_filled_radioactive_24,
|
||||
),
|
||||
)
|
||||
val vectorMetrics =
|
||||
remember(node.environmentMetrics, isFahrenheit, displayUnits) {
|
||||
buildList {
|
||||
with(node.environmentMetrics) {
|
||||
if (hasTemperature()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.temperature,
|
||||
temperature.toTempString(isFahrenheit),
|
||||
Icons.Default.Thermostat,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasRelativeHumidity()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.humidity,
|
||||
"%.0f%%".format(relativeHumidity),
|
||||
Icons.Default.WaterDrop,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasBarometricPressure()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.pressure,
|
||||
"%.0f hPa".format(barometricPressure),
|
||||
Icons.Default.Speed,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasGasResistance()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.gas_resistance,
|
||||
"%.0f MΩ".format(gasResistance),
|
||||
Icons.Default.BlurOn,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasVoltage()) {
|
||||
add(VectorMetricInfo(R.string.voltage, "%.2fV".format(voltage), Icons.Default.Bolt))
|
||||
}
|
||||
if (hasCurrent()) {
|
||||
add(VectorMetricInfo(R.string.current, "%.1fmA".format(current), Icons.Default.Power))
|
||||
}
|
||||
if (hasIaq()) add(VectorMetricInfo(R.string.iaq, iaq.toString(), Icons.Default.Air))
|
||||
if (hasDistance()) {
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.distance,
|
||||
distance.toSmallDistanceString(displayUnits),
|
||||
Icons.Default.Height,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasLux()) add(VectorMetricInfo(R.string.lux, "%.0f lx".format(lux), Icons.Default.LightMode))
|
||||
if (hasUvLux()) {
|
||||
add(VectorMetricInfo(R.string.uv_lux, "%.0f lx".format(uvLux), Icons.Default.LightMode))
|
||||
}
|
||||
if (hasWindSpeed()) {
|
||||
@Suppress("MagicNumber")
|
||||
val normalizedBearing = (windDirection + 180) % 360
|
||||
add(
|
||||
VectorMetricInfo(
|
||||
R.string.wind,
|
||||
windSpeed.toSpeedString(displayUnits),
|
||||
Icons.Outlined.Navigation,
|
||||
normalizedBearing.toFloat(),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasWeight()) {
|
||||
add(VectorMetricInfo(R.string.weight, "%.2f kg".format(weight), Icons.Default.Scale))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val drawableMetrics =
|
||||
remember(node.environmentMetrics, isFahrenheit) {
|
||||
buildList {
|
||||
with(node.environmentMetrics) {
|
||||
if (hasTemperature() && hasRelativeHumidity()) {
|
||||
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.dew_point,
|
||||
dewPoint.toTempString(isFahrenheit),
|
||||
R.drawable.ic_outlined_dew_point_24,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasSoilTemperature()) {
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.soil_temperature,
|
||||
soilTemperature.toTempString(isFahrenheit),
|
||||
R.drawable.soil_temperature,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasSoilMoisture()) {
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.soil_moisture,
|
||||
"%d%%".format(soilMoisture),
|
||||
R.drawable.soil_moisture,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (hasRadiation()) {
|
||||
add(
|
||||
DrawableMetricInfo(
|
||||
R.string.radiation,
|
||||
"%.1f µR/h".format(radiation),
|
||||
R.drawable.ic_filled_radioactive_24,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
|
|
@ -1033,24 +977,25 @@ private fun EnvironmentMetrics(
|
|||
|
||||
@Composable
|
||||
private fun PowerMetrics(node: Node) {
|
||||
val metrics = remember(node.powerMetrics) {
|
||||
buildList {
|
||||
with(node.powerMetrics) {
|
||||
if (ch1Voltage != 0f) {
|
||||
add(VectorMetricInfo(R.string.channel_1, "%.2fV".format(ch1Voltage), Icons.Default.Bolt))
|
||||
add(VectorMetricInfo(R.string.channel_1, "%.1fmA".format(ch1Current), Icons.Default.Power))
|
||||
}
|
||||
if (ch2Voltage != 0f) {
|
||||
add(VectorMetricInfo(R.string.channel_2, "%.2fV".format(ch2Voltage), Icons.Default.Bolt))
|
||||
add(VectorMetricInfo(R.string.channel_2, "%.1fmA".format(ch2Current), Icons.Default.Power))
|
||||
}
|
||||
if (ch3Voltage != 0f) {
|
||||
add(VectorMetricInfo(R.string.channel_3, "%.2fV".format(ch3Voltage), Icons.Default.Bolt))
|
||||
add(VectorMetricInfo(R.string.channel_3, "%.1fmA".format(ch3Current), Icons.Default.Power))
|
||||
val metrics =
|
||||
remember(node.powerMetrics) {
|
||||
buildList {
|
||||
with(node.powerMetrics) {
|
||||
if (ch1Voltage != 0f) {
|
||||
add(VectorMetricInfo(R.string.channel_1, "%.2fV".format(ch1Voltage), Icons.Default.Bolt))
|
||||
add(VectorMetricInfo(R.string.channel_1, "%.1fmA".format(ch1Current), Icons.Default.Power))
|
||||
}
|
||||
if (ch2Voltage != 0f) {
|
||||
add(VectorMetricInfo(R.string.channel_2, "%.2fV".format(ch2Voltage), Icons.Default.Bolt))
|
||||
add(VectorMetricInfo(R.string.channel_2, "%.1fmA".format(ch2Current), Icons.Default.Power))
|
||||
}
|
||||
if (ch3Voltage != 0f) {
|
||||
add(VectorMetricInfo(R.string.channel_3, "%.2fV".format(ch3Voltage), Icons.Default.Bolt))
|
||||
add(VectorMetricInfo(R.string.channel_3, "%.1fmA".format(ch3Current), Icons.Default.Power))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
|
@ -1066,10 +1011,11 @@ private const val COOL_DOWN_TIME_MS = 30000L
|
|||
|
||||
@Composable
|
||||
fun TracerouteActionButton(title: String, lastTracerouteTime: Long?, onClick: () -> Unit) {
|
||||
var isCoolingDown by remember(lastTracerouteTime) {
|
||||
val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0)
|
||||
mutableStateOf(timeSinceLast < COOL_DOWN_TIME_MS)
|
||||
}
|
||||
var isCoolingDown by
|
||||
remember(lastTracerouteTime) {
|
||||
val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0)
|
||||
mutableStateOf(timeSinceLast < COOL_DOWN_TIME_MS)
|
||||
}
|
||||
|
||||
LaunchedEffect(lastTracerouteTime) {
|
||||
val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0)
|
||||
|
|
@ -1079,10 +1025,7 @@ fun TracerouteActionButton(title: String, lastTracerouteTime: Long?, onClick: ()
|
|||
}
|
||||
}
|
||||
|
||||
val progress by animateFloatAsState(
|
||||
targetValue = if (isCoolingDown) 1f else 0f,
|
||||
label = "TracerouteCooldown",
|
||||
)
|
||||
val progress by animateFloatAsState(targetValue = if (isCoolingDown) 1f else 0f, label = "TracerouteCooldown")
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
|
|
@ -1090,10 +1033,7 @@ fun TracerouteActionButton(title: String, lastTracerouteTime: Long?, onClick: ()
|
|||
onClick()
|
||||
},
|
||||
enabled = !isCoolingDown,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (progress > 0f) {
|
||||
|
|
@ -1119,10 +1059,7 @@ fun TracerouteActionButton(title: String, lastTracerouteTime: Long?, onClick: ()
|
|||
|
||||
@Composable
|
||||
fun NodeActionButton(
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp),
|
||||
modifier: Modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).height(48.dp),
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
|
|
@ -1156,8 +1093,8 @@ fun NodeActionSwitch(
|
|||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp)
|
||||
.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = { onClick() }),
|
||||
|
|
@ -1167,9 +1104,7 @@ fun NodeActionSwitch(
|
|||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp, horizontal = 16.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
|
|
@ -1268,10 +1203,5 @@ private fun PreviewWindDirectionN45() {
|
|||
@Composable
|
||||
private fun PreviewWindDirectionItem(windDirection: Float, windSpeed: String = "5 m/s") {
|
||||
val normalizedBearing = (windDirection + 180) % 360
|
||||
InfoCard(
|
||||
icon = Icons.Outlined.Navigation,
|
||||
text = "Wind",
|
||||
value = windSpeed,
|
||||
rotateIcon = normalizedBearing,
|
||||
)
|
||||
InfoCard(icon = Icons.Outlined.Navigation, text = "Wind", value = windSpeed, rotateIcon = normalizedBearing)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import androidx.compose.material3.ButtonDefaults
|
|||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MaterialTheme.colorScheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
|
|
@ -46,7 +47,6 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
|
|
@ -58,72 +58,54 @@ import com.geeksville.mesh.R
|
|||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.common.components.CopyIconButton
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
|
||||
import com.google.protobuf.ByteString
|
||||
|
||||
@Composable
|
||||
private fun KeyStatusDialog(
|
||||
@StringRes title: Int,
|
||||
@StringRes text: Int,
|
||||
key: ByteString?,
|
||||
onDismiss: () -> Unit = {}
|
||||
) = Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
private fun KeyStatusDialog(@StringRes title: Int, @StringRes text: Int, key: ByteString?, onDismiss: () -> Unit = {}) =
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = text),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (key != null && title == R.string.encryption_pkc) {
|
||||
val keyString = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP)
|
||||
Text(
|
||||
text = stringResource(id = R.string.config_security_public_key) + ":",
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
item {
|
||||
Text(text = stringResource(id = title), textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(text = stringResource(id = text), textAlign = TextAlign.Center)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (key != null && title == R.string.encryption_pkc) {
|
||||
val keyString = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP)
|
||||
Text(
|
||||
text = keyString,
|
||||
text = stringResource(id = R.string.config_security_public_key) + ":",
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SelectionContainer { Text(text = keyString, textAlign = TextAlign.Center) }
|
||||
Spacer(Modifier.height(8.dp))
|
||||
CopyIconButton(valueToCopy = keyString, modifier = Modifier.padding(start = 8.dp))
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.close))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
CopyIconButton(
|
||||
valueToCopy = keyString,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text = stringResource(id = R.string.close)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeKeyStatusIcon(
|
||||
|
|
@ -134,32 +116,33 @@ fun NodeKeyStatusIcon(
|
|||
) {
|
||||
var showEncryptionDialog by remember { mutableStateOf(false) }
|
||||
if (showEncryptionDialog) {
|
||||
val (title, text) = when {
|
||||
mismatchKey -> R.string.encryption_error to R.string.encryption_error_text
|
||||
hasPKC -> R.string.encryption_pkc to R.string.encryption_pkc_text
|
||||
else -> R.string.encryption_psk to R.string.encryption_psk_text
|
||||
}
|
||||
val (title, text) =
|
||||
when {
|
||||
mismatchKey -> R.string.encryption_error to R.string.encryption_error_text
|
||||
hasPKC -> R.string.encryption_pkc to R.string.encryption_pkc_text
|
||||
else -> R.string.encryption_psk to R.string.encryption_psk_text
|
||||
}
|
||||
KeyStatusDialog(title, text, publicKey) { showEncryptionDialog = false }
|
||||
}
|
||||
|
||||
val (icon, tint) = when {
|
||||
mismatchKey -> Icons.Default.KeyOff to Color.Red
|
||||
hasPKC -> Icons.Default.Lock to Color(color = 0xFF30C047)
|
||||
else -> ImageVector.vectorResource(R.drawable.ic_lock_open_right_24) to Color(color = 0xFFFEC30A)
|
||||
}
|
||||
val (icon, tint) =
|
||||
when {
|
||||
mismatchKey -> Icons.Default.KeyOff to colorScheme.StatusRed
|
||||
hasPKC -> Icons.Default.Lock to colorScheme.StatusGreen
|
||||
else -> ImageVector.vectorResource(R.drawable.ic_lock_open_right_24) to colorScheme.StatusYellow
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { showEncryptionDialog = true },
|
||||
modifier = modifier,
|
||||
) {
|
||||
IconButton(onClick = { showEncryptionDialog = true }, modifier = modifier) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = stringResource(
|
||||
id = when {
|
||||
contentDescription =
|
||||
stringResource(
|
||||
id =
|
||||
when {
|
||||
mismatchKey -> R.string.encryption_error
|
||||
hasPKC -> R.string.encryption_pkc
|
||||
else -> R.string.encryption_psk
|
||||
}
|
||||
},
|
||||
),
|
||||
tint = tint,
|
||||
)
|
||||
|
|
@ -169,13 +152,7 @@ fun NodeKeyStatusIcon(
|
|||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun KeyStatusDialogErrorPreview() {
|
||||
AppTheme {
|
||||
KeyStatusDialog(
|
||||
title = R.string.encryption_error,
|
||||
text = R.string.encryption_error_text,
|
||||
key = null,
|
||||
)
|
||||
}
|
||||
AppTheme { KeyStatusDialog(title = R.string.encryption_error, text = R.string.encryption_error_text, key = null) }
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
|
|
@ -193,11 +170,5 @@ private fun KeyStatusDialogPkcPreview() {
|
|||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun KeyStatusDialogPskPreview() {
|
||||
AppTheme {
|
||||
KeyStatusDialog(
|
||||
title = R.string.encryption_psk,
|
||||
text = R.string.encryption_psk_text,
|
||||
key = null,
|
||||
)
|
||||
}
|
||||
AppTheme { KeyStatusDialog(title = R.string.encryption_psk, text = R.string.encryption_psk_text, key = null) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,24 +36,19 @@ import androidx.compose.material3.TooltipDefaults
|
|||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
|
||||
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NodeStatusIcons(
|
||||
isThisNode: Boolean,
|
||||
isUnmessageable: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isConnected: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: Boolean, isConnected: Boolean) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
if (isThisNode) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
|
|
@ -65,12 +60,12 @@ fun NodeStatusIcons(
|
|||
R.string.connected
|
||||
} else {
|
||||
R.string.disconnected
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
if (isConnected) {
|
||||
@Suppress("MagicNumber")
|
||||
|
|
@ -78,14 +73,14 @@ fun NodeStatusIcons(
|
|||
imageVector = Icons.TwoTone.CloudDone,
|
||||
contentDescription = stringResource(R.string.connected),
|
||||
modifier = Modifier.size(24.dp), // Smaller size for badge
|
||||
tint = Color(0xFF4CAF50)
|
||||
tint = MaterialTheme.colorScheme.StatusGreen,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.CloudOff,
|
||||
contentDescription = stringResource(R.string.not_connected),
|
||||
modifier = Modifier.size(24.dp), // Smaller size for badge
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
tint = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -94,23 +89,14 @@ fun NodeStatusIcons(
|
|||
if (isUnmessageable) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(stringResource(R.string.unmonitored_or_infrastructure))
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
tooltip = { PlainTooltip { Text(stringResource(R.string.unmonitored_or_infrastructure)) } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.NoCell,
|
||||
contentDescription = stringResource(R.string.unmessageable),
|
||||
modifier = Modifier
|
||||
.size(24.dp), // Smaller size for badge
|
||||
modifier = Modifier.size(24.dp), // Smaller size for badge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -118,24 +104,15 @@ fun NodeStatusIcons(
|
|||
if (isFavorite && !isThisNode) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(stringResource(R.string.favorite))
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
tooltip = { PlainTooltip { Text(stringResource(R.string.favorite)) } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
) {
|
||||
IconButton(onClick = {}, modifier = Modifier.size(24.dp)) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Star,
|
||||
contentDescription = stringResource(R.string.favorite),
|
||||
modifier = Modifier
|
||||
.size(24.dp), // Smaller size for badge
|
||||
tint = Color(color = 0xFFFEC30A)
|
||||
modifier = Modifier.size(24.dp), // Smaller size for badge
|
||||
tint = MaterialTheme.colorScheme.StatusYellow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -146,10 +123,5 @@ fun NodeStatusIcons(
|
|||
@Preview
|
||||
@Composable
|
||||
fun StatusIconsPreview() {
|
||||
NodeStatusIcons(
|
||||
isThisNode = true,
|
||||
isUnmessageable = true,
|
||||
isFavorite = true,
|
||||
isConnected = false,
|
||||
)
|
||||
NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue