feat: Metrics time selection (#1396)

This commit is contained in:
Robert-0410 2024-11-11 12:54:26 -08:00 committed by GitHub
parent 5480174ec9
commit 7e54ad950c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 526 additions and 26 deletions

View file

@ -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<List<Telemetry>> =
meshLogDao.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS)
fun getTelemetryFrom(nodeNum: Int, timeFrame: Long): Flow<List<Telemetry>> =
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<List<MeshLog>> = meshLogDao.getLogsFrom(nodeNum, portNum, maxItem)
oldestTime: Long = 0L
): Flow<List<MeshLog>> = 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<List<MeshPacket>> = getLogsFrom(nodeNum, portNum)
oldestTime: Long = 0L
): Flow<List<MeshPacket>> = getLogsFrom(nodeNum, portNum, oldestTime = oldestTime)
.mapLatest { list -> list.map { it.fromRadio.packet } }
.flowOn(Dispatchers.IO)

View file

@ -15,18 +15,20 @@ interface MeshLogDao {
@Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>>
/*
* 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<List<MeshLog>>
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int, timeFrame: Long = 0L): Flow<List<MeshLog>>
@Insert
fun insert(log: MeshLog)

View file

@ -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<MetricsState> = _state
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
val timeFrame: StateFlow<TimeFrame> = _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.
*/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -303,4 +303,12 @@
<item quantity="other">%d hops</item>
</plurals>
<string name="traceroute_diff">Hops towards %d Hops back %d</string>
<string name="twenty_four_hours">24H</string>
<string name="forty_eight_hours">48H</string>
<string name="one_week">1W</string>
<string name="two_weeks">2W</string>
<string name="one_month">1M</string>
<string name="max">Max</string>
<string name="selected">Selected</string>
<string name="not_selected">Not Selected</string>
</resources>