mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Metrics time selection (#1396)
This commit is contained in:
parent
5480174ec9
commit
7e54ad950c
9 changed files with 526 additions and 26 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue