Run spotless ahead of 2523 to make the diff easier (#2571)

This commit is contained in:
DaneEvans 2025-07-30 18:42:34 +10:00 committed by GitHub
parent 64ead16d83
commit d336f23486
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 282 additions and 483 deletions

View file

@ -20,9 +20,9 @@ package com.geeksville.mesh
import android.graphics.Color
import android.os.Parcelable
import com.geeksville.mesh.util.GPSFormat
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.bearing
import com.geeksville.mesh.util.latLongToMeter
import com.geeksville.mesh.util.anonymize
import com.geeksville.mesh.util.onlineTimeThreshold
import kotlinx.parcelize.Parcelize
@ -40,33 +40,27 @@ data class MeshUser(
val role: Int = 0,
) : Parcelable {
override fun toString(): String {
return "MeshUser(id=${id.anonymize}, " +
"longName=${longName.anonymize}, " +
"shortName=${shortName.anonymize}, " +
"hwModel=$hwModelString, " +
"isLicensed=$isLicensed, " +
"role=$role)"
}
override fun toString(): String = "MeshUser(id=${id.anonymize}, " +
"longName=${longName.anonymize}, " +
"shortName=${shortName.anonymize}, " +
"hwModel=$hwModelString, " +
"isLicensed=$isLicensed, " +
"role=$role)"
/** Create our model object from a protobuf.
/** Create our model object from a protobuf. */
constructor(p: MeshProtos.User) : this(p.id, p.longName, p.shortName, p.hwModel, p.isLicensed, p.roleValue)
/**
* a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot or null
* if unset
*/
constructor(p: MeshProtos.User) : this(
p.id,
p.longName,
p.shortName,
p.hwModel,
p.isLicensed,
p.roleValue
)
/** a string version of the hardware model, converted into pretty lowercase and changing _ to -, and p to dot
* or null if unset
* */
val hwModelString: String?
get() =
if (hwModel == MeshProtos.HardwareModel.UNSET) null
else hwModel.name.replace('_', '-').replace('p', '.').lowercase()
if (hwModel == MeshProtos.HardwareModel.UNSET) {
null
} else {
hwModel.name.replace('_', '-').replace('p', '.').lowercase()
}
}
@Parcelize
@ -82,16 +76,22 @@ data class Position(
) : Parcelable {
companion object {
/// Convert to a double representation of degrees
// / Convert to a double representation of degrees
fun degD(i: Int) = i * 1e-7
fun degI(d: Double) = (d * 1e7).toInt()
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
/** Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will be used.
/**
* Create our model object from a protobuf. If time is unspecified in the protobuf, the provided default time will
* be used.
*/
constructor(position: MeshProtos.Position, defaultTime: Int = currentTime()) : this(
constructor(
position: MeshProtos.Position,
defaultTime: Int = currentTime(),
) : this(
// We prefer the int version of lat/lon but if not available use the depreciated legacy version
degD(position.latitudeI),
degD(position.longitudeI),
@ -100,21 +100,20 @@ data class Position(
position.satsInView,
position.groundSpeed,
position.groundTrack,
position.precisionBits
position.precisionBits,
)
/// @return distance in meters to some other node (or null if unknown)
// / @return distance in meters to some other node (or null if unknown)
fun distance(o: Position) = latLongToMeter(latitude, longitude, o.latitude, o.longitude)
/// @return bearing to the other position in degrees
// / @return bearing to the other position in degrees
fun bearing(o: Position) = bearing(latitude, longitude, o.latitude, o.longitude)
// If GPS gives a crap position don't crash our app
fun isValid(): Boolean {
return latitude != 0.0 && longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
}
fun isValid(): Boolean = latitude != 0.0 &&
longitude != 0.0 &&
(latitude >= -90 && latitude <= 90.0) &&
(longitude >= -180 && longitude <= 180)
fun gpsString(gpsFormat: Int): String = when (gpsFormat) {
ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat.DEC_VALUE -> GPSFormat.DEC(this)
@ -124,12 +123,10 @@ data class Position(
else -> GPSFormat.DEC(this)
}
override fun toString(): String {
return "Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=${time})"
}
override fun toString(): String =
"Position(lat=${latitude.anonymize}, lon=${longitude.anonymize}, alt=${altitude.anonymize}, time=$time)"
}
@Parcelize
data class DeviceMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
@ -143,16 +140,11 @@ data class DeviceMetrics(
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
/** Create our model object from a protobuf.
*/
constructor(p: TelemetryProtos.DeviceMetrics, telemetryTime: Int = currentTime()) : this(
telemetryTime,
p.batteryLevel,
p.voltage,
p.channelUtilization,
p.airUtilTx,
p.uptimeSeconds,
)
/** Create our model object from a protobuf. */
constructor(
p: TelemetryProtos.DeviceMetrics,
telemetryTime: Int = currentTime(),
) : this(telemetryTime, p.batteryLevel, p.voltage, p.channelUtilization, p.airUtilTx, p.uptimeSeconds)
}
@Parcelize
@ -184,7 +176,7 @@ data class NodeInfo(
var deviceMetrics: DeviceMetrics? = null,
var channel: Int = 0,
var environmentMetrics: EnvironmentMetrics? = null,
var hopsAway: Int = 0
var hopsAway: Int = 0,
) : Parcelable {
val colors: Pair<Int, Int>
@ -196,46 +188,53 @@ data class NodeInfo(
return (if (brightness > 0.5) Color.BLACK else Color.WHITE) to Color.rgb(r, g, b)
}
val batteryLevel get() = deviceMetrics?.batteryLevel
val voltage get() = deviceMetrics?.voltage
val batteryStr get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
val batteryLevel
get() = deviceMetrics?.batteryLevel
/**
* true if the device was heard from recently
*/
val voltage
get() = deviceMetrics?.voltage
val batteryStr
get() = if (batteryLevel in 1..100) String.format("%d%%", batteryLevel) else ""
/** true if the device was heard from recently */
val isOnline: Boolean
get() {
return lastHeard > onlineTimeThreshold()
}
/// return the position if it is valid, else null
// / return the position if it is valid, else null
val validPosition: Position?
get() {
return position?.takeIf { it.isValid() }
}
/// @return distance in meters to some other node (or null if unknown)
// / @return distance in meters to some other node (or null if unknown)
fun distance(o: NodeInfo?): Int? {
val p = validPosition
val op = o?.validPosition
return if (p != null && op != null) p.distance(op).toInt() else null
}
/// @return bearing to the other position in degrees
// / @return bearing to the other position in degrees
fun bearing(o: NodeInfo?): Int? {
val p = validPosition
val op = o?.validPosition
return if (p != null && op != null) p.bearing(op).toInt() else null
}
/// @return a nice human readable string for the distance, or null for unknown
// / @return a nice human readable string for the distance, or null for unknown
fun distanceStr(o: NodeInfo?, prefUnits: Int = 0) = distance(o)?.let { dist ->
when {
dist == 0 -> null // same point
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 -> "%.0f m".format(dist.toDouble())
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 -> "%.1f km".format(dist / 1000.0)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 -> "%.0f ft".format(dist.toDouble()*3.281)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 -> "%.1f mi".format(dist / 1609.34)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist < 1000 ->
"%.0f m".format(dist.toDouble())
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC_VALUE && dist >= 1000 ->
"%.1f km".format(dist / 1000.0)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist < 1609 ->
"%.0f ft".format(dist.toDouble() * 3.281)
prefUnits == ConfigProtos.Config.DisplayConfig.DisplayUnits.IMPERIAL_VALUE && dist >= 1609 ->
"%.1f mi".format(dist / 1609.34)
else -> null
}
}

View file

@ -42,94 +42,64 @@ class Converters : Logging {
}
@TypeConverter
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio {
return try {
MeshProtos.FromRadio.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToFromRadio TypeConverter error:", ex)
MeshProtos.FromRadio.getDefaultInstance()
}
fun bytesToFromRadio(bytes: ByteArray): MeshProtos.FromRadio = try {
MeshProtos.FromRadio.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToFromRadio TypeConverter error:", ex)
MeshProtos.FromRadio.getDefaultInstance()
}
@TypeConverter
fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? {
return value.toByteArray()
}
@TypeConverter fun fromRadioToBytes(value: MeshProtos.FromRadio): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToUser(bytes: ByteArray): MeshProtos.User {
return try {
MeshProtos.User.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToUser TypeConverter error:", ex)
MeshProtos.User.getDefaultInstance()
}
fun bytesToUser(bytes: ByteArray): MeshProtos.User = try {
MeshProtos.User.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToUser TypeConverter error:", ex)
MeshProtos.User.getDefaultInstance()
}
@TypeConverter
fun userToBytes(value: MeshProtos.User): ByteArray? {
return value.toByteArray()
}
@TypeConverter fun userToBytes(value: MeshProtos.User): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position {
return try {
MeshProtos.Position.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToPosition TypeConverter error:", ex)
MeshProtos.Position.getDefaultInstance()
}
fun bytesToPosition(bytes: ByteArray): MeshProtos.Position = try {
MeshProtos.Position.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToPosition TypeConverter error:", ex)
MeshProtos.Position.getDefaultInstance()
}
@TypeConverter
fun positionToBytes(value: MeshProtos.Position): ByteArray? {
return value.toByteArray()
}
@TypeConverter fun positionToBytes(value: MeshProtos.Position): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry {
return try {
TelemetryProtos.Telemetry.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToTelemetry TypeConverter error:", ex)
TelemetryProtos.Telemetry.getDefaultInstance()
}
fun bytesToTelemetry(bytes: ByteArray): TelemetryProtos.Telemetry = try {
TelemetryProtos.Telemetry.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToTelemetry TypeConverter error:", ex)
TelemetryProtos.Telemetry.getDefaultInstance()
}
@TypeConverter
fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? {
return value.toByteArray()
}
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount {
return try {
PaxcountProtos.Paxcount.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToPaxcounter TypeConverter error:", ex)
PaxcountProtos.Paxcount.getDefaultInstance()
}
fun bytesToPaxcounter(bytes: ByteArray): PaxcountProtos.Paxcount = try {
PaxcountProtos.Paxcount.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToPaxcounter TypeConverter error:", ex)
PaxcountProtos.Paxcount.getDefaultInstance()
}
@TypeConverter
fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? {
return value.toByteArray()
}
@TypeConverter fun paxCounterToBytes(value: PaxcountProtos.Paxcount): ByteArray? = value.toByteArray()
@TypeConverter
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata {
return try {
MeshProtos.DeviceMetadata.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToMetadata TypeConverter error:", ex)
MeshProtos.DeviceMetadata.getDefaultInstance()
}
fun bytesToMetadata(bytes: ByteArray): MeshProtos.DeviceMetadata = try {
MeshProtos.DeviceMetadata.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToMetadata TypeConverter error:", ex)
MeshProtos.DeviceMetadata.getDefaultInstance()
}
@TypeConverter
fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? {
return value.toByteArray()
}
@TypeConverter fun metadataToBytes(value: MeshProtos.DeviceMetadata): ByteArray? = value.toByteArray()
@TypeConverter
fun fromStringList(value: String?): List<String>? {
@ -148,12 +118,7 @@ class Converters : Logging {
}
@TypeConverter
fun bytesToByteString(bytes: ByteArray?): ByteString? {
return if (bytes == null) null else ByteString.copyFrom(bytes)
}
fun bytesToByteString(bytes: ByteArray?): ByteString? = if (bytes == null) null else ByteString.copyFrom(bytes)
@TypeConverter
fun byteStringToBytes(value: ByteString?): ByteArray? {
return value?.toByteArray()
}
@TypeConverter fun byteStringToBytes(value: ByteString?): ByteArray? = value?.toByteArray()
}

View file

@ -84,69 +84,41 @@ data class NodeWithRelations(
}
}
@Entity(
tableName = "metadata",
indices = [
Index(value = ["num"]),
],
)
@Entity(tableName = "metadata", indices = [Index(value = ["num"])])
data class MetadataEntity(
@PrimaryKey val num: Int,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB)
val proto: MeshProtos.DeviceMetadata,
@ColumnInfo(name = "proto", typeAffinity = ColumnInfo.BLOB) val proto: MeshProtos.DeviceMetadata,
val timestamp: Long = System.currentTimeMillis(),
)
@Suppress("MagicNumber")
@Entity(tableName = "nodes")
data class NodeEntity(
@PrimaryKey(autoGenerate = false)
val num: Int, // This is immutable, and used as a key
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
@PrimaryKey(autoGenerate = false) val num: Int, // This is immutable, and used as a key
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) var user: MeshProtos.User = MeshProtos.User.getDefaultInstance(),
@ColumnInfo(name = "long_name") var longName: String? = null,
@ColumnInfo(name = "short_name") var shortName: String? = null, // used in includeUnknown filter
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var position: MeshProtos.Position = MeshProtos.Position.getDefaultInstance(),
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var snr: Float = Float.MAX_VALUE,
var rssi: Int = Int.MAX_VALUE,
@ColumnInfo(name = "last_heard")
var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
@ColumnInfo(name = "last_heard") var lastHeard: Int = 0, // the last time we've seen this node in secs since 1970
@ColumnInfo(name = "device_metrics", typeAffinity = ColumnInfo.BLOB)
var deviceTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
var channel: Int = 0,
@ColumnInfo(name = "via_mqtt")
var viaMqtt: Boolean = false,
@ColumnInfo(name = "hops_away")
var hopsAway: Int = -1,
@ColumnInfo(name = "is_favorite")
var isFavorite: Boolean = false,
@ColumnInfo(name = "is_ignored", defaultValue = "0")
var isIgnored: Boolean = false,
@ColumnInfo(name = "via_mqtt") var viaMqtt: Boolean = false,
@ColumnInfo(name = "hops_away") var hopsAway: Int = -1,
@ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false,
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
var paxcounter: PaxcountProtos.Paxcount = PaxcountProtos.Paxcount.getDefaultInstance(),
@ColumnInfo(name = "public_key")
var publicKey: ByteString? = null,
@ColumnInfo(name = "public_key") var publicKey: ByteString? = null,
) {
val deviceMetrics: TelemetryProtos.DeviceMetrics
get() = deviceTelemetry.deviceMetrics
@ -154,8 +126,11 @@ data class NodeEntity(
val environmentMetrics: TelemetryProtos.EnvironmentMetrics
get() = environmentTelemetry.environmentMetrics
val isUnknownUser get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC get() = (publicKey ?: user.publicKey).isNotEmpty()
val isUnknownUser
get() = user.hwModel == MeshProtos.HardwareModel.UNSET
val hasPKC
get() = (publicKey ?: user.publicKey).isNotEmpty()
fun setPosition(p: MeshProtos.Position, defaultTime: Int = currentTime()) {
position = p.copy { time = if (p.time != 0) p.time else defaultTime }
@ -163,9 +138,7 @@ data class NodeEntity(
longitude = degD(p.longitudeI)
}
/**
* true if the device was heard from recently
*/
/** true if the device was heard from recently */
val isOnline: Boolean
get() {
return lastHeard > onlineTimeThreshold()
@ -174,22 +147,27 @@ data class NodeEntity(
companion object {
// Convert to a double representation of degrees
fun degD(i: Int) = i * 1e-7
fun degI(d: Double) = (d * 1e7).toInt()
val ERROR_BYTE_STRING: ByteString = ByteString.copyFrom(ByteArray(32) { 0 })
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
}
fun toNodeInfo() = NodeInfo(
num = num,
user = MeshUser(
user =
MeshUser(
id = user.id,
longName = user.longName,
shortName = user.shortName,
hwModel = user.hwModel,
role = user.roleValue,
).takeIf { user.id.isNotEmpty() },
position = Position(
)
.takeIf { user.id.isNotEmpty() },
position =
Position(
latitude = latitude,
longitude = longitude,
altitude = position.altitude,
@ -198,11 +176,13 @@ data class NodeEntity(
groundSpeed = position.groundSpeed,
groundTrack = position.groundTrack,
precisionBits = position.precisionBits,
).takeIf { it.isValid() },
)
.takeIf { it.isValid() },
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceMetrics = DeviceMetrics(
deviceMetrics =
DeviceMetrics(
time = deviceTelemetry.time,
batteryLevel = deviceMetrics.batteryLevel,
voltage = deviceMetrics.voltage,
@ -211,7 +191,8 @@ data class NodeEntity(
uptimeSeconds = deviceMetrics.uptimeSeconds,
),
channel = channel,
environmentMetrics = EnvironmentMetrics(
environmentMetrics =
EnvironmentMetrics(
time = environmentTelemetry.time,
temperature = environmentMetrics.temperature,
relativeHumidity = environmentMetrics.relativeHumidity,

View file

@ -57,14 +57,13 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.geeksville.mesh.R
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
import java.text.DateFormat
object CommonCharts {
val DATE_TIME_FORMAT: DateFormat =
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val DATE_TIME_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
val TIME_MINUTE_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.SHORT)
val DATE_TIME_MINUTE_FORMAT: DateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT)
const val MS_PER_SEC = 1000L
@ -86,13 +85,13 @@ fun ChartHeader(amount: Int) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$amount ${stringResource(R.string.logs)}",
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
@ -103,14 +102,10 @@ fun ChartHeader(amount: Int) {
* @param lineColors A list of 5 [Color]s for the chart lines, 0 being the lowest line on the chart.
*/
@Composable
fun HorizontalLinesOverlay(
modifier: Modifier,
lineColors: List<Color>,
) {
fun HorizontalLinesOverlay(modifier: Modifier, lineColors: List<Color>) {
/* 100 is a good number to divide into quarters */
val verticalSpacing = MAX_PERCENT_VALUE / LINE_LIMIT
Canvas(modifier = modifier) {
val lineStart = 0f
val height = size.height
val width = size.width
@ -125,72 +120,51 @@ fun HorizontalLinesOverlay(
color = lineColors[i],
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
)
lineY += verticalSpacing
}
}
}
/**
* Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`).
*/
/** Draws labels on the Y-axis with respect to the range. Defined by (`maxValue` - `minValue`). */
@Composable
fun YAxisLabels(
modifier: Modifier,
labelColor: Color,
minValue: Float,
maxValue: Float,
) {
fun YAxisLabels(modifier: Modifier, labelColor: Color, minValue: Float, maxValue: Float) {
val range = maxValue - minValue
val verticalSpacing = range / LINE_LIMIT
val density = LocalDensity.current
Canvas(modifier = modifier) {
val height = size.height
/* Y Labels */
val textPaint = Paint().apply {
color = labelColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
val textPaint =
Paint().apply {
color = labelColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
drawContext.canvas.nativeCanvas.apply {
var label = minValue
repeat(LINE_LIMIT + 1) {
val ratio = (label - minValue) / range
val y = height - (ratio * height)
drawText(
"${label.toInt()}",
0f,
y + 4.dp.toPx(),
textPaint
)
drawText("${label.toInt()}", 0f, y + 4.dp.toPx(), textPaint)
label += verticalSpacing
}
}
}
}
/**
* Draws the vertical lines to help the user relate the plotted data within a time frame.
*/
/** Draws the vertical lines to help the user relate the plotted data within a time frame. */
@Composable
fun TimeAxisOverlay(
modifier: Modifier,
oldest: Int,
newest: Int,
timeInterval: Long
) {
fun TimeAxisOverlay(modifier: Modifier, oldest: Int, newest: Int, timeInterval: Long) {
val range = newest - oldest
val density = LocalDensity.current
val lineColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width
@ -200,13 +174,14 @@ fun TimeAxisOverlay(
current -= timeRemaining
current += timeInterval
val textPaint = Paint().apply {
color = lineColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
val textPaint =
Paint().apply {
color = lineColor.toArgb()
textAlign = Paint.Align.LEFT
textSize = density.run { 12.dp.toPx() }
typeface = setTypeface(Typeface.create(Typeface.DEFAULT, Typeface.BOLD))
alpha = TEXT_PAINT_ALPHA
}
/* Vertical Lines with labels */
drawContext.canvas.nativeCanvas.apply {
@ -219,39 +194,22 @@ fun TimeAxisOverlay(
color = lineColor,
strokeWidth = 1.dp.toPx(),
cap = StrokeCap.Round,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f)
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
)
/* Time */
drawText(
TIME_FORMAT.format(current * MS_PER_SEC),
x,
0f,
textPaint
)
drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint)
/* Date */
drawText(
DATE_FORMAT.format(current * MS_PER_SEC),
x,
DATE_Y,
textPaint
)
drawText(DATE_FORMAT.format(current * MS_PER_SEC), x, DATE_Y, textPaint)
current += timeInterval
}
}
}
}
/**
* Draws the `oldest` and `newest` times for the respective telemetry data.
* Expects time in seconds.
*/
/** Draws the `oldest` and `newest` times for the respective telemetry data. Expects time in seconds. */
@Composable
fun TimeLabels(
oldest: Int,
newest: Int,
) {
fun TimeLabels(oldest: Int, newest: Int) {
Row {
Text(
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
@ -264,7 +222,7 @@ fun TimeLabels(
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
modifier = Modifier.wrapContentWidth(),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = 12.sp
fontSize = 12.sp,
)
}
}
@ -276,22 +234,11 @@ fun TimeLabels(
* @param promptInfoDialog Executes when the user presses the info icon.
*/
@Composable
fun Legend(
legendData: List<LegendData>,
displayInfoIcon: Boolean = true,
promptInfoDialog: () -> Unit = {}
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
fun Legend(legendData: List<LegendData>, displayInfoIcon: Boolean = true, promptInfoDialog: () -> Unit = {}) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Modifier.weight(1f))
legendData.forEachIndexed { index, data ->
LegendLabel(
text = stringResource(data.nameRes),
color = data.color,
isLine = data.isLine
)
LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine)
if (index != legendData.lastIndex) {
Spacer(modifier = Modifier.weight(1f))
@ -302,7 +249,7 @@ fun Legend(
Icon(
imageVector = Icons.Default.Info,
modifier = Modifier.clickable { promptInfoDialog() },
contentDescription = stringResource(R.string.info)
contentDescription = stringResource(R.string.info),
)
}
@ -320,11 +267,7 @@ fun Legend(
fun LegendInfoDialog(pairedRes: List<Pair<Int, Int>>, onDismiss: () -> Unit) {
AlertDialog(
title = {
Text(
text = stringResource(R.string.info),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Text(text = stringResource(R.string.info), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
text = {
Column {
@ -332,32 +275,23 @@ fun LegendInfoDialog(pairedRes: List<Pair<Int, Int>>, onDismiss: () -> Unit) {
Text(
text = stringResource(pair.first),
style = TextStyle(fontWeight = FontWeight.Bold),
textDecoration = TextDecoration.Underline
)
Text(
text = stringResource(pair.second),
style = TextStyle.Default,
textDecoration = TextDecoration.Underline,
)
Text(text = stringResource(pair.second), style = TextStyle.Default)
Spacer(modifier = Modifier.height(24.dp))
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.close))
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) } },
shape = RoundedCornerShape(16.dp),
)
}
@Composable
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
Canvas(
modifier = Modifier.size(4.dp)
) {
Canvas(modifier = Modifier.size(4.dp)) {
if (isLine) {
drawLine(
color = color,
@ -367,9 +301,7 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
cap = StrokeCap.Round,
)
} else {
drawCircle(
color = color
)
drawCircle(color = color)
}
}
Spacer(modifier = Modifier.width(4.dp))
@ -383,9 +315,10 @@ private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
@Preview
@Composable
private fun LegendPreview() {
val data = listOf(
LegendData(nameRes = R.string.rssi, color = Color.Red),
LegendData(nameRes = R.string.snr, color = Color.Green)
)
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

@ -18,19 +18,35 @@
package com.geeksville.mesh.ui.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -38,32 +54,16 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.Portnums.PortNum
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.util.formatUptime
import com.geeksville.mesh.Portnums.PortNum
import java.text.DateFormat
import java.util.Date
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.remember
import com.geeksville.mesh.model.TimeFrame
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import com.geeksville.mesh.ui.common.components.OptionLabel
import com.geeksville.mesh.ui.common.components.SlidingSelector
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import com.geeksville.mesh.util.formatUptime
import java.text.DateFormat
import java.util.Date
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
@ -72,7 +72,7 @@ private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIG
private enum class PaxSeries(val color: Color, val legendRes: Int) {
PAX(Color.Black, R.string.pax),
BLE(Color.Cyan, R.string.ble_devices),
WIFI(Color.Green, R.string.wifi_devices)
WIFI(Color.Green, R.string.wifi_devices),
}
@Suppress("LongMethod")
@ -93,9 +93,7 @@ private fun PaxMetricsChart(
val minTime = times.minOrNull() ?: 0
val maxTime = times.maxOrNull() ?: 1
val timeDiff = maxTime - minTime
val dp = remember(timeFrame, screenWidth, timeDiff) {
timeFrame.dp(screenWidth, time = timeDiff.toLong())
}
val dp = remember(timeFrame, screenWidth, timeDiff) { timeFrame.dp(screenWidth, time = timeDiff.toLong()) }
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
@ -107,42 +105,21 @@ private fun PaxMetricsChart(
val visibleNewest = minTime + (timeDiff * rightRatio).toInt()
visibleOldest to visibleNewest
}
TimeLabels(
oldest = visibleTimeRange.first,
newest = visibleTimeRange.second
)
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.33f)
) {
Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) {
YAxisLabels(
modifier = Modifier
.weight(Y_AXIS_WEIGHT)
.fillMaxHeight()
.padding(start = 8.dp),
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp),
labelColor = MaterialTheme.colorScheme.onSurface,
minValue = minValue,
maxValue = maxValue
maxValue = maxValue,
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier
.horizontalScroll(state = scrollState, reverseScrolling = true)
.weight(CHART_WEIGHT)
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(CHART_WEIGHT),
) {
HorizontalLinesOverlay(
modifier.width(dp),
lineColors = List(size = 5) { Color.LightGray },
)
TimeAxisOverlay(
modifier.width(dp),
oldest = minTime,
newest = maxTime,
timeFrame.lineInterval()
)
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { Color.LightGray })
TimeAxisOverlay(modifier.width(dp), oldest = minTime, newest = maxTime, timeFrame.lineInterval())
Canvas(modifier = Modifier.width(dp).fillMaxHeight()) {
val width = size.width
val height = size.height
@ -155,7 +132,7 @@ private fun PaxMetricsChart(
color = color,
start = Offset(xForTime(series[i - 1].first), yForValue(series[i - 1].second)),
end = Offset(xForTime(series[i].first), yForValue(series[i].second)),
strokeWidth = 2.dp.toPx()
strokeWidth = 2.dp.toPx(),
)
}
}
@ -165,13 +142,10 @@ private fun PaxMetricsChart(
}
}
YAxisLabels(
modifier = Modifier
.weight(Y_AXIS_WEIGHT)
.fillMaxHeight()
.padding(end = 8.dp),
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp),
labelColor = MaterialTheme.colorScheme.onSurface,
minValue = minValue,
maxValue = maxValue
maxValue = maxValue,
)
}
Spacer(modifier = Modifier.height(16.dp))
@ -179,46 +153,48 @@ private fun PaxMetricsChart(
@Composable
@Suppress("MagicNumber", "LongMethod")
fun PaxMetricsScreen(
metricsViewModel: MetricsViewModel = hiltViewModel(),
) {
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val dateFormat = DateFormat.getDateTimeInstance()
var timeFrame by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) }
// Only show logs that can be decoded as PaxcountProtos.Paxcount
val paxMetrics = state.paxMetrics.mapNotNull { log ->
val pax = decodePaxFromLog(log)
if (pax != null) {
Pair(log, pax)
} else {
null
val paxMetrics =
state.paxMetrics.mapNotNull { log ->
val pax = decodePaxFromLog(log)
if (pax != null) {
Pair(log, pax)
} else {
null
}
}
}
// Prepare data for graph
val oldestTime = timeFrame.calculateOldestTime()
val graphData = paxMetrics.filter { it.first.received_date / 1000 >= oldestTime }
.map {
val t = (it.first.received_date / 1000).toInt()
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
val graphData =
paxMetrics
.filter { it.first.received_date / 1000 >= oldestTime }
.map {
val t = (it.first.received_date / 1000).toInt()
Triple(t, it.second.ble, it.second.wifi)
}
.sortedBy { it.first }
val totalSeries = graphData.map { it.first to (it.second + it.third) }
val bleSeries = graphData.map { it.first to it.second }
val wifiSeries = graphData.map { it.first to it.third }
val maxValue = (totalSeries.maxOfOrNull { it.second } ?: 1).toFloat().coerceAtLeast(1f)
val minValue = 0f
val legendData = listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
)
val legendData =
listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
)
Column(modifier = Modifier.fillMaxSize()) {
// Time frame selector
SlidingSelector(
options = TimeFrame.entries.toList(),
selectedOption = timeFrame,
onOptionSelected = { timeFrame = it }
onOptionSelected = { timeFrame = it },
) { tf: TimeFrame ->
OptionLabel(stringResource(tf.strRes))
}
@ -232,7 +208,7 @@ fun PaxMetricsScreen(
wifiSeries = wifiSeries,
minValue = minValue,
maxValue = maxValue,
timeFrame = timeFrame
timeFrame = timeFrame,
)
}
// List
@ -240,16 +216,11 @@ fun PaxMetricsScreen(
Text(
text = stringResource(R.string.no_pax_metrics_logs),
modifier = Modifier.fillMaxSize().padding(16.dp),
textAlign = TextAlign.Center
textAlign = TextAlign.Center,
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(paxMetrics) { (log, pax) ->
PaxMetricsItem(log, pax, dateFormat)
}
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
}
}
}
@ -261,8 +232,7 @@ fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? {
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
if (packet != null && packet.hasDecoded() &&
packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) {
if (packet != null && packet.hasDecoded() && packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) {
val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax
}
@ -313,33 +283,26 @@ fun unescapeProtoString(escaped: String): ByteArray {
@Composable
fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(
text = dateFormat.format(Date(log.received_date)),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
textAlign = TextAlign.End,
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
)
val total = pax.ble + pax.wifi
val summary = "PAX: $total (B:${pax.ble} W:${pax.wifi})"
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = summary,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f, fill = true)
modifier = Modifier.weight(1f, fill = true),
)
Text(
text = stringResource(R.string.uptime) + ": " + formatUptime(pax.uptime),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
modifier = Modifier.alignByBaseline()
modifier = Modifier.alignByBaseline(),
)
}
}

View file

@ -71,10 +71,10 @@ import com.geeksville.mesh.util.GraphUtil.plotPoint
@Suppress("MagicNumber")
private enum class Metric(val color: Color, val min: Float, val max: Float) {
SNR(Color.Green, -20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */
RSSI(Color.Blue, -140f, -20f);
/**
* Difference between the metrics `max` and `min` values.
*/
RSSI(Color.Blue, -140f, -20f),
;
/** Difference between the metrics `max` and `min` values. */
fun difference() = max - min
}
@ -82,54 +82,44 @@ private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private val LEGEND_DATA = listOf(
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
LegendData(nameRes = R.string.snr, color = Metric.SNR.color)
)
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
LegendData(nameRes = R.string.snr, color = Metric.SNR.color),
)
@Composable
fun SignalMetricsScreen(
viewModel: MetricsViewModel = hiltViewModel(),
) {
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
var displayInfoDialog by remember { mutableStateOf(false) }
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
val data = state.signalMetricsFiltered(selectedTimeFrame)
Column {
if (displayInfoDialog) {
LegendInfoDialog(
pairedRes = listOf(
Pair(R.string.snr, R.string.snr_definition),
Pair(R.string.rssi, R.string.rssi_definition)
),
onDismiss = { displayInfoDialog = false }
pairedRes =
listOf(Pair(R.string.snr, R.string.snr_definition), Pair(R.string.rssi, R.string.rssi_definition)),
onDismiss = { displayInfoDialog = false },
)
}
SignalMetricsChart(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(fraction = 0.33f),
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
meshPackets = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true }
promptInfoDialog = { displayInfoDialog = true },
)
SlidingSelector(
TimeFrame.entries.toList(),
selectedTimeFrame,
onOptionSelected = { viewModel.setTimeFrame(it) }
onOptionSelected = { viewModel.setTimeFrame(it) },
) {
OptionLabel(stringResource(it.strRes))
}
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
}
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { meshPacket -> SignalMetricsCard(meshPacket) } }
}
}
@ -139,26 +129,23 @@ private fun SignalMetricsChart(
modifier: Modifier = Modifier,
meshPackets: List<MeshPacket>,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit
promptInfoDialog: () -> Unit,
) {
ChartHeader(amount = meshPackets.size)
if (meshPackets.isEmpty()) {
return
}
val (oldest, newest) = remember(key1 = meshPackets) {
Pair(
meshPackets.minBy { it.rxTime },
meshPackets.maxBy { it.rxTime }
)
}
val (oldest, newest) =
remember(key1 = meshPackets) { Pair(meshPackets.minBy { it.rxTime }, meshPackets.maxBy { it.rxTime }) }
val timeDiff = newest.rxTime - oldest.rxTime
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
}
val dp by
remember(key1 = selectedTime) {
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.rxTime - oldest.rxTime).toLong()))
}
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
@ -174,10 +161,7 @@ private fun SignalMetricsChart(
visibleOldest to visibleNewest
}
TimeLabels(
oldest = visibleTimeRange.first,
newest = visibleTimeRange.second
)
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
@ -194,20 +178,15 @@ private fun SignalMetricsChart(
)
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier
.horizontalScroll(state = scrollState, reverseScrolling = true)
.weight(1f)
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
) {
HorizontalLinesOverlay(
modifier.width(dp),
lineColors = List(size = 5) { graphColor },
)
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(
modifier.width(dp),
oldest = oldest.rxTime,
newest = newest.rxTime,
selectedTime.lineInterval()
selectedTime.lineInterval(),
)
/* Plot SNR and RSSI */
@ -215,7 +194,6 @@ private fun SignalMetricsChart(
val width = size.width
/* Plot */
for (packet in meshPackets) {
val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
val x = xRatio * width
@ -225,7 +203,7 @@ private fun SignalMetricsChart(
color = Metric.SNR.color,
x = x,
value = packet.rxSnr - Metric.SNR.min,
divisor = snrDiff
divisor = snrDiff,
)
/* RSSI */
@ -234,7 +212,7 @@ private fun SignalMetricsChart(
color = Metric.RSSI.color,
x = x,
value = packet.rxRssi - Metric.RSSI.min,
divisor = rssiDiff
divisor = rssiDiff,
)
}
}
@ -257,35 +235,19 @@ private fun SignalMetricsChart(
@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket) {
val time = meshPacket.rxTime * MS_PER_SEC
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
SelectionContainer {
Row(
modifier = Modifier.fillMaxWidth()
) {
Row(modifier = Modifier.fillMaxWidth()) {
/* Data */
Box(
modifier = Modifier
.weight(weight = 5f)
.height(IntrinsicSize.Min)
) {
Column(
modifier = Modifier
.padding(8.dp)
) {
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
Column(modifier = Modifier.padding(8.dp)) {
/* Time */
Row(
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
@ -297,11 +259,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket) {
}
/* Signal Indicator */
Box(
modifier = Modifier
.weight(weight = 3f)
.height(IntrinsicSize.Max)
) {
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi)
}
}