refactor(colors): consolidate colors in UI components (#2520)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-07-24 21:27:33 -05:00 committed by GitHub
parent c61d31c3b8
commit 6fd444c077
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1028 additions and 1386 deletions

View file

@ -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),
)
}
}

View file

@ -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))
}
}

View file

@ -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)
}

View file

@ -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 {

View file

@ -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 }) }
}
}

View file

@ -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)

View file

@ -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)
}
}

View file

@ -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,
)
}

View file

@ -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) }
}
}

View file

@ -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)
}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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) }
}

View file

@ -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)
}