From b90f614794a178e9e41bb35f569b10b853aa809e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 1 Aug 2024 12:48:05 -0500 Subject: [PATCH 01/12] feat: Add more details screen for nodes This commit introduces a new "More Details" screen for nodes, accessible via the node list's context menu. The screen displays the node's long name and provides a back button to return to the node list. This feature allows users to view additional information about individual nodes. --- .../java/com/geeksville/mesh/model/UIState.kt | 13 +- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 112 ++++++++++++++++++ .../com/geeksville/mesh/ui/UsersFragment.kt | 9 ++ app/src/main/res/menu/menu_nodes.xml | 4 + app/src/main/res/values/strings.xml | 2 + 5 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index bec391d9c..01bba26c6 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -194,10 +195,19 @@ class UIViewModel @Inject constructor( nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) }.stateIn( scope = viewModelScope, - started = WhileSubscribed(5_000), + started = WhileSubscribed(STOP_TIMEOUT_MILLIS), initialValue = emptyList(), ) + fun getNodeByNum(nodeNum: Int): StateFlow = nodeList.map { list -> + list.firstOrNull { it.num == nodeNum } + }.stateIn( + scope = viewModelScope, + started = WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = null, + ) + + // hardware info about our local device (can be null) val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo @@ -315,6 +325,7 @@ class UIViewModel @Inject constructor( } companion object { + private const val STOP_TIMEOUT_MILLIS = 5_000L fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) } 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..651e2e07b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -0,0 +1,112 @@ +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.Column +import androidx.compose.foundation.layout.padding +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.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.activityViewModels +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.UIViewModel +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: UIViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val nodeNum = arguments?.getInt("nodeNum") + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + AppTheme { + NodeDetailsScreen( + model = model, + nodeNum = nodeNum, + navigateBack = { + parentFragmentManager.popBackStack() + } + ) + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NodeDetailsScreen( + model: UIViewModel = hiltViewModel(), + nodeNum: Int? = null, + navigateBack: () -> Unit, +) { + val ourNodeInfo by model.ourNodeInfo.collectAsStateWithLifecycle() + val detailsNodeInfo by model.getNodeByNum(nodeNum ?: ourNodeInfo?.num ?: 0) + .collectAsStateWithLifecycle() + Scaffold( + topBar = { + TopAppBar( + title = { + Text("Node Details: ${detailsNodeInfo?.user?.shortName}") + }, + navigationIcon = { + IconButton(onClick = navigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Localized description" + ) + } + } + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + modifier = Modifier.padding(8.dp), + text = detailsNodeInfo?.user?.longName.orEmpty(), + ) + } + } +} 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 c22464801..b0ab4c27b 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -81,6 +81,10 @@ class UsersFragment : ScreenFragment("Users"), Logging { R.id.remote_admin -> { navigateToRadioConfig(node) } + + R.id.more_details -> { + navigateToMoreDetails(node) + } } } } @@ -96,6 +100,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/res/menu/menu_nodes.xml b/app/src/main/res/menu/menu_nodes.xml index 1ae970950..eb70ca473 100644 --- a/app/src/main/res/menu/menu_nodes.xml +++ b/app/src/main/res/menu/menu_nodes.xml @@ -23,6 +23,10 @@ android:id="@+id/remove" android:title="@string/remove" app:showAsAction="withText" /> + Last heard via MQTT + More Details + MSL Channel Name From 761506600253e0e116499b8d0846cc842794e8cb Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 1 Aug 2024 13:17:38 -0500 Subject: [PATCH 02/12] feat: Sets background and content colors of the app bar. --- .../java/com/geeksville/mesh/ui/NodeDetailsFragment.kt | 10 ++++++++-- app/src/main/res/values/strings.xml | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 651e2e07b..25791f6e4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager @@ -84,14 +86,18 @@ fun NodeDetailsScreen( Scaffold( topBar = { TopAppBar( + backgroundColor = colorResource(R.color.toolbarBackground), + contentColor = colorResource(R.color.toolbarText), title = { - Text("Node Details: ${detailsNodeInfo?.user?.shortName}") + Text( + text = "Node Details: ${detailsNodeInfo?.user?.shortName}", + ) }, navigationIcon = { IconButton(onClick = navigateBack) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Localized description" + contentDescription = stringResource(R.string.navigate_back), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6fee8b40c..a984324f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,4 +216,5 @@ Scanned Channels Current Channels Replace + Navigate Back From 3241a50d77c60afa62779bfd0d98640579585d4d Mon Sep 17 00:00:00 2001 From: James Rich Date: Sat, 3 Aug 2024 08:59:56 -0500 Subject: [PATCH 03/12] Refactor: Use nodeDBbyNum to get node by number This commit refactors the getNodeByNum function to use the in memory nodeDBbyNum map instead of iterating through the nodeList. This improves performance and readability. --- app/src/main/java/com/geeksville/mesh/model/UIState.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 01bba26c6..24540eb87 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -199,8 +199,9 @@ class UIViewModel @Inject constructor( initialValue = emptyList(), ) - fun getNodeByNum(nodeNum: Int): StateFlow = nodeList.map { list -> - list.firstOrNull { it.num == nodeNum } + @OptIn(ExperimentalCoroutinesApi::class) + fun getNodeByNum(nodeNum: Int): StateFlow = nodeDB.nodeDBbyNum.mapLatest { nodes -> + nodes[nodeNum] }.stateIn( scope = viewModelScope, started = WhileSubscribed(STOP_TIMEOUT_MILLIS), From 20daa95ba1541f442eb010aa2a0fea8b346fc1d5 Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Sat, 17 Aug 2024 14:37:31 -0700 Subject: [PATCH 04/12] Implementation of a first DeviceMetrics chart. --- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 62 ++- .../mesh/ui/components/CustomCharts.kt | 352 ++++++++++++++++++ app/src/main/res/menu/menu_nodes.xml | 10 +- app/src/main/res/values/strings.xml | 4 + 4 files changed, 412 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 25791f6e4..0f55e2bc0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -5,9 +5,13 @@ 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.lazy.items import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Scaffold @@ -22,15 +26,20 @@ 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.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.DeviceMetrics import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging +import com.geeksville.mesh.model.DebugViewModel import com.geeksville.mesh.model.UIViewModel +import com.geeksville.mesh.ui.components.DataEntry +import com.geeksville.mesh.ui.components.DeviceMetricsCard +import com.geeksville.mesh.ui.components.DeviceMetricsChart import com.geeksville.mesh.ui.theme.AppTheme import dagger.hilt.android.AndroidEntryPoint @@ -49,6 +58,8 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { private val model: UIViewModel by activityViewModels() + private val debugModel: DebugViewModel by viewModels() + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -62,6 +73,7 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { AppTheme { NodeDetailsScreen( model = model, + debugModel = debugModel, nodeNum = nodeNum, navigateBack = { parentFragmentManager.popBackStack() @@ -77,20 +89,37 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { @Composable fun NodeDetailsScreen( model: UIViewModel = hiltViewModel(), + debugModel: DebugViewModel, // TODO do I need something similar to hiltViewModel()? nodeNum: Int? = null, navigateBack: () -> Unit, ) { val ourNodeInfo by model.ourNodeInfo.collectAsStateWithLifecycle() - val detailsNodeInfo by model.getNodeByNum(nodeNum ?: ourNodeInfo?.num ?: 0) + val selectedNodeInfo by model.getNodeByNum(nodeNum ?: ourNodeInfo?.num ?: 0) .collectAsStateWithLifecycle() + + val nodeId = selectedNodeInfo?.user?.id + val logs by debugModel.meshLog.collectAsStateWithLifecycle() + val data = mutableListOf() + + /* Retrieve only the data belonging to the selected node. */ + for (log in logs) { + log.nodeInfo?.let { nodeInfo -> + val currentId = nodeInfo.user.id + if (nodeInfo.hasDeviceMetrics() && currentId == nodeId) + data.add(DataEntry(log.received_date, DeviceMetrics(nodeInfo.deviceMetrics))) + } + } + + Scaffold( + /* TODO NOTE: Suggesting that we add a bottom bar that allows the user to toggle between graphs */ topBar = { TopAppBar( backgroundColor = colorResource(R.color.toolbarBackground), contentColor = colorResource(R.color.toolbarText), title = { Text( - text = "Node Details: ${detailsNodeInfo?.user?.shortName}", + text = "Node Details: ${selectedNodeInfo?.user?.shortName}", // TODO res ) }, navigationIcon = { @@ -104,15 +133,24 @@ fun NodeDetailsScreen( ) }, ) { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - modifier = Modifier.padding(8.dp), - text = detailsNodeInfo?.user?.longName.orEmpty(), + + Column { + val reversed = data.reversed() + DeviceMetricsChart( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.33f), + reversed.toMutableList() ) + + /* Device Metric Cards */ + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + items(data) { dataEntry -> DeviceMetricsCard(dataEntry) } + } } } } 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..6f7ef81a2 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -0,0 +1,352 @@ +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.Row +import androidx.compose.foundation.layout.Spacer +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.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.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +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.DeviceMetrics +import com.geeksville.mesh.R +import com.geeksville.mesh.ui.BatteryInfo +import com.geeksville.mesh.ui.components.ChartConstants.COLORS +import com.geeksville.mesh.ui.components.ChartConstants.TIME_FORMAT +import com.geeksville.mesh.ui.components.ChartConstants.MAX_PERCENT_VALUE +import java.text.DateFormat + + +private object ChartConstants { + val COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) + val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) + const val MAX_PERCENT_VALUE = 100f +} + +/** + * Contains the data necessary to build any of the available charts. + */ +data class DataEntry(val time: Long, val deviceMetrics: DeviceMetrics) + +@Composable +fun DeviceMetricsChart(modifier: Modifier = Modifier, data: MutableList) { + + if (data.isEmpty()) + return + + ChartHeader(amount = data.size, title = stringResource(R.string.device_metrics)) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val spacing = 0f + + val (oldestMetrics, newestMetrics) = remember(key1 = data) { + Pair( + data.minBy { it.time }, + data.maxBy { it.time } + ) + } + + Box(contentAlignment = Alignment.TopStart) { + + PercentageChartLayer(modifier = modifier, graphColor = graphColor) + + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + + 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 = 192 + } + + /* Battery Line, ChUtil, and AirUtilTx */ + val spacePerEntry = (width - spacing) / data.size + val dataPointRadius = 2.dp.toPx() + var lastX: Float + val strokePath = Path().apply { + for (i in data.indices) { + val dataEntry = data[i] + val nextDataEntry = data.getOrNull(i + 1) ?: data.last() + val leftRatio = dataEntry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + val rightRatio = nextDataEntry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + + val x1 = spacing + i * spacePerEntry + val y1 = height - spacing - (leftRatio * height) + + /* Channel Utilization */ + val chUtilRatio = dataEntry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE + val yChUtil = height - spacing - (chUtilRatio * height) + drawCircle( + color = COLORS[1], + radius = dataPointRadius, + center = Offset(x1, yChUtil) + ) + + /* Air Utilization Transmit */ + val airUtilRatio = dataEntry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE + val yAirUtil = height - spacing - (airUtilRatio * height) + drawCircle( + color = 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) + } + } + + drawPath( + path = strokePath, + color = COLORS[0], + style = Stroke( + width = dataPointRadius, + cap = StrokeCap.Round + ) + ) + + /* X - Labels: Time */ + drawContext.canvas.nativeCanvas.apply { + drawText( + TIME_FORMAT.format(newestMetrics.time), + 8.dp.toPx(), + 12.dp.toPx(), + textPaint + ) + drawText( + TIME_FORMAT.format(oldestMetrics.time), + width - 116.dp.toPx(), + 12.dp.toPx(), + textPaint + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + + ChartLegend() // TODO function will adapt for the specific chart + + Spacer(modifier = Modifier.height(16.dp)) +} + +@Composable +fun DeviceMetricsCard(dataEntry: DataEntry) { + val deviceMetrics = dataEntry.deviceMetrics + 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(dataEntry.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 + ) + } + } + } + } + } +} + +@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(), + color = MaterialTheme.colors.onSurface, + style = MaterialTheme.typography.body1 + ) + } +} + +@Composable +private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { + val density = LocalDensity.current + val verticalSpacing = 25f + Canvas(modifier = modifier) { + + val height = size.height + val width = size.width - 28.dp.toPx() + + /* Horizontal Lines */ + var lineY = 0f + for (i in 0..4) { + val ratio = lineY / MAX_PERCENT_VALUE + val y = height - (ratio * height) + val color: Color = when (i) { + 1 -> Color.Red + 2 -> Color(255, 153, 0) + else -> graphColor + } + drawLine( + start = Offset(0f, y), + end = Offset(width, y), + color = color, + strokeWidth = 1.dp.toPx(), + cap = StrokeCap.Round, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 20f), 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 = 192 + } + drawContext.canvas.nativeCanvas.apply { + var currentLabel = 0f + for (i in 0..4) { + val ratio = currentLabel / MAX_PERCENT_VALUE + val y = height - (ratio * height) + drawText( + "${currentLabel.toInt()}", + width + 4.dp.toPx(), + y + 4.dp.toPx(), + textPaint + ) + currentLabel += verticalSpacing + } + } + + } +} + +@Composable +private fun ChartLegend() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + + Spacer(modifier = Modifier.weight(1f)) + + LegendLabel(text = stringResource(R.string.battery), color = COLORS[0], isLine = true) + + Spacer(modifier = Modifier.width(4.dp)) + + LegendLabel(text = stringResource(R.string.channel_utilization), color = COLORS[1]) + + Spacer(modifier = Modifier.width(4.dp)) + + LegendLabel(text = stringResource(R.string.air_utilization), color = COLORS[2]) + + 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(0f, size.height / 2f), + end = Offset(16f, 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/res/menu/menu_nodes.xml b/app/src/main/res/menu/menu_nodes.xml index eb70ca473..1a5e6869a 100644 --- a/app/src/main/res/menu/menu_nodes.xml +++ b/app/src/main/res/menu/menu_nodes.xml @@ -23,10 +23,6 @@ android:id="@+id/remove" android:title="@string/remove" 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 d1c9200e0..be187f17d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -215,4 +215,8 @@ Replace Navigate Back + Battery + Channel Utilization + Air Utilization + Device Metrics From c531860430272e202f8733f990994fd7046fd392 Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Tue, 20 Aug 2024 18:26:03 -0700 Subject: [PATCH 05/12] Wrote a specific ViewModal that holds a List of DataEntries used for building both the chart and entry cards. --- .../mesh/model/NodeDetailsViewModel.kt | 55 +++++++++++++++++ .../java/com/geeksville/mesh/model/UIState.kt | 11 ---- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 59 ++++++++----------- .../mesh/ui/components/CustomCharts.kt | 39 ++++++------ .../com/geeksville/mesh/ui/theme/Color.kt | 3 +- app/src/main/res/values/strings.xml | 1 + 6 files changed, 103 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt 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..598f2502d --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -0,0 +1,55 @@ +package com.geeksville.mesh.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.geeksville.mesh.DeviceMetrics +import com.geeksville.mesh.database.MeshLogRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Contains the data necessary to build the Device Metrics Chart. + */ +data class DataEntry(val time: Long, val deviceMetrics: DeviceMetrics) + +@HiltViewModel +class NodeDetailsViewModel @Inject constructor( + val nodeDB: NodeDB, + private val meshLogRepository: MeshLogRepository +) : ViewModel() { + + private val _dataEntry = MutableStateFlow>(emptyList()) + val dataEntries: StateFlow> = _dataEntry + + /** + * Gets the short name of the node identified by `nodeNum`. + */ + @OptIn(ExperimentalCoroutinesApi::class) + suspend fun getNodeName(nodeNum: Int): String? { + return nodeDB.nodeDBbyNum.mapLatest { it[nodeNum] }.first()?.user?.shortName + } + + /** + * Used to set the Node we will be viewing charts for. + */ + fun setSelectedNode(nodeNum: Int) { + viewModelScope.launch { + meshLogRepository.getTelemetryFrom(nodeNum).collect { + val tmp = mutableListOf() + for (telemetry in it) { + val time = telemetry.time * 1000.0.toLong() + if (telemetry.hasDeviceMetrics()) + tmp.add(DataEntry(time = time, DeviceMetrics(telemetry.deviceMetrics))) + } + _dataEntry.value = tmp + } + + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 24540eb87..88ec4641b 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -199,16 +198,6 @@ class UIViewModel @Inject constructor( initialValue = emptyList(), ) - @OptIn(ExperimentalCoroutinesApi::class) - fun getNodeByNum(nodeNum: Int): StateFlow = nodeDB.nodeDBbyNum.mapLatest { nodes -> - nodes[nodeNum] - }.stateIn( - scope = viewModelScope, - started = WhileSubscribed(STOP_TIMEOUT_MILLIS), - initialValue = null, - ) - - // hardware info about our local device (can be null) val myNodeInfo: StateFlow get() = nodeDB.myNodeInfo val ourNodeInfo: StateFlow get() = nodeDB.ourNodeInfo diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 0f55e2bc0..50ea0095d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -1,10 +1,10 @@ package com.geeksville.mesh.ui +import android.annotation.SuppressLint 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.lazy.items import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight @@ -28,20 +28,19 @@ import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager -import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.geeksville.mesh.DeviceMetrics +import androidx.lifecycle.lifecycleScope import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.model.DebugViewModel -import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.ui.components.DataEntry +import com.geeksville.mesh.model.NodeDetailsViewModel import com.geeksville.mesh.ui.components.DeviceMetricsCard import com.geeksville.mesh.ui.components.DeviceMetricsChart import com.geeksville.mesh.ui.theme.AppTheme import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch internal fun FragmentManager.navigateToNodeDetails(nodeNum: Int? = null) { val nodeDetailsFragment = NodeDetailsFragment().apply { @@ -56,9 +55,7 @@ internal fun FragmentManager.navigateToNodeDetails(nodeNum: Int? = null) { @AndroidEntryPoint class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { - private val model: UIViewModel by activityViewModels() - - private val debugModel: DebugViewModel by viewModels() + private val model: NodeDetailsViewModel by viewModels() override fun onCreateView( inflater: LayoutInflater, @@ -66,6 +63,8 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { savedInstanceState: Bundle? ): View { val nodeNum = arguments?.getInt("nodeNum") + if (nodeNum != null) + model.setSelectedNode(nodeNum) return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -73,7 +72,7 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { AppTheme { NodeDetailsScreen( model = model, - debugModel = debugModel, + coroutineScope = lifecycleScope, nodeNum = nodeNum, navigateBack = { parentFragmentManager.popBackStack() @@ -85,41 +84,32 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { } } -@OptIn(ExperimentalFoundationApi::class) +@SuppressLint("CoroutineCreationDuringComposition") @Composable fun NodeDetailsScreen( - model: UIViewModel = hiltViewModel(), - debugModel: DebugViewModel, // TODO do I need something similar to hiltViewModel()? + model: NodeDetailsViewModel = hiltViewModel(), + coroutineScope: CoroutineScope, nodeNum: Int? = null, navigateBack: () -> Unit, ) { - val ourNodeInfo by model.ourNodeInfo.collectAsStateWithLifecycle() - val selectedNodeInfo by model.getNodeByNum(nodeNum ?: ourNodeInfo?.num ?: 0) - .collectAsStateWithLifecycle() - - val nodeId = selectedNodeInfo?.user?.id - val logs by debugModel.meshLog.collectAsStateWithLifecycle() - val data = mutableListOf() - - /* Retrieve only the data belonging to the selected node. */ - for (log in logs) { - log.nodeInfo?.let { nodeInfo -> - val currentId = nodeInfo.user.id - if (nodeInfo.hasDeviceMetrics() && currentId == nodeId) - data.add(DataEntry(log.received_date, DeviceMetrics(nodeInfo.deviceMetrics))) - } - } - + // TODO Need to let user know when we don't have data to display + val data by model.dataEntries.collectAsStateWithLifecycle() + /* We only need to get the nodes name once. */ + var nodeName: String? = "" + coroutineScope.launch { nodeName = model.getNodeName(nodeNum ?: 0) } Scaffold( - /* TODO NOTE: Suggesting that we add a bottom bar that allows the user to toggle between graphs */ + /* + * NOTE: Perhaps we can use a Pager to navigate from graph to graph. + * The bottom bar could be used to enable other actions such as clear data. + **/ topBar = { TopAppBar( backgroundColor = colorResource(R.color.toolbarBackground), contentColor = colorResource(R.color.toolbarText), title = { Text( - text = "Node Details: ${selectedNodeInfo?.user?.shortName}", // TODO res + text = "${stringResource(R.string.node_details)}: $nodeName", ) }, navigationIcon = { @@ -135,12 +125,11 @@ fun NodeDetailsScreen( ) { innerPadding -> Column { - val reversed = data.reversed() DeviceMetricsChart( modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.33f), - reversed.toMutableList() + data.toList() ) /* Device Metric Cards */ @@ -149,7 +138,7 @@ fun NodeDetailsScreen( .fillMaxSize() .padding(innerPadding) ) { - items(data) { dataEntry -> DeviceMetricsCard(dataEntry) } + items(data.reversed()) { dataEntry -> DeviceMetricsCard(dataEntry) } } } } 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 index 6f7ef81a2..95e77e5a9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -36,12 +36,16 @@ 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.DeviceMetrics import com.geeksville.mesh.R +import com.geeksville.mesh.model.DataEntry import com.geeksville.mesh.ui.BatteryInfo import com.geeksville.mesh.ui.components.ChartConstants.COLORS 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.PERCENT_LINE_LIMIT +import com.geeksville.mesh.ui.components.ChartConstants.PERCENT_VERTICAL_SPACING +import com.geeksville.mesh.ui.components.ChartConstants.TEXT_PAINT_ALPHA +import com.geeksville.mesh.ui.theme.Orange import java.text.DateFormat @@ -49,15 +53,14 @@ private object ChartConstants { val COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) val TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) const val MAX_PERCENT_VALUE = 100f + const val PERCENT_VERTICAL_SPACING = 25f + const val PERCENT_LINE_LIMIT = 4 + const val TEXT_PAINT_ALPHA = 192 } -/** - * Contains the data necessary to build any of the available charts. - */ -data class DataEntry(val time: Long, val deviceMetrics: DeviceMetrics) - +@Suppress("LongMethod") @Composable -fun DeviceMetricsChart(modifier: Modifier = Modifier, data: MutableList) { +fun DeviceMetricsChart(modifier: Modifier = Modifier, data: List) { if (data.isEmpty()) return @@ -80,6 +83,7 @@ fun DeviceMetricsChart(modifier: Modifier = Modifier, data: MutableList Color.Red - 2 -> Color(255, 153, 0) + 2 -> Orange else -> graphColor } drawLine( @@ -270,7 +273,7 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { cap = StrokeCap.Round, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 20f), 0f) ) - lineY += verticalSpacing + lineY += PERCENT_VERTICAL_SPACING } /* Y Labels */ @@ -280,11 +283,11 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { textAlign = Paint.Align.LEFT textSize = density.run { 12.dp.toPx() } typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD)) - alpha = 192 + alpha = TEXT_PAINT_ALPHA } drawContext.canvas.nativeCanvas.apply { var currentLabel = 0f - for (i in 0..4) { + for (i in 0..PERCENT_LINE_LIMIT) { val ratio = currentLabel / MAX_PERCENT_VALUE val y = height - (ratio * height) drawText( @@ -293,7 +296,7 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { y + 4.dp.toPx(), textPaint ) - currentLabel += verticalSpacing + currentLabel += PERCENT_VERTICAL_SPACING } } 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/values/strings.xml b/app/src/main/res/values/strings.xml index be187f17d..be063bdf8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -219,4 +219,5 @@ Channel Utilization Air Utilization Device Metrics + Node Details From e3499eae8d18cc332fb7792a9d52d95186ae1919 Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Wed, 21 Aug 2024 14:35:16 -0700 Subject: [PATCH 06/12] Making detekt happy. --- .../mesh/model/NodeDetailsViewModel.kt | 21 ++++++++++++------- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 2 +- .../mesh/ui/components/CustomCharts.kt | 10 ++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt index 598f2502d..7f296bf0e 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.geeksville.mesh.DeviceMetrics +import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.database.MeshLogRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,9 +25,13 @@ class NodeDetailsViewModel @Inject constructor( private val meshLogRepository: MeshLogRepository ) : ViewModel() { + // TODO Switch this to use the proto buf directly, this will impact our composable functions. private val _dataEntry = MutableStateFlow>(emptyList()) val dataEntries: StateFlow> = _dataEntry + private val _environmentMetrics = MutableStateFlow>(emptyList()) + val environmentMetrics: StateFlow> = _environmentMetrics + /** * Gets the short name of the node identified by `nodeNum`. */ @@ -36,20 +41,22 @@ class NodeDetailsViewModel @Inject constructor( } /** - * Used to set the Node we will be viewing charts for. + * Used to set the Node for which the user will see charts for. */ fun setSelectedNode(nodeNum: Int) { viewModelScope.launch { meshLogRepository.getTelemetryFrom(nodeNum).collect { - val tmp = mutableListOf() + val deviceMet = mutableListOf() + val environmentMet = mutableListOf() for (telemetry in it) { - val time = telemetry.time * 1000.0.toLong() + val time = telemetry.time * 1000.0.toLong() // TODO Won't need if (telemetry.hasDeviceMetrics()) - tmp.add(DataEntry(time = time, DeviceMetrics(telemetry.deviceMetrics))) + deviceMet.add(DataEntry(time = time, DeviceMetrics(telemetry.deviceMetrics))) + if (telemetry.hasEnvironmentMetrics()) + environmentMet.add(telemetry.environmentMetrics) } - _dataEntry.value = tmp + _dataEntry.value = deviceMet } - } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 50ea0095d..ba4921b55 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -128,7 +128,7 @@ fun NodeDetailsScreen( DeviceMetricsChart( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(0.33f), + .fillMaxHeight(fraction = 0.33f), data.toList() ) 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 index 95e77e5a9..1f83cdcb6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -40,6 +40,8 @@ import com.geeksville.mesh.R import com.geeksville.mesh.model.DataEntry import com.geeksville.mesh.ui.BatteryInfo import com.geeksville.mesh.ui.components.ChartConstants.COLORS +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.PERCENT_LINE_LIMIT @@ -56,6 +58,8 @@ private object ChartConstants { const val PERCENT_VERTICAL_SPACING = 25f const val PERCENT_LINE_LIMIT = 4 const val TEXT_PAINT_ALPHA = 192 + const val LINE_ON = 10f + const val LINE_OFF = 20f } @Suppress("LongMethod") @@ -271,7 +275,7 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { color = color, strokeWidth = 1.dp.toPx(), cap = StrokeCap.Round, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 20f), 0f) + pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f) ) lineY += PERCENT_VERTICAL_SPACING } @@ -335,8 +339,8 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { if (isLine) { drawLine( color = color, - start = Offset(0f, size.height / 2f), - end = Offset(16f, size.height / 2f), + start = Offset(x = 0f, y = size.height / 2f), + end = Offset(x = 16f, y = size.height / 2f), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round, ) From 2a4d6df739fa9bf1ff82169a7b5e4f7a8f2ad0dd Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Fri, 23 Aug 2024 22:52:32 -0700 Subject: [PATCH 07/12] Implemented the Environment Metrics chart; the user is able to swipe between charts. Still missing a few things at this point. --- .../mesh/model/NodeDetailsViewModel.kt | 31 +- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 65 +-- .../mesh/ui/components/CustomCharts.kt | 478 +++++++++++++++--- app/src/main/res/values/strings.xml | 3 + 4 files changed, 446 insertions(+), 131 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt index 7f296bf0e..423cd7539 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -2,8 +2,7 @@ package com.geeksville.mesh.model import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.geeksville.mesh.DeviceMetrics -import com.geeksville.mesh.TelemetryProtos +import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.database.MeshLogRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -14,10 +13,6 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import javax.inject.Inject -/** - * Contains the data necessary to build the Device Metrics Chart. - */ -data class DataEntry(val time: Long, val deviceMetrics: DeviceMetrics) @HiltViewModel class NodeDetailsViewModel @Inject constructor( @@ -25,12 +20,11 @@ class NodeDetailsViewModel @Inject constructor( private val meshLogRepository: MeshLogRepository ) : ViewModel() { - // TODO Switch this to use the proto buf directly, this will impact our composable functions. - private val _dataEntry = MutableStateFlow>(emptyList()) - val dataEntries: StateFlow> = _dataEntry + private val _deviceMetrics = MutableStateFlow>(emptyList()) + val deviceMetrics: StateFlow> = _deviceMetrics - private val _environmentMetrics = MutableStateFlow>(emptyList()) - val environmentMetrics: StateFlow> = _environmentMetrics + private val _environmentMetrics = MutableStateFlow>(emptyList()) + val environmentMetrics: StateFlow> = _environmentMetrics /** * Gets the short name of the node identified by `nodeNum`. @@ -46,16 +40,17 @@ class NodeDetailsViewModel @Inject constructor( fun setSelectedNode(nodeNum: Int) { viewModelScope.launch { meshLogRepository.getTelemetryFrom(nodeNum).collect { - val deviceMet = mutableListOf() - val environmentMet = mutableListOf() + val deviceList = mutableListOf() + val environmentList = mutableListOf() for (telemetry in it) { - val time = telemetry.time * 1000.0.toLong() // TODO Won't need if (telemetry.hasDeviceMetrics()) - deviceMet.add(DataEntry(time = time, DeviceMetrics(telemetry.deviceMetrics))) - if (telemetry.hasEnvironmentMetrics()) - environmentMet.add(telemetry.environmentMetrics) + deviceList.add(telemetry) + /* Avoiding negative outliers */ + if (telemetry.hasEnvironmentMetrics() && telemetry.environmentMetrics.relativeHumidity >= 0f) + environmentList.add(telemetry) } - _dataEntry.value = deviceMet + _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 index ba4921b55..bd42eada8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -1,17 +1,12 @@ package com.geeksville.mesh.ui -import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Scaffold @@ -21,7 +16,6 @@ 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.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.res.colorResource @@ -35,11 +29,10 @@ import androidx.lifecycle.lifecycleScope import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging import com.geeksville.mesh.model.NodeDetailsViewModel -import com.geeksville.mesh.ui.components.DeviceMetricsCard -import com.geeksville.mesh.ui.components.DeviceMetricsChart +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 -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch internal fun FragmentManager.navigateToNodeDetails(nodeNum: Int? = null) { @@ -65,6 +58,9 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { val nodeNum = arguments?.getInt("nodeNum") if (nodeNum != null) model.setSelectedNode(nodeNum) + /* We only need to get the nodes name once. */ + var nodeName: String? = "" + lifecycleScope.launch { nodeName = model.getNodeName(nodeNum ?: 0) } return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -72,8 +68,7 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { AppTheme { NodeDetailsScreen( model = model, - coroutineScope = lifecycleScope, - nodeNum = nodeNum, + nodeName = nodeName, navigateBack = { parentFragmentManager.popBackStack() } @@ -84,24 +79,23 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { } } -@SuppressLint("CoroutineCreationDuringComposition") +@OptIn(ExperimentalFoundationApi::class) @Composable fun NodeDetailsScreen( model: NodeDetailsViewModel = hiltViewModel(), - coroutineScope: CoroutineScope, - nodeNum: Int? = null, + nodeName: String?, navigateBack: () -> Unit, ) { // TODO Need to let user know when we don't have data to display - val data by model.dataEntries.collectAsStateWithLifecycle() - /* We only need to get the nodes name once. */ - var nodeName: String? = "" - coroutineScope.launch { nodeName = model.getNodeName(nodeNum ?: 0) } + + val deviceMetrics by model.deviceMetrics.collectAsStateWithLifecycle() + val environmentMetrics by model.environmentMetrics.collectAsStateWithLifecycle() + + val pagerState = rememberPagerState(pageCount = { 2 }) Scaffold( /* - * NOTE: Perhaps we can use a Pager to navigate from graph to graph. - * The bottom bar could be used to enable other actions such as clear data. + * NOTE: The bottom bar could be used to enable other actions such as clear or export data. **/ topBar = { TopAppBar( @@ -124,22 +118,17 @@ fun NodeDetailsScreen( }, ) { innerPadding -> - Column { - DeviceMetricsChart( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.33f), - data.toList() - ) - - /* Device Metric Cards */ - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - ) { - items(data.reversed()) { dataEntry -> DeviceMetricsCard(dataEntry) } + // TODO need tabs that help the user know what tab they are located in + // TODO it would be cool to animate swipe + HorizontalPager( + state = pagerState, + ) { page -> + // TODO Maybe the no data thing can be handled here also + when (page) { + 0 -> DeviceMetricsScreen(innerPadding = innerPadding, telemetries = deviceMetrics) + 1 -> EnvironmentMetricsScreen(innerPadding = innerPadding, telemetries = environmentMetrics) } + } } } 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 index 1f83cdcb6..3b72e48c5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -6,14 +6,19 @@ 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 @@ -24,10 +29,13 @@ 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 @@ -37,97 +45,126 @@ 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.model.DataEntry +import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.ui.BatteryInfo -import com.geeksville.mesh.ui.components.ChartConstants.COLORS +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.PERCENT_LINE_LIMIT -import com.geeksville.mesh.ui.components.ChartConstants.PERCENT_VERTICAL_SPACING +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 COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) + 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 PERCENT_VERTICAL_SPACING = 25f - const val PERCENT_LINE_LIMIT = 4 + 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()) { dataEntry -> DeviceMetricsCard(dataEntry) } + } + } +} + +@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()) { envMetric -> EnvironmentMetricsCard(telemetry = envMetric)} + } + } } @Suppress("LongMethod") @Composable -fun DeviceMetricsChart(modifier: Modifier = Modifier, data: List) { +private fun DeviceMetricsChart(modifier: Modifier = Modifier, telemetries: List) { - if (data.isEmpty()) + if (telemetries.isEmpty()) return - ChartHeader(amount = data.size, title = stringResource(R.string.device_metrics)) + ChartHeader(amount = telemetries.size, title = stringResource(R.string.device_metrics)) Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface - val spacing = 0f - - val (oldestMetrics, newestMetrics) = remember(key1 = data) { - Pair( - data.minBy { it.time }, - data.maxBy { it.time } - ) - } + val spacing = LEFT_CHART_SPACING Box(contentAlignment = Alignment.TopStart) { - PercentageChartLayer(modifier = modifier, graphColor = graphColor) + 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 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 - } - - val spacePerEntry = (width - spacing) / data.size + val spacePerEntry = (width - spacing) / telemetries.size val dataPointRadius = 2.dp.toPx() var lastX: Float val strokePath = Path().apply { - for (i in data.indices) { - val dataEntry = data[i] - val nextDataEntry = data.getOrNull(i + 1) ?: data.last() - val leftRatio = dataEntry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE - val rightRatio = nextDataEntry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE + 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 = dataEntry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE + val chUtilRatio = telemetry.deviceMetrics.channelUtilization / MAX_PERCENT_VALUE val yChUtil = height - spacing - (chUtilRatio * height) drawCircle( - color = COLORS[1], + color = DEVICE_METRICS_COLORS[1], radius = dataPointRadius, center = Offset(x1, yChUtil) ) /* Air Utilization Transmit */ - val airUtilRatio = dataEntry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE + val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE val yAirUtil = height - spacing - (airUtilRatio * height) drawCircle( - color = COLORS[2], + color = DEVICE_METRICS_COLORS[2], radius = dataPointRadius, center = Offset(x1, yAirUtil) ) @@ -146,40 +183,197 @@ fun DeviceMetricsChart(modifier: Modifier = Modifier, data: List) { /* Battery Line */ drawPath( path = strokePath, - color = COLORS[0], + color = DEVICE_METRICS_COLORS[0], style = Stroke( width = dataPointRadius, cap = StrokeCap.Round ) ) + } - /* X - Labels: Time */ - drawContext.canvas.nativeCanvas.apply { - drawText( - TIME_FORMAT.format(oldestMetrics.time), - 8.dp.toPx(), - 12.dp.toPx(), - textPaint - ) - drawText( - TIME_FORMAT.format(newestMetrics.time), - width - 116.dp.toPx(), - 12.dp.toPx(), - textPaint + 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) { + + if (telemetries.isEmpty()) + return + + ChartHeader(amount = telemetries.size, title = stringResource(R.string.environment_metrics)) + + 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)) - ChartLegend() // TODO function will adapt for the specific chart + EnvironmentLegend() Spacer(modifier = Modifier.height(16.dp)) } @Composable -fun DeviceMetricsCard(dataEntry: DataEntry) { - val deviceMetrics = dataEntry.deviceMetrics +private fun DeviceMetricsCard(telemetry: Telemetry) { + val deviceMetrics = telemetry.deviceMetrics + val time = telemetry.time * MS_PER_SEC Card( modifier = Modifier .fillMaxWidth() @@ -199,7 +393,7 @@ fun DeviceMetricsCard(dataEntry: DataEntry) { horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = TIME_FORMAT.format(dataEntry.time), + text = TIME_FORMAT.format(time), style = TextStyle(fontWeight = FontWeight.Bold), fontSize = MaterialTheme.typography.button.fontSize ) @@ -212,12 +406,12 @@ fun DeviceMetricsCard(dataEntry: DataEntry) { Spacer(modifier = Modifier.height(4.dp)) - /* Channel Utilization and Air Utilization Tx*/ + /* Channel Utilization and Air Utilization Tx */ Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween ) { - val text = "%s %.2f%% %s %.2f%%".format( + val text = "%s %.2f%% %s %.2f%%".format( stringResource(R.string.channel_utilization), deviceMetrics.channelUtilization, stringResource(R.string.air_utilization), @@ -235,6 +429,74 @@ fun DeviceMetricsCard(dataEntry: DataEntry) { } } +@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( @@ -245,14 +507,24 @@ private fun ChartHeader(amount: Int, title: String) { Text( text = "$amount $title", modifier = Modifier.wrapContentWidth(), - color = MaterialTheme.colors.onSurface, - style = MaterialTheme.typography.body1 + 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 PercentageChartLayer(modifier: Modifier, graphColor: Color) { +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) { @@ -260,9 +532,9 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { val width = size.width - 28.dp.toPx() /* Horizontal Lines */ - var lineY = 0f - for (i in 0..PERCENT_LINE_LIMIT) { - val ratio = lineY / MAX_PERCENT_VALUE + 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 @@ -277,7 +549,7 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { cap = StrokeCap.Round, pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f) ) - lineY += PERCENT_VERTICAL_SPACING + lineY += verticalSpacing } /* Y Labels */ @@ -290,42 +562,98 @@ private fun PercentageChartLayer(modifier: Modifier, graphColor: Color) { alpha = TEXT_PAINT_ALPHA } drawContext.canvas.nativeCanvas.apply { - var currentLabel = 0f - for (i in 0..PERCENT_LINE_LIMIT) { - val ratio = currentLabel / MAX_PERCENT_VALUE + var label = minValue + for (i in 0..LINE_LIMIT) { + val ratio = (label - minValue) / range val y = height - (ratio * height) drawText( - "${currentLabel.toInt()}", + "${label.toInt()}", width + 4.dp.toPx(), y + 4.dp.toPx(), textPaint ) - currentLabel += PERCENT_VERTICAL_SPACING + 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 ChartLegend() { +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 = COLORS[0], isLine = true) + 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.channel_utilization), color = COLORS[1]) + 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.air_utilization), color = COLORS[2]) + LegendLabel(text = stringResource(R.string.humidity), color = ENVIRONMENT_METRICS_COLORS[1], isLine = true) Spacer(modifier = Modifier.weight(1f)) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index be063bdf8..669d5c522 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -220,4 +220,7 @@ Air Utilization Device Metrics Node Details + Environment Metrics + Temperature + Humidity From 1c05fb3761ae5239cb546a984cb4b6d2f2c531bc Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Sat, 24 Aug 2024 17:15:44 -0700 Subject: [PATCH 08/12] Added tabs as a visual aid for navigating the pager. --- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 61 ++++++++++++++++--- .../drawable/baseline_charging_station_24.xml | 7 +++ .../res/drawable/baseline_thermostat_24.xml | 7 +++ app/src/main/res/values/strings.xml | 1 + 4 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 app/src/main/res/drawable/baseline_charging_station_24.xml create mode 100644 app/src/main/res/drawable/baseline_thermostat_24.xml diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index bd42eada8..8db62a3e8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -5,7 +5,13 @@ 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 @@ -16,10 +22,15 @@ 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 @@ -96,7 +107,7 @@ fun NodeDetailsScreen( 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), @@ -105,6 +116,7 @@ fun NodeDetailsScreen( Text( text = "${stringResource(R.string.node_details)}: $nodeName", ) + HorizontalTabs(pagerState) }, navigationIcon = { IconButton(onClick = navigateBack) { @@ -117,18 +129,47 @@ fun NodeDetailsScreen( ) }, ) { innerPadding -> - - // TODO need tabs that help the user know what tab they are located in - // TODO it would be cool to animate swipe - HorizontalPager( - state = pagerState, - ) { page -> - // TODO Maybe the no data thing can be handled here also + HorizontalPager(state = pagerState) { page -> when (page) { - 0 -> DeviceMetricsScreen(innerPadding = innerPadding, telemetries = deviceMetrics) - 1 -> EnvironmentMetricsScreen(innerPadding = innerPadding, telemetries = environmentMetrics) + 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 = if (iteration == 0) + ImageVector.vectorResource(R.drawable.baseline_charging_station_24) + else + ImageVector.vectorResource(R.drawable.baseline_thermostat_24) + Icon( + imageVector = imageVector, + contentDescription = stringResource(R.string.tab), + tint = color + ) } } } 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..00d5ed302 --- /dev/null +++ b/app/src/main/res/drawable/baseline_charging_station_24.xml @@ -0,0 +1,7 @@ + + + + + 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..b2850c23f --- /dev/null +++ b/app/src/main/res/drawable/baseline_thermostat_24.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a0c41bd86..47f05a558 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -225,4 +225,5 @@ Environment Metrics Temperature Humidity + Tab From aa10fcd171ccbc8bf2e5045b55ceef9ddd8fbee1 Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Sat, 24 Aug 2024 18:08:45 -0700 Subject: [PATCH 09/12] Placed the header so we could see when we have no data. --- .../java/com/geeksville/mesh/ui/NodeDetailsFragment.kt | 2 -- .../com/geeksville/mesh/ui/components/CustomCharts.kt | 10 ++++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 8db62a3e8..00aa646bc 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -97,8 +97,6 @@ fun NodeDetailsScreen( nodeName: String?, navigateBack: () -> Unit, ) { - // TODO Need to let user know when we don't have data to display - val deviceMetrics by model.deviceMetrics.collectAsStateWithLifecycle() val environmentMetrics by model.environmentMetrics.collectAsStateWithLifecycle() 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 index 3b72e48c5..603b65449 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -89,7 +89,7 @@ fun DeviceMetricsScreen(innerPadding: PaddingValues, telemetries: List DeviceMetricsCard(dataEntry) } + items(telemetries.reversed()) { telemetry -> DeviceMetricsCard(telemetry) } } } } @@ -110,7 +110,7 @@ fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List EnvironmentMetricsCard(telemetry = envMetric)} + items(telemetries.reversed()) { telemetry -> EnvironmentMetricsCard(telemetry)} } } } @@ -119,11 +119,10 @@ fun EnvironmentMetricsScreen(innerPadding: PaddingValues, telemetries: List) { + ChartHeader(amount = telemetries.size, title = stringResource(R.string.device_metrics)) if (telemetries.isEmpty()) return - ChartHeader(amount = telemetries.size, title = stringResource(R.string.device_metrics)) - Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface @@ -209,11 +208,10 @@ private fun DeviceMetricsChart(modifier: Modifier = Modifier, telemetries: List< @Composable private fun EnvironmentMetricsChart(modifier: Modifier = Modifier, telemetries: List) { + ChartHeader(amount = telemetries.size, title = stringResource(R.string.environment_metrics)) if (telemetries.isEmpty()) return - ChartHeader(amount = telemetries.size, title = stringResource(R.string.environment_metrics)) - Spacer(modifier = Modifier.height(16.dp)) val graphColor = MaterialTheme.colors.onSurface From abf20d3947c66eb1b1d5b272eaf7274ed384a94c Mon Sep 17 00:00:00 2001 From: andrekir Date: Sun, 25 Aug 2024 07:07:39 -0300 Subject: [PATCH 10/12] detekt fixes --- .../main/java/com/geeksville/mesh/model/UIState.kt | 3 +-- .../com/geeksville/mesh/ui/components/CustomCharts.kt | 2 ++ .../res/drawable/baseline_charging_station_24.xml | 11 +++++++---- app/src/main/res/drawable/baseline_thermostat_24.xml | 11 +++++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 88ec4641b..bec391d9c 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -194,7 +194,7 @@ class UIViewModel @Inject constructor( nodeDB.getNodes(state.sort, state.filter, state.includeUnknown) }.stateIn( scope = viewModelScope, - started = WhileSubscribed(STOP_TIMEOUT_MILLIS), + started = WhileSubscribed(5_000), initialValue = emptyList(), ) @@ -315,7 +315,6 @@ class UIViewModel @Inject constructor( } companion object { - private const val STOP_TIMEOUT_MILLIS = 5_000L fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE) } 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 index 603b65449..8d9d6edec 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CustomCharts.kt @@ -1,3 +1,5 @@ +@file:Suppress("TooManyFunctions") + package com.geeksville.mesh.ui.components import android.graphics.Paint diff --git a/app/src/main/res/drawable/baseline_charging_station_24.xml b/app/src/main/res/drawable/baseline_charging_station_24.xml index 00d5ed302..5efc33810 100644 --- a/app/src/main/res/drawable/baseline_charging_station_24.xml +++ b/app/src/main/res/drawable/baseline_charging_station_24.xml @@ -1,7 +1,10 @@ - - + - + android:pathData="M14.5,11l-3,6v-4h-2l3,-6v4H14.5zM7,1h10c1.1,0 2,0.9 2,2v18c0,1.1 -0.9,2 -2,2H7c-1.1,0 -2,-0.9 -2,-2V3C5,1.9 5.9,1 7,1zM7,6v12h10V6H7z" /> diff --git a/app/src/main/res/drawable/baseline_thermostat_24.xml b/app/src/main/res/drawable/baseline_thermostat_24.xml index b2850c23f..79f663280 100644 --- a/app/src/main/res/drawable/baseline_thermostat_24.xml +++ b/app/src/main/res/drawable/baseline_thermostat_24.xml @@ -1,7 +1,10 @@ - - + - + android:pathData="M15,13V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v8c-1.21,0.91 -2,2.37 -2,4c0,2.76 2.24,5 5,5s5,-2.24 5,-5C17,15.37 16.21,13.91 15,13zM11,11V5c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v1h-1v1h1v1v1h-1v1h1v1H11z" /> From dab951469502975fb605e3dd646352c5dc9aa8b2 Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Sun, 25 Aug 2024 11:35:44 -0700 Subject: [PATCH 11/12] Addressed changes recommended by review. --- .../mesh/model/NodeDetailsViewModel.kt | 5 +---- .../geeksville/mesh/ui/NodeDetailsFragment.kt | 21 ++++++++++++------- app/src/main/res/values/strings.xml | 1 - 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt index 423cd7539..fbb6a8be3 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -29,10 +29,7 @@ class NodeDetailsViewModel @Inject constructor( /** * Gets the short name of the node identified by `nodeNum`. */ - @OptIn(ExperimentalCoroutinesApi::class) - suspend fun getNodeName(nodeNum: Int): String? { - return nodeDB.nodeDBbyNum.mapLatest { it[nodeNum] }.first()?.user?.shortName - } + fun getNodeName(nodeNum: Int): String? = nodeDB.nodeDBbyNum.value[nodeNum]?.user?.shortName /** * Used to set the Node for which the user will see charts for. diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 00aa646bc..92df5da12 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -69,9 +69,8 @@ class NodeDetailsFragment : ScreenFragment("NodeDetails"), Logging { val nodeNum = arguments?.getInt("nodeNum") if (nodeNum != null) model.setSelectedNode(nodeNum) - /* We only need to get the nodes name once. */ - var nodeName: String? = "" - lifecycleScope.launch { nodeName = model.getNodeName(nodeNum ?: 0) } + + val nodeName = model.getNodeName(nodeNum ?: 0) return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -159,13 +158,19 @@ fun HorizontalTabs(pagerState: PagerState) { else Color.LightGray - val imageVector = if (iteration == 0) - ImageVector.vectorResource(R.drawable.baseline_charging_station_24) + val (imageVector, contentDescription) = if (iteration == 0) + Pair(ImageVector.vectorResource( + R.drawable.baseline_charging_station_24), + stringResource(R.string.device_metrics) + ) else - ImageVector.vectorResource(R.drawable.baseline_thermostat_24) + Pair( + ImageVector.vectorResource(R.drawable.baseline_thermostat_24), + stringResource(R.string.environment_metrics) + ) Icon( - imageVector = imageVector, - contentDescription = stringResource(R.string.tab), + imageVector, + contentDescription, tint = color ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6f2844ebe..eda7a53a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -226,5 +226,4 @@ Environment Metrics Temperature Humidity - Tab From 4fe12c08d6f10b56c87edbb2f059595499dd21a6 Mon Sep 17 00:00:00 2001 From: Robert-0410 Date: Wed, 28 Aug 2024 18:37:22 -0700 Subject: [PATCH 12/12] Removed unused imports. --- .../java/com/geeksville/mesh/model/NodeDetailsViewModel.kt | 3 --- .../main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt | 2 -- 2 files changed, 5 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt index fbb6a8be3..17f0cb873 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDetailsViewModel.kt @@ -5,11 +5,8 @@ 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.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch import javax.inject.Inject diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt index 92df5da12..b4d9dddbe 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsFragment.kt @@ -36,7 +36,6 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.lifecycleScope import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging import com.geeksville.mesh.model.NodeDetailsViewModel @@ -44,7 +43,6 @@ 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 -import kotlinx.coroutines.launch internal fun FragmentManager.navigateToNodeDetails(nodeNum: Int? = null) { val nodeDetailsFragment = NodeDetailsFragment().apply {