diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt new file mode 100644 index 000000000..17f0cb873 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -0,0 +1,51 @@ +package com.geeksville.mesh.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.database.MeshLogRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class NodeDetailsViewModel @Inject constructor( + val nodeDB: NodeDB, + private val meshLogRepository: MeshLogRepository +) : ViewModel() { + + private val _deviceMetrics = MutableStateFlow>(emptyList()) + val deviceMetrics: StateFlow> = _deviceMetrics + + private val _environmentMetrics = MutableStateFlow>(emptyList()) + val environmentMetrics: StateFlow> = _environmentMetrics + + /** + * Gets the short name of the node identified by `nodeNum`. + */ + fun getNodeName(nodeNum: Int): String? = nodeDB.nodeDBbyNum.value[nodeNum]?.user?.shortName + + /** + * Used to set the Node for which the user will see charts for. + */ + fun setSelectedNode(nodeNum: Int) { + viewModelScope.launch { + meshLogRepository.getTelemetryFrom(nodeNum).collect { + val deviceList = mutableListOf() + val environmentList = mutableListOf() + for (telemetry in it) { + if (telemetry.hasDeviceMetrics()) + deviceList.add(telemetry) + /* Avoiding negative outliers */ + if (telemetry.hasEnvironmentMetrics() && telemetry.environmentMetrics.relativeHumidity >= 0f) + environmentList.add(telemetry) + } + _deviceMetrics.value = deviceList + _environmentMetrics.value = environmentList + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt new file mode 100644 index 000000000..b4d9dddbe --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -0,0 +1,176 @@ +package com.geeksville.mesh.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.model.NodeDetailsViewModel +import com.geeksville.mesh.ui.components.DeviceMetricsScreen +import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen +import com.geeksville.mesh.ui.theme.AppTheme +import dagger.hilt.android.AndroidEntryPoint + +internal fun FragmentManager.navigateToNodeDetails(nodeNum: Int? = null) { + val nodeDetailsFragment = NodeDetailsFragment().apply { + arguments = bundleOf("nodeNum" to nodeNum) + } + beginTransaction() + .replace(R.id.mainActivityLayout, nodeDetailsFragment) + .addToBackStack(null) + .commit() +} + +@AndroidEntryPoint +class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { + + private val model: NodeDetailsViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val nodeNum = arguments?.getInt("nodeNum") + if (nodeNum != null) + model.setSelectedNode(nodeNum) + + val nodeName = model.getNodeName(nodeNum ?: 0) + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + NodeDetailsScreen( + model = model, + nodeName = nodeName, + navigateBack = { + parentFragmentManager.popBackStack() + } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NodeDetailsScreen( + model: NodeDetailsViewModel = hiltViewModel(), + nodeName: String?, + navigateBack: () -> Unit, +) { + val deviceMetrics by model.deviceMetrics.collectAsStateWithLifecycle() + val environmentMetrics by model.environmentMetrics.collectAsStateWithLifecycle() + + val pagerState = rememberPagerState(pageCount = { 2 }) + + Scaffold( + /* + * NOTE: The bottom bar could be used to enable other actions such as clear or export data. + */ + topBar = { + TopAppBar( + backgroundColor = colorResource(R.color.toolbarBackground), + contentColor = colorResource(R.color.toolbarText), + title = { + Text( + text = "${stringResource(R.string.node_details)}: $nodeName", + ) + HorizontalTabs(pagerState) + }, + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.navigate_back), + ) + } + } + ) + }, + ) { innerPadding -> + HorizontalPager(state = pagerState) { page -> + when (page) { + 0 -> DeviceMetricsScreen( + innerPadding = innerPadding, + telemetries = deviceMetrics + ) + 1 -> EnvironmentMetricsScreen( + innerPadding = innerPadding, + telemetries = environmentMetrics + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HorizontalTabs(pagerState: PagerState) { + + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.Center + ) { + repeat(pagerState.pageCount) { iteration -> + val color = if (pagerState.currentPage == iteration) + colorResource(R.color.toolbarText) + else + Color.LightGray + + val (imageVector, contentDescription) = if (iteration == 0) + Pair(ImageVector.vectorResource( + R.drawable.baseline_charging_station_24), + stringResource(R.string.device_metrics) + ) + else + Pair( + ImageVector.vectorResource(R.drawable.baseline_thermostat_24), + stringResource(R.string.environment_metrics) + ) + Icon( + imageVector, + contentDescription, + tint = color + ) + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index 447396cce..9384f5042 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -82,6 +82,10 @@ class UsersFragment : ScreenFragment("Users"), Logging { R.id.remote_admin -> { navigateToRadioConfig(node) } + + R.id.more_details -> { + navigateToMoreDetails(node) + } } } } @@ -97,6 +101,11 @@ class UsersFragment : ScreenFragment("Users"), Logging { parentFragmentManager.navigateToRadioConfig(node.num) } + private fun navigateToMoreDetails(node: NodeInfo) { + info("calling MoreDetails --> destNum: ${node.num}") + parentFragmentManager.navigateToNodeDetails(node.num) + } + override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt new file mode 100644 index 000000000..8d9d6edec --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -0,0 +1,687 @@ +@file:Suppress("TooManyFunctions") + +package com.geeksville.mesh.ui.components + +import android.graphics.Paint +import android.graphics.Typeface +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.asComposePath +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.ui.BatteryInfo +import com.geeksville.mesh.ui.components.ChartConstants.DEVICE_METRICS_COLORS +import com.geeksville.mesh.ui.components.ChartConstants.ENVIRONMENT_METRICS_COLORS +import com.geeksville.mesh.ui.components.ChartConstants.LEFT_CHART_SPACING +import com.geeksville.mesh.ui.components.ChartConstants.LINE_OFF +import com.geeksville.mesh.ui.components.ChartConstants.LINE_ON +import com.geeksville.mesh.ui.components.ChartConstants.TIME_FORMAT +import com.geeksville.mesh.ui.components.ChartConstants.MAX_PERCENT_VALUE +import com.geeksville.mesh.ui.components.ChartConstants.LINE_LIMIT +import com.geeksville.mesh.ui.components.ChartConstants.MS_PER_SEC +import com.geeksville.mesh.ui.components.ChartConstants.TEXT_PAINT_ALPHA +import com.geeksville.mesh.ui.theme.Orange +import java.text.DateFormat + + +private object ChartConstants { + val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) + val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue) + val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + const val MAX_PERCENT_VALUE = 100f + const val LINE_LIMIT = 4 + const val TEXT_PAINT_ALPHA = 192 + const val LINE_ON = 10f + const val LINE_OFF = 20f + const val LEFT_CHART_SPACING = 8f + const val MS_PER_SEC = 1000.0f +} + +@Composable +fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List) { + Column { + DeviceMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + telemetries + ) + /* Device Metric Cards */ + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + items(telemetries.reversed()) { telemetry -> DeviceMetricsCard(telemetry) } + } + } +} + +@Composable +fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List) { + Column { + EnvironmentMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.33f), + telemetries = telemetries + ) + + /* Environment Metric Cards */ + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + items(telemetries.reversed()) { telemetry -> EnvironmentMetricsCard(telemetry)} + } + } +} + +@Suppress("LongMethod") +@Composable +private fun DeviceMetricsChart(modifier: Modifier = Modifier, telemetries: List) { + + ChartHeader(amount = telemetries.size, title = stringResource(R.string.device_metrics)) + if (telemetries.isEmpty()) + return + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val spacing = LEFT_CHART_SPACING + + Box(contentAlignment = Alignment.TopStart) { + + ChartOverlay(modifier, graphColor, minValue = 0f, maxValue = 100f) + + /* Plot Battery Line, ChUtil, and AirUtilTx */ + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + val spacePerEntry = (width - spacing) / telemetries.size + val dataPointRadius = 2.dp.toPx() + var lastX: Float + val strokePath = Path().apply { + for (i in telemetries.indices) { + val telemetry = telemetries[i] + val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last() + val leftRatio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + val rightRatio = nextTelemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + + val x1 = spacing + i * spacePerEntry + val y1 = height - spacing - (leftRatio * height) + + /* Channel Utilization */ + val chUtilRatio = telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE + val yChUtil = height - spacing - (chUtilRatio * height) + drawCircle( + color = DEVICE_METRICS_COLORS[1], + radius = dataPointRadius, + center = Offset(x1, yChUtil) + ) + + /* Air Utilization Transmit */ + val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE + val yAirUtil = height - spacing - (airUtilRatio * height) + drawCircle( + color = DEVICE_METRICS_COLORS[2], + radius = dataPointRadius, + center = Offset(x1, yAirUtil) + ) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - spacing - (rightRatio * height) + if (i == 0) + moveTo(x1, y1) + + lastX = (x1 + x2) / 2f + + quadraticBezierTo(x1, y1, lastX, (y1 + y2) / 2f) + } + } + + /* Battery Line */ + drawPath( + path = strokePath, + color = DEVICE_METRICS_COLORS[0], + style = Stroke( + width = dataPointRadius, + cap = StrokeCap.Round + ) + ) + } + + TimeLabels( + modifier = modifier, + graphColor = graphColor, + oldest = telemetries.first().time * MS_PER_SEC, + newest = telemetries.last().time * MS_PER_SEC + ) + } + Spacer(modifier = Modifier.height(16.dp)) + + DeviceLegend() + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Suppress("LongMethod") +@Composable +private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List) { + + ChartHeader(amount = telemetries.size, title = stringResource(R.string.environment_metrics)) + if (telemetries.isEmpty()) + return + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val transparentTemperatureColor = remember { ENVIRONMENT_METRICS_COLORS[0].copy(alpha = 0.5f) } + val transparentHumidityColor = remember { ENVIRONMENT_METRICS_COLORS[1].copy(alpha = 0.5f) } + val spacing = LEFT_CHART_SPACING + + /* Since both temperature and humidity are being plotted we need a combined min and max. */ + val (minTemp, maxTemp) = remember(key1 = telemetries) { + Pair( + telemetries.minBy { it.environmentMetrics.temperature }, + telemetries.maxBy { it.environmentMetrics.temperature } + ) + } + val (minHumidity, maxHumidity) = remember(key1 = telemetries) { + Pair( + telemetries.minBy { it.environmentMetrics.relativeHumidity }, + telemetries.maxBy { it.environmentMetrics.relativeHumidity } + ) + } + val min = minOf(minTemp.environmentMetrics.temperature, minHumidity.environmentMetrics.relativeHumidity) + val max = maxOf(maxTemp.environmentMetrics.temperature, maxHumidity.environmentMetrics.relativeHumidity) + val diff = max - min + + Box(contentAlignment = Alignment.TopStart) { + + ChartOverlay(modifier = modifier, graphColor = graphColor, minValue = min, maxValue = max) + + /* Plot Temperature and Relative Humidity */ + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + val spacePerEntry = (width - spacing) / telemetries.size + + /* Temperature */ + var lastTempX = 0f + val temperaturePath = Path().apply { + for (i in telemetries.indices) { + val envMetrics = telemetries[i].environmentMetrics + val nextEnvMetrics = + (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics + val leftRatio = (envMetrics.temperature - min) / diff + val rightRatio = (nextEnvMetrics.temperature - min) / diff + + val x1 = spacing + i * spacePerEntry + val y1 = height - spacing - (leftRatio * height) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - spacing - (rightRatio * height) + if (i == 0) { + moveTo(x1, y1) + } + lastTempX = (x1 + x2) / 2f + quadraticBezierTo( + x1, y1, lastTempX, (y1 + y2) / 2f + ) + } + } + + val fillPath = android.graphics.Path(temperaturePath.asAndroidPath()) + .asComposePath() + .apply { + lineTo(lastTempX, height - spacing) + lineTo(spacing, height - spacing) + close() + } + + drawPath( + path = fillPath, + brush = Brush.verticalGradient( + colors = listOf( + transparentTemperatureColor, + Color.Transparent + ), + endY = height - spacing + ), + ) + + drawPath( + path = temperaturePath, + color = ENVIRONMENT_METRICS_COLORS[0], + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + + /* Relative Humidity */ + var lastHumidityX = 0f + val humidityPath = Path().apply { + for (i in telemetries.indices) { + val envMetrics = telemetries[i].environmentMetrics + val nextEnvMetrics = + (telemetries.getOrNull(i + 1) ?: telemetries.last()).environmentMetrics + val leftRatio = (envMetrics.relativeHumidity - min) / diff + val rightRatio = (nextEnvMetrics.relativeHumidity - min) / diff + + val x1 = spacing + i * spacePerEntry + val y1 = height - spacing - (leftRatio * height) + + val x2 = spacing + (i + 1) * spacePerEntry + val y2 = height - spacing - (rightRatio * height) + if (i == 0) { + moveTo(x1, y1) + } + lastHumidityX = (x1 + x2) / 2f + quadraticBezierTo( + x1, y1, lastHumidityX, (y1 + y2) / 2f + ) + } + } + + val fillHumidityPath = android.graphics.Path(humidityPath.asAndroidPath()) + .asComposePath() + .apply { + lineTo(lastHumidityX, height - spacing) + lineTo(spacing, height - spacing) + close() + } + + drawPath( + path = fillHumidityPath, + brush = Brush.verticalGradient( + colors = listOf( + transparentHumidityColor, + Color.Transparent + ), + endY = height - spacing + ), + ) + + drawPath( + path = humidityPath, + color = ENVIRONMENT_METRICS_COLORS[1], + style = Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + TimeLabels( + modifier = modifier, + graphColor = graphColor, + oldest = telemetries.first().time * MS_PER_SEC, + newest = telemetries.last().time * MS_PER_SEC + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + EnvironmentLegend() + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +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) + ) { + Surface { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + /* Time, Battery, and Voltage */ + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + + 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 = "%s %.2f%% %s %.2f%%".format( + stringResource(R.string.channel_utilization), + deviceMetrics.channelUtilization, + stringResource(R.string.air_utilization), + deviceMetrics.airUtilTx + ) + Text( + text = text, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + } + } + } + } +} + +@Suppress("LongMethod") +@Composable +private fun EnvironmentMetricsCard(telemetry: Telemetry) { + val envMetrics = telemetry.environmentMetrics + val time = telemetry.time * MS_PER_SEC + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Surface { + SelectionContainer { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + /* Time and Temperature */ + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = TIME_FORMAT.format(time), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + + Text( + text = "%s %.1f°C".format( + stringResource(id = R.string.temperature), + envMetrics.temperature + ), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + /* Humidity and Barometric Pressure */ + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "%s %.2f%%".format( + stringResource(id = R.string.humidity), + envMetrics.relativeHumidity, + ), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + if (envMetrics.barometricPressure > 0) { + Text( + text = "%.2f hPa".format(envMetrics.barometricPressure), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } + } + } + } + } + } +} + +@Composable +private fun ChartHeader(amount: Int, title: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$amount $title", + modifier = Modifier.wrapContentWidth(), + style = TextStyle(fontWeight = FontWeight.Bold), + fontSize = MaterialTheme.typography.button.fontSize + ) + } +} + +/** + * Draws chart lines and labels with respect to the Y-axis range; defined by (`maxValue` - `minValue`). + */ +@Composable +private fun ChartOverlay( + modifier: Modifier, + graphColor: Color, + minValue: Float, + maxValue: Float +) { + val range = maxValue - minValue + val verticalSpacing = range / LINE_LIMIT + val density = LocalDensity.current + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + + /* Horizontal Lines */ + var lineY = minValue + for (i in 0..LINE_LIMIT) { + val ratio = (lineY - minValue) / range + val y = height - (ratio * height) + val color: Color = when (i) { + 1 -> Color.Red + 2 -> Orange + else -> graphColor + } + drawLine( + start = Offset(0f, y), + end = Offset(width, y), + color = color, + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f) + ) + lineY += verticalSpacing + } + + /* Y Labels */ + + val textPaint = Paint().apply { + color = graphColor.toArgb() + textAlign = Paint.Align.LEFT + textSize = density.run { 12.dp.toPx() } + typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) + alpha = TEXT_PAINT_ALPHA + } + drawContext.canvas.nativeCanvas.apply { + var label = minValue + for (i in 0..LINE_LIMIT) { + val ratio = (label - minValue) / range + val y = height - (ratio * height) + drawText( + "${label.toInt()}", + width + 4.dp.toPx(), + y + 4.dp.toPx(), + textPaint + ) + label += verticalSpacing + } + } + } +} + +/** + * Draws the `oldest` and `newest` times for the respective telemetry data. + * Expects time in milliseconds + */ +@Composable +private fun TimeLabels( + modifier: Modifier, + graphColor: Color, + oldest: Float, + newest: Float +) { + val density = LocalDensity.current + Canvas(modifier = modifier) { + + val textPaint = Paint().apply { + color = graphColor.toArgb() + textAlign = Paint.Align.LEFT + textSize = density.run { 12.dp.toPx() } + typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) + alpha = TEXT_PAINT_ALPHA + } + + drawContext.canvas.nativeCanvas.apply { + drawText( + TIME_FORMAT.format(oldest), + 8.dp.toPx(), + 12.dp.toPx(), + textPaint + ) + drawText( + TIME_FORMAT.format(newest), + size.width - 140.dp.toPx(), + 12.dp.toPx(), + textPaint + ) + } + } +} + +@Composable +private fun DeviceLegend() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.weight(1f)) + + LegendLabel(text = stringResource(R.string.battery), color = DEVICE_METRICS_COLORS[0], isLine = true) + + Spacer(modifier = Modifier.width(4.dp)) + + LegendLabel(text = stringResource(R.string.channel_utilization), color = DEVICE_METRICS_COLORS[1]) + + Spacer(modifier = Modifier.width(4.dp)) + + LegendLabel(text = stringResource(R.string.air_utilization), color = DEVICE_METRICS_COLORS[2]) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun EnvironmentLegend() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Spacer(modifier = Modifier.weight(1f)) + + LegendLabel(text = stringResource(R.string.temperature), color = ENVIRONMENT_METRICS_COLORS[0], isLine = true) + + Spacer(modifier = Modifier.width(4.dp)) + + LegendLabel(text = stringResource(R.string.humidity), color = ENVIRONMENT_METRICS_COLORS[1], isLine = true) + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { + Canvas( + modifier = Modifier.size(4.dp) + ) { + if (isLine) { + drawLine( + color = color, + start = Offset(x = 0f, y = size.height / 2f), + end = Offset(x = 16f, y = size.height / 2f), + strokeWidth = 2.dp.toPx(), + cap = StrokeCap.Round, + ) + } else { + drawCircle( + color = color + ) + } + } + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = text, + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize, + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt b/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt index 51033d43c..9da963e12 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/theme/Color.kt @@ -19,4 +19,5 @@ val MeshtasticGreen = Color(0xFF67EA94) val AlmostWhite = Color(0xB3FFFFFF) val AlmostBlack = Color(0x8A000000) -val HyperlinkBlue = Color(0xFF43C3B0) \ No newline at end of file +val HyperlinkBlue = Color(0xFF43C3B0) +val Orange = Color(255, 153, 0) \ No newline at end of file diff --git a/app/src/main/res/drawable/baseline_charging_station_24.xml b/app/src/main/res/drawable/baseline_charging_station_24.xml new file mode 100644 index 000000000..5efc33810 --- /dev/null +++ b/app/src/main/res/drawable/baseline_charging_station_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/baseline_thermostat_24.xml b/app/src/main/res/drawable/baseline_thermostat_24.xml new file mode 100644 index 000000000..79f663280 --- /dev/null +++ b/app/src/main/res/drawable/baseline_thermostat_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/menu/menu_nodes.xml b/app/src/main/res/menu/menu_nodes.xml index 1ae970950..1a5e6869a 100644 --- a/app/src/main/res/menu/menu_nodes.xml +++ b/app/src/main/res/menu/menu_nodes.xml @@ -30,4 +30,10 @@ android:title="@string/device_settings" app:showAsAction="withText" /> + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a8db898cd..eda7a53a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,6 +25,8 @@ Last heard via MQTT + More Details + MSL Channel Name @@ -215,4 +217,13 @@ Replace Scan WiFi QR code Invalid WiFi Credential QR code format + Navigate Back + Battery + Channel Utilization + Air Utilization + Device Metrics + Node Details + Environment Metrics + Temperature + Humidity