From 7e54ad950c6496965a73e040ed7671e6d14bfc1d Mon Sep 17 00:00:00 2001 From: Robert-0410 <62630290+Robert-0410@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:54:26 -0800 Subject: [PATCH] feat: Metrics time selection (#1396) --- .../mesh/database/MeshLogRepository.kt | 13 +- .../mesh/database/dao/MeshLogDao.kt | 12 +- .../geeksville/mesh/model/MetricsViewModel.kt | 62 ++- .../mesh/ui/components/CommonCharts.kt | 19 +- .../mesh/ui/components/DeviceMetrics.kt | 12 +- .../mesh/ui/components/EnvironmentMetrics.kt | 9 + .../mesh/ui/components/MetricsTimeSelector.kt | 403 ++++++++++++++++++ .../mesh/ui/components/SignalMetrics.kt | 14 +- app/src/main/res/values/strings.xml | 8 + 9 files changed, 526 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index 82c77eeea..fcbfebc4a 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -32,9 +32,12 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L .toBuilder().setTime((log.received_date / MILLIS_TO_SECONDS).toInt()).build() }.getOrNull() + /** + * @param timeFrame the oldest [Telemetry] to get. + */ @OptIn(ExperimentalCoroutinesApi::class) - fun getTelemetryFrom(nodeNum: Int): Flow> = - meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS) + fun getTelemetryFrom(nodeNum: Int, timeFrame: Long): Flow> = + meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS, timeFrame) .distinctUntilChanged() .mapLatest { list -> list.mapNotNull(::parseTelemetryLog) } .flowOn(Dispatchers.IO) @@ -43,7 +46,8 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, maxItem: Int = MAX_MESH_PACKETS, - ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem) + oldestTime: Long = 0L + ): Flow> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem, oldestTime) .distinctUntilChanged() .flowOn(Dispatchers.IO) @@ -55,7 +59,8 @@ class MeshLogRepository @Inject constructor(private val meshLogDaoLazy: dagger.L fun getMeshPacketsFrom( nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE, - ): Flow> = getLogsFrom(nodeNum, portNum) + oldestTime: Long = 0L + ): Flow> = getLogsFrom(nodeNum, portNum, oldestTime = oldestTime) .mapLatest { list -> list.map { it.fromRadio.packet } } .flowOn(Dispatchers.IO) diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt index f11227ff1..1fdb45389 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/MeshLogDao.kt @@ -15,18 +15,20 @@ interface MeshLogDao { @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem") fun getAllLogsInReceiveOrder(maxItem: Int): Flow> - /* - * Retrieves MeshPackets matching 'from_num' (nodeNum) and 'port_num' (PortNum). - * If 'portNum' is 0, returns all MeshPackets. Otherwise, filters by 'port_num'. + /** + * Retrieves [MeshLog]s matching 'from_num' (nodeNum) and 'port_num' (PortNum). + * + * @param portNum If 0, returns all MeshPackets. Otherwise, filters by 'port_num'. + * @param timeFrame oldest limit in milliseconds of [MeshLog]s we want to retrieve. */ @Query( """ SELECT * FROM log - WHERE from_num = :fromNum AND (:portNum = 0 AND port_num != 0 OR port_num = :portNum) + WHERE from_num = :fromNum AND (:portNum = 0 AND port_num != 0 OR port_num = :portNum) AND received_date > :timeFrame ORDER BY received_date DESC LIMIT 0,:maxItem """ ) - fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow> + fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int, timeFrame: Long = 0L): Flow> @Insert fun insert(log: MeshLog) diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index 6a7dba7e8..4c031ce14 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -2,6 +2,7 @@ package com.geeksville.mesh.model import android.app.Application import android.net.Uri +import androidx.annotation.StringRes import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -11,6 +12,7 @@ import com.geeksville.mesh.CoroutineDispatchers import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.MeshProtos.Position import com.geeksville.mesh.Portnums.PortNum +import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.android.Logging import com.geeksville.mesh.database.MeshLogRepository @@ -18,9 +20,11 @@ import com.geeksville.mesh.database.entity.MeshLog import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.ui.Route import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update @@ -55,6 +59,28 @@ data class MetricsState( } } +/** + * Supported time frames used to display data. + */ +@Suppress("MagicNumber") +enum class TimeFrame( + val milliseconds: Long, + @StringRes val strRes: Int +) { + TWENTY_FOUR_HOURS(86400000L, R.string.twenty_four_hours), + FORTY_EIGHT_HOURS(172800000L, R.string.forty_eight_hours), + ONE_WEEK(604800000L, R.string.one_week), + TWO_WEEKS(1209600000L, R.string.two_weeks), + ONE_MONTH(2629800000L, R.string.one_month), + MAX(0L, R.string.max); + + fun calculateOldestTime(): Long = if (this == MAX) { + MAX.milliseconds + } else { + System.currentTimeMillis() - this.milliseconds + } +} + private fun MeshPacket.hasValidSignal(): Boolean = rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0) @@ -91,6 +117,9 @@ class MetricsViewModel @Inject constructor( private val _state = MutableStateFlow(MetricsState.Empty) val state: StateFlow = _state + private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS) + val timeFrame: StateFlow = _timeFrame + init { radioConfigRepository.deviceProfileFlow.onEach { profile -> val moduleConfig = profile.moduleConfig @@ -102,20 +131,27 @@ class MetricsViewModel @Inject constructor( } }.launchIn(viewModelScope) - meshLogRepository.getTelemetryFrom(destNum).onEach { telemetry -> - _state.update { state -> - state.copy( - deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, - environmentMetrics = telemetry.filter { - it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f - }, - ) + @OptIn(ExperimentalCoroutinesApi::class) + _timeFrame.flatMapLatest { timeFrame -> + meshLogRepository.getTelemetryFrom(destNum, timeFrame.calculateOldestTime()).onEach { telemetry -> + _state.update { state -> + state.copy( + deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, + environmentMetrics = telemetry.filter { + it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f + }, + ) + } } }.launchIn(viewModelScope) - meshLogRepository.getMeshPacketsFrom(destNum).onEach { meshPackets -> - _state.update { state -> - state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) + @OptIn(ExperimentalCoroutinesApi::class) + _timeFrame.flatMapLatest { timeFrame -> + val oldestTime = timeFrame.calculateOldestTime() + meshLogRepository.getMeshPacketsFrom(destNum, oldestTime = oldestTime).onEach { meshPackets -> + _state.update { state -> + state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) + } } }.launchIn(viewModelScope) @@ -143,6 +179,10 @@ class MetricsViewModel @Inject constructor( debug("MetricsViewModel cleared") } + fun setTimeFrame(timeFrame: TimeFrame) { + _timeFrame.value = timeFrame + } + /** * Write the persisted Position data out to a CSV file in the specified location. */ diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt index 45d5e60cb..613c78071 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.geeksville.mesh.R @@ -183,18 +184,20 @@ fun Legend(legendData: List, promptInfoDialog: () -> Unit) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, ) { Spacer(modifier = Modifier.weight(1f)) - for (data in legendData) { + legendData.forEachIndexed { index, data -> LegendLabel( text = stringResource(data.nameRes), color = data.color, isLine = data.isLine ) - Spacer(modifier = Modifier.width(4.dp)) + if (index != legendData.lastIndex) { + Spacer(modifier = Modifier.weight(1f)) + } } + Spacer(modifier = Modifier.width(4.dp)) Icon( imageVector = Icons.Default.Info, @@ -276,3 +279,13 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) { fontSize = MaterialTheme.typography.button.fontSize, ) } + +@Preview +@Composable +private fun LegendPreview() { + val data = listOf( + LegendData(nameRes = R.string.rssi, color = Color.Red), + LegendData(nameRes = R.string.snr, color = Color.Green) + ) + Legend(legendData = data, promptInfoDialog = {}) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt index 004d06581..914bdfaa0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt @@ -19,6 +19,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -84,6 +85,15 @@ fun DeviceMetricsScreen( state.deviceMetrics.reversed(), promptInfoDialog = { displayInfoDialog = true } ) + + val selectedTimeFrame by viewModel.timeFrame.collectAsState() + MetricsTimeSelector( + selectedTimeFrame, + onOptionSelected = { viewModel.setTimeFrame(it) } + ) { + TimeLabel(stringResource(it.strRes)) + } + /* Device Metric Cards */ LazyColumn( modifier = Modifier.fillMaxSize() @@ -156,7 +166,7 @@ private fun DeviceMetricsChart( center = Offset(x1, yChUtil) ) - /* Air Utilization Transmit */ + /* Air Utilization Transmit */ val airUtilRatio = telemetry.deviceMetrics.airUtilTx / MAX_PERCENT_VALUE val yAirUtil = height - (airUtilRatio * height) drawCircle( diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt index f9d706728..3873f087e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt @@ -20,6 +20,7 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -117,6 +118,14 @@ fun EnvironmentMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) + val selectedTimeFrame by viewModel.timeFrame.collectAsState() + MetricsTimeSelector( + selectedTimeFrame, + onOptionSelected = { viewModel.setTimeFrame(it) } + ) { + TimeLabel(stringResource(it.strRes)) + } + /* Environment Metric Cards */ LazyColumn( modifier = Modifier.fillMaxSize() diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt b/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt new file mode 100644 index 000000000..92b3c8d5e --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt @@ -0,0 +1,403 @@ +/* Inspired by https://gist.github.com/zach-klippenstein/7ae8874db304f957d6bb91263e292117 */ + +package com.geeksville.mesh.ui.components + +import android.annotation.SuppressLint +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.horizontalDrag +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow.Companion.Ellipsis +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.model.TimeFrame + +private const val NO_OPTION_INDEX = -1 + +private val TRACK_PADDING = 2.dp + +private val TRACK_COLOR = Color.LightGray.copy(alpha = .5f) + +private val PRESSED_TRACK_PADDING = 1.dp + +private val OPTION_PADDING = 5.dp + +private const val PRESSED_UNSELECTED_ALPHA = .6f + +private val BACKGROUND_SHAPE = RoundedCornerShape(8.dp) + +/** + * Provides the user with a set of time options they can choose from that controls + * the time frame the data being plotted was received. + */ +@Composable +fun MetricsTimeSelector( + selectedTime: TimeFrame, + onOptionSelected: (TimeFrame) -> Unit, + modifier: Modifier = Modifier, + content: @Composable (TimeFrame) -> Unit +) { + val state = remember { TimeSelectorState() } + state.selectedOption = state.options.indexOf(selectedTime) + state.onOptionSelected = { onOptionSelected(state.options[it]) } + + /* Animate between whole-number indices so we don't need to do pixel calculations. */ + val selectedIndexOffset by animateFloatAsState(state.selectedOption.toFloat(), label = "Selected Index Offset") + + Layout( + content = { + SelectedIndicator(state) + Dividers(state) + TimeOptions(state, content) + }, + modifier = modifier + .fillMaxWidth() + .then(state.inputModifier) + .background(TRACK_COLOR, BACKGROUND_SHAPE) + .padding(TRACK_PADDING) + ) { measurables, constraints -> + val (indicatorMeasurable, dividersMeasurable, optionsMeasurable) = measurables + + /* Measure the options first so we know how tall to make the indicator. */ + val optionsPlaceable = optionsMeasurable.measure(constraints) + state.updatePressedScale(optionsPlaceable.height, this) + + /* Measure the indicator and dividers to be the right size. */ + val indicatorPlaceable = indicatorMeasurable.measure( + Constraints.fixed( + width = optionsPlaceable.width / state.options.size, + height = optionsPlaceable.height + ) + ) + + val dividersPlaceable = dividersMeasurable.measure( + Constraints.fixed( + width = optionsPlaceable.width, + height = optionsPlaceable.height + ) + ) + + layout(optionsPlaceable.width, optionsPlaceable.height) { + val optionWidth = optionsPlaceable.width / state.options.size + + /* Place the indicator first so that it's below the option labels. */ + indicatorPlaceable.placeRelative( + x = (selectedIndexOffset * optionWidth).toInt(), + y = 0 + ) + dividersPlaceable.placeRelative(IntOffset.Zero) + optionsPlaceable.placeRelative(IntOffset.Zero) + } + } +} + +/** + * Visual representation of the time option the user may select. + */ +@Composable +fun TimeLabel(text: String) { + Text(text, maxLines = 1, overflow = Ellipsis) +} + +/** + * Draws the selected indicator on the [MetricsTimeSelector] track. + */ +@Composable +private fun SelectedIndicator(state: TimeSelectorState) { + Box( + Modifier + .then( + state.optionScaleModifier( + pressed = state.pressedOption == state.selectedOption, + option = state.selectedOption + ) + ) + .shadow(4.dp, BACKGROUND_SHAPE) + .background(MaterialTheme.colors.background, BACKGROUND_SHAPE) + ) +} + +/** + * Draws dividers between [TimeLabel]s. + */ +@Composable +private fun Dividers(state: TimeSelectorState) { + /* Animate each divider independently. */ + val alphas = (0 until state.options.size).map { i -> + val selectionAdjacent = i == state.selectedOption || i - 1 == state.selectedOption + animateFloatAsState(if (selectionAdjacent) 0f else 1f, label = "Dividers") + } + + Canvas(Modifier.fillMaxSize()) { + val optionWidth = size.width / state.options.size + val dividerPadding = TRACK_PADDING + PRESSED_TRACK_PADDING + + alphas.forEachIndexed { i, alpha -> + val x = i * optionWidth + drawLine( + Color.White, + alpha = alpha.value, + start = Offset(x, dividerPadding.toPx()), + end = Offset(x, size.height - dividerPadding.toPx()) + ) + } + } +} + +/** + * Draws the time options available to the user. + */ +@Composable +private fun TimeOptions( + state: TimeSelectorState, + content: @Composable (TimeFrame) -> Unit +) { + CompositionLocalProvider( + LocalTextStyle provides TextStyle(fontWeight = FontWeight.Medium) + ) { + Row( + horizontalArrangement = spacedBy(TRACK_PADDING), + modifier = Modifier + .fillMaxWidth() + .selectableGroup() + ) { + state.options.forEachIndexed { i, timeFrame -> + val isSelected = i == state.selectedOption + val isPressed = i == state.pressedOption + + /* Unselected presses are represented by fading. */ + val alpha by animateFloatAsState( + if (!isSelected && isPressed) PRESSED_UNSELECTED_ALPHA else 1f, + label = "Unselected" + ) + + val semanticsModifier = Modifier.semantics(mergeDescendants = true) { + selected = isSelected + role = Role.Button + onClick { state.onOptionSelected(i); true } + stateDescription = if (isSelected) "Selected" else "Not selected" + } + + Box( + Modifier + /* Divide space evenly between all options. */ + .weight(1f) + .then(semanticsModifier) + .padding(OPTION_PADDING) + /* Draw pressed indication when not selected. */ + .alpha(alpha) + /* Selected presses are represented by scaling. */ + .then(state.optionScaleModifier(isPressed && isSelected, i)) + /* Center the option content. */ + .wrapContentWidth() + ) { + content(timeFrame) + } + } + } + } +} + +/** + * Contains and handles the state necessary to present the [MetricsTimeSelector] to the user. + */ +private class TimeSelectorState { + val options = TimeFrame.entries.toTypedArray() + var selectedOption by mutableIntStateOf(0) + var onOptionSelected: (Int) -> Unit by mutableStateOf({}) + var pressedOption by mutableIntStateOf(NO_OPTION_INDEX) + + /** + * Scale factor that should be used to scale pressed option. When this scale is applied, + * exactly [PRESSED_TRACK_PADDING] will be added around the element's usual size. + */ + var pressedSelectedScale by mutableFloatStateOf(1f) + private set + + /** + * Calculates the scale factor we need to use for pressed options to get the desired padding. + */ + fun updatePressedScale(controlHeight: Int, density: Density) { + with(density) { + val pressedPadding = PRESSED_TRACK_PADDING * 2 + val pressedHeight = controlHeight - pressedPadding.toPx() + pressedSelectedScale = pressedHeight / controlHeight + } + } + + /** + * Returns a [Modifier] that will scale an element so that it gets [PRESSED_TRACK_PADDING] extra + * padding around it. The scale will be animated. + * + * The scale is also performed around either the left or right edge of the element if the option + * is the first or last option, respectively. In those cases, the scale will also be translated so + * that [PRESSED_TRACK_PADDING] will be added on the left or right edge. + */ + @SuppressLint("ModifierFactoryExtensionFunction") + fun optionScaleModifier( + pressed: Boolean, + option: Int, + ): Modifier = Modifier.composed { + val scale by animateFloatAsState(if (pressed) pressedSelectedScale else 1f, label = "Scale") + val xOffset by animateDpAsState(if (pressed) PRESSED_TRACK_PADDING else 0.dp, label = "x Offset") + + graphicsLayer { + this.scaleX = scale + this.scaleY = scale + + /* Scales on the ends should gravitate to that edge. */ + this.transformOrigin = TransformOrigin( + pivotFractionX = when (option) { + 0 -> 0f + options.size - 1 -> 1f + else -> .5f + }, + pivotFractionY = .5f + ) + + /* But should still move inwards to keep the pressed padding consistent with top and bottom. */ + this.translationX = when (option) { + 0 -> xOffset.toPx() + options.size - 1 -> -xOffset.toPx() + else -> 0f + } + } + } + + /** + * A [Modifier] that will listen for touch gestures and update the selected and pressed properties + * of this state appropriately. + */ + val inputModifier = Modifier.pointerInput(options.size) { + val optionWidth = size.width / options.size + + /* Helper to calculate which option an event occurred in. */ + fun optionIndex(change: PointerInputChange): Int = + ((change.position.x / size.width.toFloat()) * options.size) + .toInt() + .coerceIn(0, options.size - 1) + + awaitEachGesture { + val down = awaitFirstDown() + + pressedOption = optionIndex(down) + val downOnSelected = pressedOption == selectedOption + val optionBounds = Rect( + left = pressedOption * optionWidth.toFloat(), + right = (pressedOption + 1) * optionWidth.toFloat(), + top = 0f, + bottom = size.height.toFloat() + ) + + if (downOnSelected) { + horizontalDrag(down.id) { change -> + pressedOption = optionIndex(change) + + if (pressedOption != selectedOption) { + onOptionSelected(pressedOption) + } + } + } else { + waitForUpOrCancellation(inBounds = optionBounds) + /* Null means the gesture was cancelled (e.g. dragged out of bounds). */ + ?.let { onOptionSelected(pressedOption) } + } + pressedOption = NO_OPTION_INDEX + } + } +} + +/** + * Works with bounds that may not be at 0,0. + */ +@Suppress("ReturnCount") +private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(inBounds: Rect): PointerInputChange? { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + if (event.changes.all { it.changedToUp() }) { + /* All pointers are up */ + return event.changes[0] + } + + if (event.changes.any { it.isConsumed || !inBounds.contains(it.position) }) { + /* Canceled */ + return null + } + + val consumeCheck = awaitPointerEvent(PointerEventPass.Final) + if (consumeCheck.changes.any { it.isConsumed }) { + return null + } + } +} + +@Preview +@Composable +fun MetricsTimeSelectorPreview() { + MaterialTheme { + Surface { + Column(Modifier.padding(8.dp)) { + + var selectedOption by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) } + MetricsTimeSelector( + selectedOption, + onOptionSelected = { selectedOption = it } + ) { + TimeLabel(stringResource(it.strRes)) + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt index f1972676f..fba27394c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt @@ -22,6 +22,7 @@ import androidx.compose.material.Surface import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,6 +34,7 @@ import androidx.compose.ui.graphics.Color 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 @@ -59,8 +61,8 @@ private enum class Metric(val min: Float, val max: Float) { fun difference() = max - min } private val LEGEND_DATA = listOf( - LegendData(nameRes = R.string.snr, color = METRICS_COLORS[Metric.SNR.ordinal]), - LegendData(nameRes = R.string.rssi, color = METRICS_COLORS[Metric.RSSI.ordinal]) + LegendData(nameRes = R.string.rssi, color = METRICS_COLORS[Metric.RSSI.ordinal]), + LegendData(nameRes = R.string.snr, color = METRICS_COLORS[Metric.SNR.ordinal]) ) @Composable @@ -90,6 +92,14 @@ fun SignalMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) + val selectedTimeFrame by viewModel.timeFrame.collectAsState() + MetricsTimeSelector( + selectedTimeFrame, + onOptionSelected = { viewModel.setTimeFrame(it) } + ) { + TimeLabel(stringResource(it.strRes)) + } + LazyColumn( modifier = Modifier.fillMaxSize() ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e0f72389c..a1ded9395 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,4 +303,12 @@ %d hops Hops towards %d Hops back %d + 24H + 48H + 1W + 2W + 1M + Max + Selected + Not Selected