mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Modularize remaining nodes code (#3599)
This commit is contained in:
parent
89bc9528c5
commit
315950b7c6
23 changed files with 125 additions and 141 deletions
|
|
@ -1,198 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.model
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.meshtastic.core.model.util.UnitConversions
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Green
|
||||
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
|
||||
import org.meshtastic.core.ui.theme.GraphColors.LightGreen
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Magenta
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Orange
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Pink
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Purple
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Red
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Yellow
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class Environment(val color: Color) {
|
||||
TEMPERATURE(Red) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature
|
||||
},
|
||||
HUMIDITY(InfantryBlue) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity
|
||||
},
|
||||
SOIL_TEMPERATURE(Pink) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.soilTemperature
|
||||
},
|
||||
SOIL_MOISTURE(Purple) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
|
||||
telemetry.environmentMetrics.soilMoisture?.toFloat()
|
||||
},
|
||||
BAROMETRIC_PRESSURE(Green) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
|
||||
},
|
||||
GAS_RESISTANCE(Yellow) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
|
||||
},
|
||||
IAQ(Magenta) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat()
|
||||
},
|
||||
LUX(LightGreen) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
|
||||
},
|
||||
UV_LUX(Orange) {
|
||||
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.uvLux
|
||||
}, ;
|
||||
|
||||
abstract fun getValue(telemetry: TelemetryProtos.Telemetry): Float?
|
||||
}
|
||||
|
||||
/**
|
||||
* @param metrics the filtered [List]
|
||||
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted
|
||||
* @param leftMinMax [Pair] with the min and max of the barometric pressure
|
||||
* @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ
|
||||
* @param times [Pair] with the oldest and newest times in that order
|
||||
*/
|
||||
data class EnvironmentGraphingData(
|
||||
val metrics: List<TelemetryProtos.Telemetry>,
|
||||
val shouldPlot: List<Boolean>,
|
||||
val leftMinMax: Pair<Float, Float> = Pair(0f, 0f),
|
||||
val rightMinMax: Pair<Float, Float> = Pair(0f, 0f),
|
||||
val times: Pair<Int, Int> = Pair(0, 0),
|
||||
)
|
||||
|
||||
data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.Telemetry> = emptyList()) {
|
||||
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
|
||||
|
||||
/**
|
||||
* Filters [environmentMetrics] based on a [org.meshtastic.feature.node.model.TimeFrame].
|
||||
*
|
||||
* @param timeFrame used to filter
|
||||
* @return [EnvironmentGraphingData]
|
||||
*/
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber")
|
||||
fun environmentMetricsFiltered(timeFrame: TimeFrame, useFahrenheit: Boolean = false): EnvironmentGraphingData {
|
||||
val oldestTime = timeFrame.calculateOldestTime()
|
||||
val telemetries = environmentMetrics.filter { it.time >= oldestTime }
|
||||
val shouldPlot = BooleanArray(Environment.entries.size) { false }
|
||||
if (telemetries.isEmpty()) {
|
||||
return EnvironmentGraphingData(metrics = telemetries, shouldPlot = shouldPlot.toList())
|
||||
}
|
||||
|
||||
/* Grab the combined min and max for temp, humidity, soil_Temperature, soilMoisture and iaq. */
|
||||
val minValues = mutableListOf<Float>()
|
||||
val maxValues = mutableListOf<Float>()
|
||||
|
||||
// Temperature
|
||||
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } }
|
||||
if (temperatures.isNotEmpty()) {
|
||||
var minTempValue = temperatures.minOf { it }
|
||||
var maxTempValue = temperatures.maxOf { it }
|
||||
if (useFahrenheit) {
|
||||
minTempValue = UnitConversions.celsiusToFahrenheit(minTempValue)
|
||||
maxTempValue = UnitConversions.celsiusToFahrenheit(maxTempValue)
|
||||
}
|
||||
minValues.add(minTempValue)
|
||||
maxValues.add(maxTempValue)
|
||||
shouldPlot[Environment.TEMPERATURE.ordinal] = true
|
||||
}
|
||||
|
||||
// Relative Humidity
|
||||
val humidities =
|
||||
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity?.takeIf { !it.isNaN() && it != 0.0f } }
|
||||
if (humidities.isNotEmpty()) {
|
||||
minValues.add(humidities.minOf { it })
|
||||
maxValues.add(humidities.maxOf { it })
|
||||
shouldPlot[Environment.HUMIDITY.ordinal] = true
|
||||
}
|
||||
|
||||
// Soil Temperature
|
||||
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature?.takeIf { !it.isNaN() } }
|
||||
if (soilTemperatures.isNotEmpty()) {
|
||||
var minSoilTemperatureValue = soilTemperatures.minOf { it }
|
||||
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
|
||||
if (useFahrenheit) {
|
||||
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
|
||||
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
|
||||
}
|
||||
minValues.add(minSoilTemperatureValue)
|
||||
maxValues.add(maxSoilTemperatureValue)
|
||||
shouldPlot[Environment.SOIL_TEMPERATURE.ordinal] = true
|
||||
}
|
||||
|
||||
// Soil Moisture
|
||||
val soilMoistures =
|
||||
telemetries.mapNotNull { it.environmentMetrics.soilMoisture?.takeIf { it != Int.MIN_VALUE } }
|
||||
if (soilMoistures.isNotEmpty()) {
|
||||
minValues.add(soilMoistures.minOf { it.toFloat() })
|
||||
maxValues.add(soilMoistures.maxOf { it.toFloat() })
|
||||
shouldPlot[Environment.SOIL_MOISTURE.ordinal] = true
|
||||
}
|
||||
|
||||
// IAQ
|
||||
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq?.takeIf { it != Int.MIN_VALUE } }
|
||||
if (iaqs.isNotEmpty()) {
|
||||
minValues.add(iaqs.minOf { it.toFloat() })
|
||||
maxValues.add(iaqs.maxOf { it.toFloat() })
|
||||
shouldPlot[Environment.IAQ.ordinal] = true
|
||||
}
|
||||
|
||||
// Barometric Pressure
|
||||
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure?.takeIf { !it.isNaN() } }
|
||||
var minPressureValue = 0f
|
||||
var maxPressureValue = 0f
|
||||
if (pressures.isNotEmpty()) {
|
||||
minPressureValue = pressures.minOf { it }
|
||||
maxPressureValue = pressures.maxOf { it }
|
||||
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] = true
|
||||
}
|
||||
|
||||
// Lux
|
||||
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux?.takeIf { !it.isNaN() } }
|
||||
if (luxValues.isNotEmpty()) {
|
||||
minValues.add(luxValues.minOf { it })
|
||||
maxValues.add(luxValues.maxOf { it })
|
||||
shouldPlot[Environment.LUX.ordinal] = true
|
||||
}
|
||||
|
||||
// UVLux
|
||||
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux?.takeIf { !it.isNaN() } }
|
||||
if (uvLuxValues.isNotEmpty()) {
|
||||
minValues.add(uvLuxValues.minOf { it })
|
||||
maxValues.add(uvLuxValues.maxOf { it })
|
||||
shouldPlot[Environment.UV_LUX.ordinal] = true
|
||||
}
|
||||
|
||||
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
|
||||
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
|
||||
|
||||
val (oldest, newest) = Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time })
|
||||
|
||||
return EnvironmentGraphingData(
|
||||
metrics = telemetries,
|
||||
shouldPlot = shouldPlot.toList(),
|
||||
leftMinMax = Pair(minPressureValue, maxPressureValue),
|
||||
rightMinMax = Pair(min, max),
|
||||
times = Pair(oldest.time, newest.time),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.model
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.toRoute
|
||||
import com.geeksville.mesh.util.safeNumber
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.meshtastic.core.data.repository.DeviceHardwareRepository
|
||||
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
|
||||
import org.meshtastic.core.data.repository.MeshLogRepository
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.navigation.NodesRoutes
|
||||
import org.meshtastic.core.prefs.map.MapPrefs
|
||||
import org.meshtastic.core.proto.toPosition
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.feature.map.model.CustomTileSource
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||
import org.meshtastic.proto.Portnums
|
||||
import org.meshtastic.proto.Portnums.PortNum
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedWriter
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.FileWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
|
||||
|
||||
private fun MeshPacket.hasValidSignal(): Boolean =
|
||||
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@HiltViewModel
|
||||
class MetricsViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val app: Application,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val meshLogRepository: MeshLogRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val deviceHardwareRepository: DeviceHardwareRepository,
|
||||
private val firmwareReleaseRepository: FirmwareReleaseRepository,
|
||||
private val mapPrefs: MapPrefs,
|
||||
) : ViewModel() {
|
||||
private val destNum = savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum
|
||||
|
||||
private fun MeshLog.hasValidTraceroute(): Boolean =
|
||||
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
|
||||
|
||||
/**
|
||||
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
|
||||
* freezing when viewing unknown nodes.
|
||||
*/
|
||||
private fun createFallbackNode(nodeNum: Int): Node {
|
||||
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
|
||||
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
|
||||
val longName = app.getString(R.string.fallback_node_name) + " $safeUserId"
|
||||
val defaultUser =
|
||||
MeshProtos.User.newBuilder()
|
||||
.setId(userId)
|
||||
.setLongName(longName)
|
||||
.setShortName(safeUserId)
|
||||
.setHwModel(MeshProtos.HardwareModel.UNSET)
|
||||
.build()
|
||||
|
||||
return Node(num = nodeNum, user = defaultUser)
|
||||
}
|
||||
|
||||
fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum)
|
||||
|
||||
val tileSource
|
||||
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
|
||||
|
||||
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
|
||||
|
||||
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
|
||||
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
|
||||
}
|
||||
|
||||
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
|
||||
|
||||
private val _state = MutableStateFlow(MetricsState.Empty)
|
||||
val state: StateFlow<MetricsState> = _state
|
||||
|
||||
private val _environmentState = MutableStateFlow(EnvironmentMetricsState())
|
||||
val environmentState: StateFlow<EnvironmentMetricsState> = _environmentState
|
||||
|
||||
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
|
||||
val timeFrame: StateFlow<TimeFrame> = _timeFrame
|
||||
|
||||
init {
|
||||
if (destNum != null) {
|
||||
nodeRepository.nodeDBbyNum
|
||||
.mapLatest { nodes -> nodes[destNum] to nodes.keys.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.onEach { (node, ourNode) ->
|
||||
// Create a fallback node if not found in database (for hidden clients, etc.)
|
||||
val actualNode = node ?: createFallbackNode(destNum)
|
||||
val deviceHardware =
|
||||
actualNode.user.hwModel.safeNumber().let {
|
||||
deviceHardwareRepository.getDeviceHardwareByModel(it)
|
||||
}
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
node = actualNode,
|
||||
isLocal = destNum == ourNode,
|
||||
deviceHardware = deviceHardware.getOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
radioConfigRepository.deviceProfileFlow
|
||||
.onEach { profile ->
|
||||
val moduleConfig = profile.moduleConfig
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
isManaged = profile.config.security.isManaged,
|
||||
isFahrenheit = moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
displayUnits = profile.config.display.units,
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getTelemetryFrom(destNum)
|
||||
.onEach { telemetry ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
|
||||
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
|
||||
hostMetrics = telemetry.filter { it.hasHostMetrics() },
|
||||
)
|
||||
}
|
||||
_environmentState.update { state ->
|
||||
state.copy(
|
||||
environmentMetrics =
|
||||
telemetry.filter {
|
||||
it.hasEnvironmentMetrics() &&
|
||||
it.environmentMetrics.hasRelativeHumidity() &&
|
||||
it.environmentMetrics.hasTemperature() &&
|
||||
!it.environmentMetrics.temperature.isNaN()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getMeshPacketsFrom(destNum)
|
||||
.onEach { meshPackets ->
|
||||
_state.update { state -> state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() }) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
combine(
|
||||
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
|
||||
meshLogRepository.getLogsFrom(destNum ?: 0, PortNum.TRACEROUTE_APP_VALUE),
|
||||
) { request, response ->
|
||||
_state.update { state ->
|
||||
state.copy(
|
||||
tracerouteRequests = request.filter { it.hasValidTraceroute() },
|
||||
tracerouteResults = response,
|
||||
)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getMeshPacketsFrom(destNum, PortNum.POSITION_APP_VALUE)
|
||||
.onEach { packets ->
|
||||
val distinctPositions =
|
||||
packets
|
||||
.mapNotNull { it.toPosition() }
|
||||
.asFlow()
|
||||
.distinctUntilChanged { old, new ->
|
||||
old.time == new.time ||
|
||||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
|
||||
}
|
||||
.toList()
|
||||
_state.update { state -> state.copy(positionLogs = distinctPositions) }
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getLogsFrom(destNum, Portnums.PortNum.PAXCOUNTER_APP_VALUE)
|
||||
.onEach { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
firmwareReleaseRepository.stableRelease
|
||||
.filterNotNull()
|
||||
.onEach { latestStable -> _state.update { state -> state.copy(latestStableFirmware = latestStable) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
firmwareReleaseRepository.alphaRelease
|
||||
.filterNotNull()
|
||||
.onEach { latestAlpha -> _state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
meshLogRepository
|
||||
.getMyNodeInfo()
|
||||
.map { it?.firmwareEdition }
|
||||
.distinctUntilChanged()
|
||||
.onEach { firmwareEdition -> _state.update { state -> state.copy(firmwareEdition = firmwareEdition) } }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
Timber.d("MetricsViewModel created")
|
||||
} else {
|
||||
Timber.d("MetricsViewModel: destNum is null, skipping metrics flows initialization.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
Timber.d("MetricsViewModel cleared")
|
||||
}
|
||||
|
||||
fun setTimeFrame(timeFrame: TimeFrame) {
|
||||
_timeFrame.value = timeFrame
|
||||
}
|
||||
|
||||
/** Write the persisted Position data out to a CSV file in the specified location. */
|
||||
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
|
||||
val positions = state.value.positionLogs
|
||||
writeToUri(uri) { writer ->
|
||||
writer.appendLine(
|
||||
"\"date\",\"time\",\"latitude\",\"longitude\",\"altitude\",\"satsInView\",\"speed\",\"heading\"",
|
||||
)
|
||||
|
||||
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
|
||||
|
||||
positions.forEach { position ->
|
||||
val rxDateTime = dateFormat.format(position.time * 1000L)
|
||||
val latitude = position.latitudeI * 1e-7
|
||||
val longitude = position.longitudeI * 1e-7
|
||||
val altitude = position.altitude
|
||||
val satsInView = position.satsInView
|
||||
val speed = position.groundSpeed
|
||||
val heading = "%.2f".format(position.groundTrack * 1e-5)
|
||||
|
||||
// date,time,latitude,longitude,altitude,satsInView,speed,heading
|
||||
writer.appendLine(
|
||||
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend inline fun writeToUri(uri: Uri, crossinline block: suspend (BufferedWriter) -> Unit) =
|
||||
withContext(dispatchers.io) {
|
||||
try {
|
||||
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
|
||||
FileWriter(parcelFileDescriptor.fileDescriptor).use { fileWriter ->
|
||||
BufferedWriter(fileWriter).use { writer -> block.invoke(writer) }
|
||||
}
|
||||
}
|
||||
} catch (ex: FileNotFoundException) {
|
||||
Timber.e(ex, "Can't write file error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -39,17 +39,6 @@ import androidx.navigation.NavHostController
|
|||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.navigation
|
||||
import androidx.navigation.navDeepLink
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.DeviceMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.EnvironmentMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.HostMetricsLogScreen
|
||||
import com.geeksville.mesh.ui.metrics.PaxMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.PositionLogScreen
|
||||
import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
|
||||
import com.geeksville.mesh.ui.node.NodeDetailScreen
|
||||
import com.geeksville.mesh.ui.node.NodeListScreen
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
|
|
@ -58,6 +47,17 @@ import org.meshtastic.core.navigation.Route
|
|||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.feature.map.node.NodeMapScreen
|
||||
import org.meshtastic.feature.map.node.NodeMapViewModel
|
||||
import org.meshtastic.feature.node.detail.NodeDetailScreen
|
||||
import org.meshtastic.feature.node.list.NodeListScreen
|
||||
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
|
||||
import org.meshtastic.feature.node.metrics.MetricsViewModel
|
||||
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.PositionLogScreen
|
||||
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
|
||||
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
|
||||
|
||||
fun NavGraphBuilder.nodesGraph(navController: NavHostController) {
|
||||
navigation<NodesRoutes.NodesGraph>(startDestination = NodesRoutes.Nodes) {
|
||||
|
|
|
|||
|
|
@ -94,7 +94,6 @@ import com.geeksville.mesh.repository.radio.MeshActivity
|
|||
import com.geeksville.mesh.service.MeshService
|
||||
import com.geeksville.mesh.ui.connections.DeviceType
|
||||
import com.geeksville.mesh.ui.connections.components.ConnectionsNavIcon
|
||||
import com.geeksville.mesh.ui.metrics.annotateTraceroute
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
|
|
@ -120,6 +119,7 @@ import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
|||
import org.meshtastic.core.ui.share.SharedContactDialog
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusBlue
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.feature.node.metrics.annotateTraceroute
|
||||
import org.meshtastic.feature.settings.navigation.settingsGraph
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import timber.log.Timber
|
||||
|
|
|
|||
|
|
@ -1,330 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
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.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.model.Environment
|
||||
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 org.meshtastic.core.strings.R
|
||||
import java.text.DateFormat
|
||||
|
||||
object CommonCharts {
|
||||
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
|
||||
const val MAX_PERCENT_VALUE = 100f
|
||||
}
|
||||
|
||||
private const val LINE_ON = 10f
|
||||
private const val LINE_OFF = 20f
|
||||
private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
|
||||
private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
private const val DATE_Y = 32f
|
||||
private const val LINE_LIMIT = 4
|
||||
private const val TEXT_PAINT_ALPHA = 192
|
||||
|
||||
data class LegendData(
|
||||
val nameRes: Int,
|
||||
val color: Color,
|
||||
val isLine: Boolean = false,
|
||||
val environmentMetric: Environment? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ChartHeader(amount: Int) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "$amount ${stringResource(R.string.logs)}",
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws chart lines with respect to the Y-axis.
|
||||
*
|
||||
* @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>) {
|
||||
/* 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
|
||||
/* Horizontal Lines */
|
||||
var lineY = 0f
|
||||
for (i in 0..LINE_LIMIT) {
|
||||
val ratio = lineY / MAX_PERCENT_VALUE
|
||||
val y = height - (ratio * height)
|
||||
drawLine(
|
||||
start = Offset(lineStart, y),
|
||||
end = Offset(width, y),
|
||||
color = lineColors[i],
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
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`). */
|
||||
@Composable
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
label += verticalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
val range = newest - oldest
|
||||
val density = LocalDensity.current
|
||||
val lineColor = MaterialTheme.colorScheme.onSurface
|
||||
Canvas(modifier = modifier) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
/* Cut out the time remaining in order to place the lines on the dot. */
|
||||
val timeRemaining = oldest % timeInterval
|
||||
var current = oldest.toLong()
|
||||
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
|
||||
}
|
||||
|
||||
/* Vertical Lines with labels */
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
while (current <= newest) {
|
||||
val ratio = (current - oldest).toFloat() / range
|
||||
val x = (ratio * width)
|
||||
drawLine(
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
color = lineColor,
|
||||
strokeWidth = 1.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
pathEffect = PathEffect.dashPathEffect(floatArrayOf(LINE_ON, LINE_OFF), 0f),
|
||||
)
|
||||
|
||||
/* Time */
|
||||
drawText(TIME_FORMAT.format(current * MS_PER_SEC), x, 0f, textPaint)
|
||||
/* Date */
|
||||
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. */
|
||||
@Composable
|
||||
fun TimeLabels(oldest: Int, newest: Int) {
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_MINUTE_FORMAT.format(oldest * MS_PER_SEC),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = DATE_TIME_MINUTE_FORMAT.format(newest * MS_PER_SEC),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the legend that identifies the colors used for the graph.
|
||||
*
|
||||
* @param legendData A list containing the `LegendData` to build the labels.
|
||||
* @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) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
legendData.forEachIndexed { index, data ->
|
||||
LegendLabel(text = stringResource(data.nameRes), color = data.color, isLine = data.isLine)
|
||||
|
||||
if (index != legendData.lastIndex) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
if (displayInfoIcon) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
modifier = Modifier.clickable { promptInfoDialog() },
|
||||
contentDescription = stringResource(R.string.info),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dialog with information about the legend items.
|
||||
*
|
||||
* @param pairedRes A list of `Pair`s containing (term, definition).
|
||||
* @param onDismiss Executes when the user presses the close button.
|
||||
*/
|
||||
@Composable
|
||||
fun LegendInfoDialog(pairedRes: List<Pair<Int, Int>>, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
title = {
|
||||
Text(text = stringResource(R.string.info), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
for (pair in pairedRes) {
|
||||
Text(
|
||||
text = stringResource(pair.first),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
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)) } },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LegendLabel(text: String, color: Color, isLine: Boolean = false) {
|
||||
Canvas(modifier = Modifier.size(4.dp)) {
|
||||
if (isLine) {
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x = 0f, y = size.height / 2f),
|
||||
end = Offset(x = 16f, y = size.height / 2f),
|
||||
strokeWidth = 2.dp.toPx(),
|
||||
cap = StrokeCap.Round,
|
||||
)
|
||||
} else {
|
||||
drawCircle(color = color)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.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 = {})
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.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.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_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.util.GraphUtil
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import com.geeksville.mesh.util.GraphUtil.plotPoint
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Cyan
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Green
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Magenta
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
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 enum class Device(val color: Color) {
|
||||
BATTERY(Green) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
|
||||
},
|
||||
CH_UTIL(Magenta) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization
|
||||
},
|
||||
AIR_UTIL(Cyan) {
|
||||
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.airUtilTx
|
||||
}, ;
|
||||
|
||||
abstract fun getValue(telemetry: Telemetry): Float
|
||||
}
|
||||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
|
||||
LegendData(
|
||||
nameRes = R.string.channel_utilization,
|
||||
color = Device.CH_UTIL.color,
|
||||
isLine = false,
|
||||
environmentMetric = null,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.air_utilization,
|
||||
color = Device.AIR_UTIL.color,
|
||||
isLine = false,
|
||||
environmentMetric = null,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.deviceMetricsFiltered(selectedTimeFrame)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes =
|
||||
listOf(
|
||||
Pair(R.string.channel_utilization, R.string.ch_util_definition),
|
||||
Pair(R.string.air_utilization, R.string.air_util_definition),
|
||||
),
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
/* Device Metric Cards */
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> DeviceMetricsCard(telemetry) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun DeviceMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) return
|
||||
|
||||
val (oldest, newest) =
|
||||
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
|
||||
val timeDiff = newest.time - oldest.time
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.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() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate visible width based on actual weight distribution
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row {
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
|
||||
) {
|
||||
/*
|
||||
* The order of the colors are with respect to the ChUtil.
|
||||
* 25 - 49 Orange
|
||||
* 50 - 100 Red
|
||||
*/
|
||||
HorizontalLinesOverlay(
|
||||
modifier.width(dp),
|
||||
lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor),
|
||||
)
|
||||
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
||||
/* Plot Battery Line, ChUtil, and AirUtilTx */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
for (i in telemetries.indices) {
|
||||
val telemetry = telemetries[i]
|
||||
|
||||
/* x-value time */
|
||||
val xRatio = (telemetry.time - oldest.time).toFloat() / timeDiff
|
||||
val x = xRatio * width
|
||||
|
||||
/* Channel Utilization */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Device.CH_UTIL.color,
|
||||
x = x,
|
||||
value = telemetry.deviceMetrics.channelUtilization,
|
||||
divisor = MAX_PERCENT_VALUE,
|
||||
)
|
||||
|
||||
/* Air Utilization Transmit */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Device.AIR_UTIL.color,
|
||||
x = x,
|
||||
value = telemetry.deviceMetrics.airUtilTx,
|
||||
divisor = MAX_PERCENT_VALUE,
|
||||
)
|
||||
}
|
||||
|
||||
/* Battery Line */
|
||||
var index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = telemetry.deviceMetrics.batteryLevel / MAX_PERCENT_VALUE
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Device.BATTERY.color,
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
YAxisLabels(modifier = modifier.weight(weight = Y_AXIS_WEIGHT), graphColor, minValue = 0f, maxValue = 100f)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun DeviceMetricsChartPreview() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val telemetries =
|
||||
List(20) { i ->
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(80 - i)
|
||||
.setVoltage(3.7f - i * 0.02f)
|
||||
.setChannelUtilization(10f + i * 2)
|
||||
.setAirUtilTx(5f + i)
|
||||
.setUptimeSeconds(3600 + i * 300),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
AppTheme {
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier.height(400.dp),
|
||||
telemetries = telemetries,
|
||||
selectedTime = TimeFrame.TWENTY_FOUR_HOURS,
|
||||
promptInfoDialog = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceMetricsCard(telemetry: Telemetry) {
|
||||
val deviceMetrics = telemetry.deviceMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
/* Time, Battery, and Voltage */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
|
||||
MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
/* Channel Utilization and Air Utilization Tx */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val text =
|
||||
stringResource(R.string.channel_air_util)
|
||||
.format(deviceMetrics.channelUtilization, deviceMetrics.airUtilTx)
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun DeviceMetricsCardPreview() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val telemetry =
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now)
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(75)
|
||||
.setVoltage(3.65f)
|
||||
.setChannelUtilization(22.5f)
|
||||
.setAirUtilTx(12.0f)
|
||||
.setUptimeSeconds(7200),
|
||||
)
|
||||
.build()
|
||||
AppTheme { DeviceMetricsCard(telemetry = telemetry) }
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber") // fake data
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun DeviceMetricsScreenPreview() {
|
||||
val now = (System.currentTimeMillis() / 1000).toInt()
|
||||
val telemetries =
|
||||
List(24) { i ->
|
||||
Telemetry.newBuilder()
|
||||
.setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first
|
||||
.setDeviceMetrics(
|
||||
TelemetryProtos.DeviceMetrics.newBuilder()
|
||||
.setBatteryLevel(85 - i * 2) // Battery decreases over time
|
||||
.setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly
|
||||
.setChannelUtilization(15f + i * 1.5f) // Channel utilization increases
|
||||
.setAirUtilTx(8f + i * 0.8f) // Air utilization increases
|
||||
.setUptimeSeconds(3600 + i * 3600), // Uptime increases by 1 hour each
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
AppTheme {
|
||||
Surface {
|
||||
Column {
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes =
|
||||
listOf(
|
||||
Pair(R.string.channel_utilization, R.string.ch_util_definition),
|
||||
Pair(R.string.air_utilization, R.string.air_util_definition),
|
||||
),
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
DeviceMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries.reversed(),
|
||||
TimeFrame.TWENTY_FOUR_HOURS,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
TimeFrame.TWENTY_FOUR_HOURS,
|
||||
onOptionSelected = { /* Preview only */ },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
/* Device Metric Cards */
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(telemetries) { telemetry -> DeviceMetricsCard(telemetry) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,350 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.Environment
|
||||
import com.geeksville.mesh.model.EnvironmentGraphingData
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
private const val CHART_WEIGHT = 1f
|
||||
private const val Y_AXIS_WEIGHT = 0.1f
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val LEGEND_DATA_1 =
|
||||
listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.temperature,
|
||||
color = Environment.TEMPERATURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.TEMPERATURE,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.humidity,
|
||||
color = Environment.HUMIDITY.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.HUMIDITY,
|
||||
),
|
||||
)
|
||||
private val LEGEND_DATA_2 =
|
||||
listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.iaq,
|
||||
color = Environment.IAQ.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.IAQ,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.baro_pressure,
|
||||
color = Environment.BAROMETRIC_PRESSURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.BAROMETRIC_PRESSURE,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.lux,
|
||||
color = Environment.LUX.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.LUX,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.uv_lux,
|
||||
color = Environment.UV_LUX.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.UV_LUX,
|
||||
),
|
||||
)
|
||||
|
||||
private val LEGEND_DATA_3 =
|
||||
listOf(
|
||||
LegendData(
|
||||
nameRes = R.string.soil_temperature,
|
||||
color = Environment.SOIL_TEMPERATURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.SOIL_TEMPERATURE,
|
||||
),
|
||||
LegendData(
|
||||
nameRes = R.string.soil_moisture,
|
||||
color = Environment.SOIL_MOISTURE.color,
|
||||
isLine = true,
|
||||
environmentMetric = Environment.SOIL_MOISTURE,
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun EnvironmentMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
promptInfoDialog: () -> Unit,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) = graphData.times
|
||||
val timeDiff = newest - oldest
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
|
||||
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
|
||||
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
|
||||
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
|
||||
val visibleWidthPx = screenWidth * chartWidthRatio
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
|
||||
BarometricPressureYAxisLabel(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
|
||||
shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal],
|
||||
minValue = graphData.leftMinMax.first,
|
||||
maxValue = graphData.leftMinMax.second,
|
||||
)
|
||||
ChartContent(
|
||||
modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(),
|
||||
scrollState = scrollState,
|
||||
dp = dp,
|
||||
oldest = oldest,
|
||||
newest = newest,
|
||||
selectedTime = selectedTime,
|
||||
telemetries = telemetries,
|
||||
graphData = graphData,
|
||||
rightMin = graphData.rightMinMax.first,
|
||||
rightMax = graphData.rightMinMax.second,
|
||||
timeDiff = timeDiff,
|
||||
)
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
|
||||
MaterialTheme.colorScheme.onSurface,
|
||||
minValue = graphData.rightMinMax.first,
|
||||
maxValue = graphData.rightMinMax.second,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
MetricLegends(graphData = graphData, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Suppress("detekt:LongMethod")
|
||||
@Composable
|
||||
private fun MetricPlottingCanvas(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
selectedTime: TimeFrame,
|
||||
oldest: Int,
|
||||
timeDiff: Int,
|
||||
rightMin: Float,
|
||||
rightMax: Float,
|
||||
) {
|
||||
val (pressureMin, pressureMax) = graphData.leftMinMax
|
||||
val shouldPlot = graphData.shouldPlot
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Canvas(modifier = modifier) {
|
||||
val height = size.height
|
||||
val width = size.width
|
||||
|
||||
var min: Float
|
||||
var diff: Float
|
||||
var index: Int
|
||||
var first: Int
|
||||
for (metric in Environment.entries) {
|
||||
if (!shouldPlot[metric.ordinal]) {
|
||||
continue
|
||||
}
|
||||
if (metric == Environment.BAROMETRIC_PRESSURE) {
|
||||
diff = pressureMax - pressureMin
|
||||
min = pressureMin
|
||||
} else { // Reset for other metrics to use rightMin/rightMax
|
||||
min = rightMin
|
||||
diff = rightMax - rightMin
|
||||
}
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
first = index
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val rawValue = metric.getValue(telemetry) // This is Float?
|
||||
|
||||
// Default to 0f if the actual value is null or NaN. This is a reasonable default for
|
||||
// lux.
|
||||
val pointValue =
|
||||
if (rawValue != null && !rawValue.isNaN()) {
|
||||
rawValue
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
// Use 'min' and 'diff' from the outer scope, which are specific to the current metric's
|
||||
// scale group.
|
||||
val currentMin = min
|
||||
// Avoid division by zero if all values in the current y-axis range are the same.
|
||||
val currentDiff = if (diff == 0f) 1f else diff
|
||||
|
||||
val ratio = (pointValue - currentMin) / currentDiff
|
||||
var y = height - (ratio * height)
|
||||
|
||||
// Final check to ensure y is a valid, plottable coordinate.
|
||||
if (y.isNaN() || y.isInfinite()) {
|
||||
y = height // Default to the bottom of the chart if calculation still results in an
|
||||
// invalid number.
|
||||
} else {
|
||||
y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe.
|
||||
}
|
||||
return@createPath y
|
||||
}
|
||||
drawPathWithGradient(
|
||||
path = path,
|
||||
color = metric.color,
|
||||
height = height,
|
||||
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
|
||||
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BarometricPressureYAxisLabel(
|
||||
modifier: Modifier,
|
||||
shouldPlotBarometricPressure: Boolean,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
) {
|
||||
if (shouldPlotBarometricPressure) {
|
||||
YAxisLabels(
|
||||
modifier = modifier,
|
||||
Environment.BAROMETRIC_PRESSURE.color,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChartContent(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState,
|
||||
dp: Dp,
|
||||
oldest: Int,
|
||||
newest: Int,
|
||||
selectedTime: TimeFrame,
|
||||
telemetries: List<Telemetry>,
|
||||
graphData: EnvironmentGraphingData,
|
||||
rightMin: Float,
|
||||
rightMax: Float,
|
||||
timeDiff: Int,
|
||||
) {
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval())
|
||||
|
||||
MetricPlottingCanvas(
|
||||
modifier = modifier.width(dp),
|
||||
telemetries = telemetries,
|
||||
graphData = graphData,
|
||||
selectedTime = selectedTime,
|
||||
oldest = oldest,
|
||||
timeDiff = timeDiff,
|
||||
rightMin = rightMin,
|
||||
rightMax = rightMax,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
|
||||
Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
|
||||
Legend(LEGEND_DATA_3.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
|
||||
Legend(
|
||||
LEGEND_DATA_2.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] },
|
||||
promptInfoDialog = promptInfoDialog,
|
||||
)
|
||||
}
|
||||
|
||||
// private const val LINE_ON = 10f
|
||||
// private const val LINE_OFF = 20f
|
||||
// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
|
||||
// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
|
||||
// private const val DATE_Y = 32f
|
||||
// private const val LINE_LIMIT = 4
|
||||
// private const val TEXT_PAINT_ALPHA = 192
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.IaqDisplayMode
|
||||
import org.meshtastic.core.ui.component.IndoorAirQuality
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
import org.meshtastic.proto.copy
|
||||
|
||||
@Composable
|
||||
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val graphData = environmentState.environmentMetricsFiltered(selectedTimeFrame, state.isFahrenheit)
|
||||
val data = graphData.metrics
|
||||
|
||||
val processedTelemetries: List<Telemetry> =
|
||||
if (state.isFahrenheit) {
|
||||
data.map { telemetry ->
|
||||
val temperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.temperature)
|
||||
val soilTemperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.soilTemperature)
|
||||
telemetry.copy {
|
||||
environmentMetrics =
|
||||
telemetry.environmentMetrics.copy {
|
||||
temperature = temperatureFahrenheit
|
||||
soilTemperature = soilTemperatureFahrenheit
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
data
|
||||
}
|
||||
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
pairedRes = listOf(Pair(R.string.iaq, R.string.iaq_definition)),
|
||||
onDismiss = { displayInfoDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
EnvironmentMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = processedTelemetries.reversed(),
|
||||
graphData = graphData,
|
||||
selectedTime = selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(processedTelemetries) { telemetry -> EnvironmentMetricsCard(telemetry, state.isFahrenheit) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
|
||||
envMetrics.temperature?.let { temperature ->
|
||||
if (!temperature.isNaN()) {
|
||||
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
Text(
|
||||
text = textFormat.format(stringResource(id = R.string.temperature), temperature),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
val hasHumidity = envMetrics.relativeHumidity?.let { !it.isNaN() } == true
|
||||
val hasPressure = envMetrics.barometricPressure?.let { !it.isNaN() && it > 0 } == true
|
||||
|
||||
if (hasHumidity || hasPressure) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 0.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
if (hasHumidity) {
|
||||
val humidity = envMetrics.relativeHumidity!!
|
||||
Text(
|
||||
text = "%s %.2f%%".format(stringResource(id = R.string.humidity), humidity),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
modifier = Modifier.padding(vertical = 0.dp),
|
||||
)
|
||||
}
|
||||
if (hasPressure) {
|
||||
val pressure = envMetrics.barometricPressure!!
|
||||
Text(
|
||||
text = "%.2f hPa".format(pressure),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
modifier = Modifier.padding(vertical = 0.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
|
||||
if (
|
||||
envMetrics.soilTemperature != null ||
|
||||
(envMetrics.soilMoisture != null && envMetrics.soilMoisture != Int.MIN_VALUE)
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
val soilTemperatureTextFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
|
||||
val soilMoistureTextFormat = "%s %d%%"
|
||||
envMetrics.soilMoisture?.let { soilMoistureValue ->
|
||||
if (soilMoistureValue != Int.MIN_VALUE) {
|
||||
Text(
|
||||
text = soilMoistureTextFormat.format(stringResource(R.string.soil_moisture), soilMoistureValue),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
envMetrics.soilTemperature?.let { soilTemperature ->
|
||||
if (!soilTemperature.isNaN()) {
|
||||
Text(
|
||||
text =
|
||||
soilTemperatureTextFormat.format(
|
||||
stringResource(R.string.soil_temperature),
|
||||
soilTemperature,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
val hasLux = envMetrics.lux != null && !envMetrics.lux.isNaN()
|
||||
val hasUvLux = envMetrics.uvLux != null && !envMetrics.uvLux.isNaN()
|
||||
|
||||
if (hasLux || hasUvLux) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
if (hasLux) {
|
||||
val luxValue = envMetrics.lux!!
|
||||
Text(
|
||||
text = "%s %.0f lx".format(stringResource(R.string.lux), luxValue),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
if (hasUvLux) {
|
||||
val uvLuxValue = envMetrics.uvLux!!
|
||||
Text(
|
||||
text = "%s %.0f UVlx".format(stringResource(R.string.uv_lux), uvLuxValue),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage.isNaN()
|
||||
val hasCurrent = envMetrics.current != null && !envMetrics.current.isNaN()
|
||||
|
||||
if (hasVoltage || hasCurrent) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
if (hasVoltage) {
|
||||
val voltage = envMetrics.voltage!!
|
||||
Text(
|
||||
text = "%s %.2f V".format(stringResource(R.string.voltage), voltage),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
if (hasCurrent) {
|
||||
val current = envMetrics.current!!
|
||||
Text(
|
||||
text = "%s %.2f mA".format(stringResource(R.string.current), current),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
val iaqValue = envMetrics.iaq
|
||||
val gasResistance = envMetrics.gasResistance
|
||||
|
||||
if ((iaqValue != null && iaqValue != Int.MIN_VALUE) || (gasResistance?.isFinite() == true)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
if (iaqValue != null && iaqValue != Int.MIN_VALUE) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.iaq),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
IndoorAirQuality(iaq = iaqValue, displayMode = IaqDisplayMode.Dot)
|
||||
}
|
||||
}
|
||||
if (gasResistance != null && !gasResistance.isNaN()) {
|
||||
Text(
|
||||
text = "%s %.2f Ohm".format(stringResource(R.string.gas_resistance), gasResistance),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// These are in a differnt proto ...
|
||||
// envMetrics.co2?.let { co2 ->
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
// Text(
|
||||
// text = "%s %.0f ppm".format(stringResource(R.string.co2), co2),
|
||||
// color = MaterialTheme.colorScheme.onSurface,
|
||||
// fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// envMetrics.tvoc?.let { tvoc ->
|
||||
// Spacer(modifier = Modifier.height(4.dp))
|
||||
// Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
// Text(
|
||||
// text = "%s %.0f ppb".format(stringResource(R.string.tvoc), tvoc),
|
||||
// color = MaterialTheme.colorScheme.onSurface,
|
||||
// fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
|
||||
envMetrics.radiation?.let { radiation ->
|
||||
if (!radiation.isNaN() && radiation > 0f) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "%s %.2f µR/h".format(stringResource(R.string.radiation), radiation),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
|
||||
val envMetrics = telemetry.environmentMetrics
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp)) {
|
||||
/* Time and Temperature */
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
TemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
|
||||
}
|
||||
|
||||
HumidityAndBarometricPressureDisplay(envMetrics)
|
||||
|
||||
SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit)
|
||||
|
||||
GasCompositionDisplay(envMetrics)
|
||||
|
||||
LuxUVLuxDisplay(envMetrics)
|
||||
|
||||
VoltageCurrentDisplay(envMetrics)
|
||||
RadiationDisplay(envMetrics)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber") // preview data
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PreviewEnvironmentMetricsContent() {
|
||||
// Build a fake EnvironmentMetrics using the generated proto builder APIs
|
||||
val fakeEnvMetrics =
|
||||
TelemetryProtos.EnvironmentMetrics.newBuilder()
|
||||
.setTemperature(22.5f)
|
||||
.setRelativeHumidity(55.0f)
|
||||
.setBarometricPressure(1013.25f)
|
||||
.setSoilMoisture(33)
|
||||
.setSoilTemperature(18.0f)
|
||||
.setLux(100.0f)
|
||||
.setUvLux(100.0f)
|
||||
.setVoltage(3.7f)
|
||||
.setCurrent(0.12f)
|
||||
.setIaq(100)
|
||||
.setRadiation(0.15f)
|
||||
.setGasResistance(1200.0f)
|
||||
.build()
|
||||
val fakeTelemetry =
|
||||
TelemetryProtos.Telemetry.newBuilder()
|
||||
.setTime((System.currentTimeMillis() / 1000).toInt())
|
||||
.setEnvironmentMetrics(fakeEnvMetrics)
|
||||
.build()
|
||||
MaterialTheme {
|
||||
Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DataArray
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.TelemetryProtos
|
||||
import java.text.DecimalFormat
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by metricsViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val hostMetrics = state.hostMetrics
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(innerPadding),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(hostMetrics) { telemetry -> HostMetricsItem(telemetry = telemetry) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "MagicNumber")
|
||||
@Composable
|
||||
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) {
|
||||
val hostMetrics = telemetry.hostMetrics
|
||||
val time = telemetry.time * CommonCharts.MS_PER_SEC
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.padding(16.dp)) {
|
||||
Icon(imageVector = Icons.Default.DataArray, contentDescription = null, modifier = Modifier.width(24.dp))
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.End,
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
LogLine(
|
||||
label = stringResource(R.string.uptime),
|
||||
value = formatUptime(hostMetrics.uptimeSeconds),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LogLine(
|
||||
label = stringResource(R.string.free_memory),
|
||||
value = formatBytes(hostMetrics.freememBytes),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LogLine(
|
||||
label = stringResource(R.string.disk_free_indexed, 1),
|
||||
value = formatBytes(hostMetrics.diskfree1Bytes),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (hostMetrics.hasDiskfree2Bytes()) {
|
||||
LogLine(
|
||||
label = stringResource(R.string.disk_free_indexed, 2),
|
||||
value = formatBytes(hostMetrics.diskfree2Bytes),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
if (hostMetrics.hasDiskfree3Bytes()) {
|
||||
LogLine(
|
||||
label = stringResource(R.string.disk_free_indexed, 3),
|
||||
value = formatBytes(hostMetrics.diskfree3Bytes),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
LogLine(
|
||||
label = stringResource(R.string.load_indexed, 1),
|
||||
value = (hostMetrics.load1 / 100.0).toString(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { hostMetrics.load1 / 10000.0f },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
color = ProgressIndicatorDefaults.linearColor,
|
||||
trackColor = ProgressIndicatorDefaults.linearTrackColor,
|
||||
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
|
||||
)
|
||||
LogLine(
|
||||
label = stringResource(R.string.load_indexed, 5),
|
||||
value = (hostMetrics.load5 / 100.0).toString(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { hostMetrics.load5 / 10000.0f },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
color = ProgressIndicatorDefaults.linearColor,
|
||||
trackColor = ProgressIndicatorDefaults.linearTrackColor,
|
||||
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
|
||||
)
|
||||
LogLine(
|
||||
label = stringResource(R.string.load_indexed, 15),
|
||||
value = (hostMetrics.load15 / 100.0).toString(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
LinearProgressIndicator(
|
||||
progress = { hostMetrics.load15 / 10000.0f },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
color = ProgressIndicatorDefaults.linearColor,
|
||||
trackColor = ProgressIndicatorDefaults.linearTrackColor,
|
||||
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
|
||||
)
|
||||
if (hostMetrics.hasUserString()) {
|
||||
Text(text = stringResource(R.string.user_string), style = MaterialTheme.typography.bodyMedium)
|
||||
Text(text = hostMetrics.userString, style = TextStyle(fontFamily = FontFamily.Monospace))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogLine(modifier: Modifier = Modifier, label: String, value: String) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = label)
|
||||
Text(text = value)
|
||||
}
|
||||
}
|
||||
|
||||
const val BYTES_IN_KB = 1024.0
|
||||
const val BYTES_IN_MB = BYTES_IN_KB * 1024.0
|
||||
const val BYTES_IN_GB = BYTES_IN_MB * 1024.0
|
||||
|
||||
fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String {
|
||||
val formatter =
|
||||
DecimalFormat().apply {
|
||||
maximumFractionDigits = decimalPlaces
|
||||
minimumFractionDigits = 0
|
||||
isGroupingUsed = false
|
||||
}
|
||||
return when {
|
||||
bytes < 0 -> "N/A" // Handle negative bytes gracefully
|
||||
bytes == 0L -> "0 B"
|
||||
bytes >= BYTES_IN_GB -> "${formatter.format(bytes / BYTES_IN_GB)} GB"
|
||||
bytes >= BYTES_IN_MB -> "${formatter.format(bytes / BYTES_IN_MB)} MB"
|
||||
bytes >= BYTES_IN_KB -> "${formatter.format(bytes / BYTES_IN_KB)} KB"
|
||||
else -> "$bytes B"
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun HostMetricsItemPreview() {
|
||||
val hostMetrics =
|
||||
TelemetryProtos.HostMetrics.newBuilder()
|
||||
.setUptimeSeconds(3600)
|
||||
.setFreememBytes(2048000)
|
||||
.setDiskfree1Bytes(104857600)
|
||||
.setDiskfree2Bytes(2097915200)
|
||||
.setDiskfree3Bytes(44444)
|
||||
.setLoad1(30)
|
||||
.setLoad5(75)
|
||||
.setLoad15(19)
|
||||
.setUserString("test")
|
||||
.build()
|
||||
val logs =
|
||||
TelemetryProtos.Telemetry.newBuilder()
|
||||
.setTime((System.currentTimeMillis() / 1000L).toInt())
|
||||
.setHostMetrics(hostMetrics)
|
||||
.build()
|
||||
AppTheme { HostMetricsItem(telemetry = logs) }
|
||||
}
|
||||
|
|
@ -1,325 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.Scaffold
|
||||
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
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import org.meshtastic.core.database.entity.MeshLog
|
||||
import org.meshtastic.core.model.util.formatUptime
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.PaxcountProtos
|
||||
import org.meshtastic.proto.Portnums.PortNum
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
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 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),
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun PaxMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
totalSeries: List<Pair<Int, Int>>,
|
||||
bleSeries: List<Pair<Int, Int>>,
|
||||
wifiSeries: List<Pair<Int, Int>>,
|
||||
minValue: Float,
|
||||
maxValue: Float,
|
||||
timeFrame: TimeFrame,
|
||||
) {
|
||||
if (totalSeries.isEmpty()) return
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val times = totalSeries.map { it.first }
|
||||
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()) }
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
val visibleOldest = minTime + (timeDiff * leftRatio).toInt()
|
||||
val visibleNewest = minTime + (timeDiff * rightRatio).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(modifier = modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f)) {
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(start = 8.dp),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
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())
|
||||
Canvas(modifier = Modifier.width(dp).fillMaxHeight()) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
fun xForTime(t: Int): Float =
|
||||
if (maxTime == minTime) width / 2 else (t - minTime).toFloat() / (maxTime - minTime) * width
|
||||
fun yForValue(v: Int): Float = height - (v - minValue) / (maxValue - minValue) * height
|
||||
fun drawLine(series: List<Pair<Int, Int>>, color: Color) {
|
||||
for (i in 1 until series.size) {
|
||||
drawLine(
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
drawLine(bleSeries, PaxSeries.BLE.color)
|
||||
drawLine(wifiSeries, PaxSeries.WIFI.color)
|
||||
drawLine(totalSeries, PaxSeries.PAX.color)
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight().padding(end = 8.dp),
|
||||
labelColor = MaterialTheme.colorScheme.onSurface,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Suppress("MagicNumber", "LongMethod")
|
||||
fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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 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, environmentMetric = null),
|
||||
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
|
||||
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
// Time frame selector
|
||||
SlidingSelector(
|
||||
options = TimeFrame.entries.toList(),
|
||||
selectedOption = timeFrame,
|
||||
onOptionSelected = { timeFrame = it },
|
||||
) { tf: TimeFrame ->
|
||||
OptionLabel(stringResource(tf.strRes))
|
||||
}
|
||||
// Graph
|
||||
if (graphData.isNotEmpty()) {
|
||||
ChartHeader(graphData.size)
|
||||
Legend(legendData = legendData)
|
||||
PaxMetricsChart(
|
||||
totalSeries = totalSeries,
|
||||
bleSeries = bleSeries,
|
||||
wifiSeries = wifiSeries,
|
||||
minValue = minValue,
|
||||
maxValue = maxValue,
|
||||
timeFrame = timeFrame,
|
||||
)
|
||||
}
|
||||
// List
|
||||
if (paxMetrics.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_pax_metrics_logs),
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
items(paxMetrics) { (log, pax) -> PaxMetricsItem(log, pax, dateFormat) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber", "CyclomaticComplexMethod")
|
||||
fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? {
|
||||
var result: PaxcountProtos.Paxcount? = null
|
||||
// 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) {
|
||||
val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
|
||||
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax
|
||||
}
|
||||
} catch (e: com.google.protobuf.InvalidProtocolBufferException) {
|
||||
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
android.util.Log.e("PaxMetrics", "Invalid argument while parsing Paxcount from binary data", e)
|
||||
}
|
||||
// Fallback: Try direct base64 or bytes from raw_message
|
||||
if (result == null) {
|
||||
try {
|
||||
val base64 = log.raw_message.trim()
|
||||
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
|
||||
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||
val pax = PaxcountProtos.Paxcount.parseFrom(bytes)
|
||||
result = pax
|
||||
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
|
||||
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
val pax = PaxcountProtos.Paxcount.parseFrom(bytes)
|
||||
result = pax
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
android.util.Log.e("PaxMetrics", "Invalid Base64 or hex input", e)
|
||||
} catch (e: com.google.protobuf.InvalidProtocolBufferException) {
|
||||
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun unescapeProtoString(escaped: String): ByteArray {
|
||||
val out = mutableListOf<Byte>()
|
||||
var i = 0
|
||||
while (i < escaped.length) {
|
||||
if (escaped[i] == '\\' && i + 3 < escaped.length && escaped[i + 1].isDigit()) {
|
||||
// Octal escape: \\ddd
|
||||
val octal = escaped.substring(i + 1, i + 4)
|
||||
out.add(octal.toInt(8).toByte())
|
||||
i += 4
|
||||
} else {
|
||||
out.add(escaped[i].code.toByte())
|
||||
i++
|
||||
}
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaxMetricsItem(log: MeshLog, pax: PaxcountProtos.Paxcount, dateFormat: DateFormat) {
|
||||
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(),
|
||||
)
|
||||
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) {
|
||||
Text(
|
||||
text = summary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.proto.formatPositionTime
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import java.text.DateFormat
|
||||
|
||||
@Composable
|
||||
private fun RowScope.PositionText(text: String, weight: Float) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.weight(weight),
|
||||
textAlign = TextAlign.Center,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
|
||||
private const val WEIGHT_10 = .10f
|
||||
private const val WEIGHT_15 = .15f
|
||||
private const val WEIGHT_20 = .20f
|
||||
private const val WEIGHT_40 = .40f
|
||||
|
||||
@Composable
|
||||
private fun HeaderItem(compactWidth: Boolean) {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
PositionText(stringResource(R.string.latitude), WEIGHT_20)
|
||||
PositionText(stringResource(R.string.longitude), WEIGHT_20)
|
||||
PositionText(stringResource(R.string.sats), WEIGHT_10)
|
||||
PositionText(stringResource(R.string.alt), WEIGHT_15)
|
||||
if (!compactWidth) {
|
||||
PositionText(stringResource(R.string.speed), WEIGHT_15)
|
||||
PositionText(stringResource(R.string.heading), WEIGHT_15)
|
||||
}
|
||||
PositionText(stringResource(R.string.timestamp), WEIGHT_40)
|
||||
}
|
||||
}
|
||||
|
||||
const val DEG_D = 1e-7
|
||||
const val HEADING_DEG = 1e-5
|
||||
|
||||
@Composable
|
||||
fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, dateFormat: DateFormat, system: DisplayUnits) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
PositionText("%.5f".format(position.latitudeI * DEG_D), WEIGHT_20)
|
||||
PositionText("%.5f".format(position.longitudeI * DEG_D), WEIGHT_20)
|
||||
PositionText(position.satsInView.toString(), WEIGHT_10)
|
||||
PositionText(position.altitude.metersIn(system).toString(system), WEIGHT_15)
|
||||
if (!compactWidth) {
|
||||
PositionText("${position.groundSpeed} Km/h", WEIGHT_15)
|
||||
PositionText("%.0f°".format(position.groundTrack * HEADING_DEG), WEIGHT_15)
|
||||
}
|
||||
PositionText(position.formatPositionTime(dateFormat), WEIGHT_40)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionButtons(
|
||||
clearButtonEnabled: Boolean,
|
||||
onClear: () -> Unit,
|
||||
saveButtonEnabled: Boolean,
|
||||
onSave: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
onClick = onClear,
|
||||
enabled = clearButtonEnabled,
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.clear))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text = stringResource(id = R.string.clear))
|
||||
}
|
||||
|
||||
OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) {
|
||||
Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(id = R.string.save))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(text = stringResource(id = R.string.save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val exportPositionLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) }
|
||||
}
|
||||
}
|
||||
|
||||
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
BoxWithConstraints(modifier = Modifier.padding(innerPadding)) {
|
||||
val compactWidth = maxWidth < 600.dp
|
||||
Column {
|
||||
val textStyle =
|
||||
if (compactWidth) {
|
||||
MaterialTheme.typography.bodySmall
|
||||
} else {
|
||||
LocalTextStyle.current
|
||||
}
|
||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
||||
HeaderItem(compactWidth)
|
||||
PositionList(compactWidth, state.positionLogs, state.displayUnits)
|
||||
}
|
||||
|
||||
ActionButtons(
|
||||
clearButtonEnabled = clearButtonEnabled,
|
||||
onClear = {
|
||||
clearButtonEnabled = false
|
||||
viewModel.clearPosition()
|
||||
},
|
||||
saveButtonEnabled = state.hasPositionLogs(),
|
||||
onSave = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/*"
|
||||
putExtra(Intent.EXTRA_TITLE, "position.csv")
|
||||
}
|
||||
exportPositionLauncher.launch(intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.PositionList(
|
||||
compactWidth: Boolean,
|
||||
positions: List<MeshProtos.Position>,
|
||||
displayUnits: DisplayUnits,
|
||||
) {
|
||||
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
items(positions) { position -> PositionItem(compactWidth, position, dateFormat, displayUnits) }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val testPosition =
|
||||
MeshProtos.Position.newBuilder()
|
||||
.apply {
|
||||
latitudeI = 297604270
|
||||
longitudeI = -953698040
|
||||
altitude = 1230
|
||||
satsInView = 7
|
||||
time = (System.currentTimeMillis() / 1000).toInt()
|
||||
}
|
||||
.build()
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun PositionItemPreview() {
|
||||
AppTheme {
|
||||
PositionItem(
|
||||
compactWidth = false,
|
||||
position = testPosition,
|
||||
dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM),
|
||||
system = DisplayUnits.METRIC,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewScreenSizes
|
||||
@Composable
|
||||
private fun ActionButtonsPreview() {
|
||||
AppTheme {
|
||||
Column(Modifier.fillMaxSize(), Arrangement.Bottom) {
|
||||
ActionButtons(clearButtonEnabled = true, onClear = {}, saveButtonEnabled = true, onSave = {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,386 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
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.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.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.util.GraphUtil
|
||||
import com.geeksville.mesh.util.GraphUtil.createPath
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
|
||||
import org.meshtastic.core.ui.theme.GraphColors.Red
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private enum class Power(val color: Color, val min: Float, val max: Float) {
|
||||
CURRENT(InfantryBlue, -500f, 500f),
|
||||
;
|
||||
|
||||
/** Difference between the metrics `max` and `min` values. */
|
||||
fun difference() = max - min
|
||||
}
|
||||
|
||||
private enum class PowerChannel(@StringRes val strRes: Int) {
|
||||
ONE(R.string.channel_1),
|
||||
TWO(R.string.channel_2),
|
||||
THREE(R.string.channel_3),
|
||||
}
|
||||
|
||||
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 const val VOLTAGE_STICK_TO_ZERO_RANGE = 2f
|
||||
|
||||
private val VOLTAGE_COLOR = Red
|
||||
|
||||
fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair<Float, Float> {
|
||||
val valueMin = floor(valueMin)
|
||||
val min =
|
||||
if (valueMin == 0f || (valueMin >= 0f && valueMin - VOLTAGE_STICK_TO_ZERO_RANGE <= 0f)) {
|
||||
0f
|
||||
} else {
|
||||
valueMin - VOLTAGE_STICK_TO_ZERO_RANGE
|
||||
}
|
||||
val max = ceil(valueMax)
|
||||
|
||||
return Pair(min, max)
|
||||
}
|
||||
|
||||
private val LEGEND_DATA =
|
||||
listOf(
|
||||
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null),
|
||||
LegendData(nameRes = R.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) }
|
||||
val data = state.powerMetricsFiltered(selectedTimeFrame)
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
PowerMetricsChart(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
|
||||
telemetries = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
selectedChannel,
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
PowerChannel.entries.toList(),
|
||||
selectedChannel,
|
||||
onOptionSelected = { selectedChannel = it },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) { items(data) { telemetry -> PowerMetricsCard(telemetry) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun PowerMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
telemetries: List<Telemetry>,
|
||||
selectedTime: TimeFrame,
|
||||
selectedChannel: PowerChannel,
|
||||
) {
|
||||
ChartHeader(amount = telemetries.size)
|
||||
if (telemetries.isEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val (oldest, newest) =
|
||||
remember(key1 = telemetries) { Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time }) }
|
||||
val timeDiff = newest.time - oldest.time
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidth = LocalWindowInfo.current.containerSize.width
|
||||
val dp by
|
||||
remember(key1 = selectedTime) {
|
||||
mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong()))
|
||||
}
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate visible width based on actual weight distribution
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest.time + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest.time + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
val currentDiff = Power.CURRENT.difference()
|
||||
|
||||
val (voltageMin, voltageMax) =
|
||||
minMaxGraphVoltage(
|
||||
retrieveVoltage(selectedChannel, telemetries.minBy { retrieveVoltage(selectedChannel, it) }),
|
||||
retrieveVoltage(selectedChannel, telemetries.maxBy { retrieveVoltage(selectedChannel, it) }),
|
||||
)
|
||||
val voltageDiff = voltageMax - voltageMin
|
||||
|
||||
Row {
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Power.CURRENT.color,
|
||||
minValue = Power.CURRENT.min,
|
||||
maxValue = Power.CURRENT.max,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())
|
||||
|
||||
/* Plot */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
/* Voltage */
|
||||
var index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (retrieveVoltage(selectedChannel, telemetry) - voltageMin) / voltageDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = VOLTAGE_COLOR,
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
/* Current */
|
||||
index = 0
|
||||
while (index < telemetries.size) {
|
||||
val path = Path()
|
||||
index =
|
||||
createPath(
|
||||
telemetries = telemetries,
|
||||
index = index,
|
||||
path = path,
|
||||
oldestTime = oldest.time,
|
||||
timeRange = timeDiff,
|
||||
width = width,
|
||||
timeThreshold = selectedTime.timeThreshold(),
|
||||
) { i ->
|
||||
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
|
||||
val ratio = (retrieveCurrent(selectedChannel, telemetry) - Power.CURRENT.min) / currentDiff
|
||||
val y = height - (ratio * height)
|
||||
return@createPath y
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Power.CURRENT.color,
|
||||
style = Stroke(width = GraphUtil.RADIUS, cap = StrokeCap.Round),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
VOLTAGE_COLOR,
|
||||
minValue = voltageMin,
|
||||
maxValue = voltageMax,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Legend(legendData = LEGEND_DATA, displayInfoIcon = false)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerMetricsCard(telemetry: Telemetry) {
|
||||
val time = telemetry.time * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
/* Time */
|
||||
Row {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||
if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) {
|
||||
PowerChannelColumn(
|
||||
R.string.channel_1,
|
||||
telemetry.powerMetrics.ch1Voltage,
|
||||
telemetry.powerMetrics.ch1Current,
|
||||
)
|
||||
}
|
||||
if (telemetry.powerMetrics.hasCh2Current() || telemetry.powerMetrics.hasCh2Voltage()) {
|
||||
PowerChannelColumn(
|
||||
R.string.channel_2,
|
||||
telemetry.powerMetrics.ch2Voltage,
|
||||
telemetry.powerMetrics.ch2Current,
|
||||
)
|
||||
}
|
||||
if (telemetry.powerMetrics.hasCh3Current() || telemetry.powerMetrics.hasCh3Voltage()) {
|
||||
PowerChannelColumn(
|
||||
R.string.channel_3,
|
||||
telemetry.powerMetrics.ch3Voltage,
|
||||
telemetry.powerMetrics.ch3Current,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerChannelColumn(@StringRes titleRes: Int, voltage: Float, current: Float) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(
|
||||
text = "%.2fV".format(voltage),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
Text(
|
||||
text = "%.1fmA".format(current),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Retrieves the appropriate voltage depending on `channelSelected`. */
|
||||
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
|
||||
PowerChannel.ONE -> telemetry.powerMetrics.ch1Voltage
|
||||
PowerChannel.TWO -> telemetry.powerMetrics.ch2Voltage
|
||||
PowerChannel.THREE -> telemetry.powerMetrics.ch3Voltage
|
||||
}
|
||||
|
||||
/** Retrieves the appropriate current depending on `channelSelected`. */
|
||||
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
|
||||
PowerChannel.ONE -> telemetry.powerMetrics.ch1Current
|
||||
PowerChannel.TWO -> telemetry.powerMetrics.ch2Current
|
||||
PowerChannel.THREE -> telemetry.powerMetrics.ch3Current
|
||||
}
|
||||
|
|
@ -1,290 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.IntrinsicSize
|
||||
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.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.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
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import com.geeksville.mesh.util.GraphUtil.plotPoint
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.LoraSignalIndicator
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.OptionLabel
|
||||
import org.meshtastic.core.ui.component.SlidingSelector
|
||||
import org.meshtastic.core.ui.component.SnrAndRssi
|
||||
import org.meshtastic.feature.node.model.TimeFrame
|
||||
import org.meshtastic.proto.MeshProtos.MeshPacket
|
||||
|
||||
@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. */
|
||||
fun difference() = max - min
|
||||
}
|
||||
|
||||
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, environmentMetric = null),
|
||||
LegendData(nameRes = R.string.snr, color = Metric.SNR.color, environmentMetric = null),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -> Unit) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var displayInfoDialog by remember { mutableStateOf(false) }
|
||||
val selectedTimeFrame by viewModel.timeFrame.collectAsState()
|
||||
val data = state.signalMetricsFiltered(selectedTimeFrame)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.padding(innerPadding)) {
|
||||
if (displayInfoDialog) {
|
||||
LegendInfoDialog(
|
||||
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),
|
||||
meshPackets = data.reversed(),
|
||||
selectedTimeFrame,
|
||||
promptInfoDialog = { displayInfoDialog = true },
|
||||
)
|
||||
|
||||
SlidingSelector(
|
||||
TimeFrame.entries.toList(),
|
||||
selectedTimeFrame,
|
||||
onOptionSelected = { viewModel.setTimeFrame(it) },
|
||||
) {
|
||||
OptionLabel(stringResource(it.strRes))
|
||||
}
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(data) { meshPacket -> SignalMetricsCard(meshPacket) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun SignalMetricsChart(
|
||||
modifier: Modifier = Modifier,
|
||||
meshPackets: List<MeshPacket>,
|
||||
selectedTime: TimeFrame,
|
||||
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 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()))
|
||||
}
|
||||
|
||||
// Calculate visible time range based on scroll position and chart width
|
||||
val visibleTimeRange = run {
|
||||
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
|
||||
val scrollPx = scrollState.value.toFloat()
|
||||
// Calculate visible width based on actual weight distribution
|
||||
val visibleWidthPx = screenWidth * CHART_WIDTH_RATIO
|
||||
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
|
||||
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
|
||||
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
|
||||
val visibleOldest = oldest.rxTime + (timeDiff * (1f - rightRatio)).toInt()
|
||||
val visibleNewest = oldest.rxTime + (timeDiff * (1f - leftRatio)).toInt()
|
||||
visibleOldest to visibleNewest
|
||||
}
|
||||
|
||||
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val graphColor = MaterialTheme.colorScheme.onSurface
|
||||
val snrDiff = Metric.SNR.difference()
|
||||
val rssiDiff = Metric.RSSI.difference()
|
||||
|
||||
Row {
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Metric.RSSI.color,
|
||||
minValue = Metric.RSSI.min,
|
||||
maxValue = Metric.RSSI.max,
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.TopStart,
|
||||
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(1f),
|
||||
) {
|
||||
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
|
||||
|
||||
TimeAxisOverlay(
|
||||
modifier.width(dp),
|
||||
oldest = oldest.rxTime,
|
||||
newest = newest.rxTime,
|
||||
selectedTime.lineInterval(),
|
||||
)
|
||||
|
||||
/* Plot SNR and RSSI */
|
||||
Canvas(modifier = modifier.width(dp)) {
|
||||
val width = size.width
|
||||
/* Plot */
|
||||
for (packet in meshPackets) {
|
||||
val xRatio = (packet.rxTime - oldest.rxTime).toFloat() / timeDiff
|
||||
val x = xRatio * width
|
||||
|
||||
/* SNR */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Metric.SNR.color,
|
||||
x = x,
|
||||
value = packet.rxSnr - Metric.SNR.min,
|
||||
divisor = snrDiff,
|
||||
)
|
||||
|
||||
/* RSSI */
|
||||
plotPoint(
|
||||
drawContext = drawContext,
|
||||
color = Metric.RSSI.color,
|
||||
x = x,
|
||||
value = packet.rxRssi - Metric.RSSI.min,
|
||||
divisor = rssiDiff,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
YAxisLabels(
|
||||
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
|
||||
Metric.SNR.color,
|
||||
minValue = Metric.SNR.min,
|
||||
maxValue = Metric.SNR.max,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SignalMetricsCard(meshPacket: MeshPacket) {
|
||||
val time = meshPacket.rxTime * MS_PER_SEC
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
Surface {
|
||||
SelectionContainer {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
/* Data */
|
||||
Box(modifier = Modifier.weight(weight = 5f).height(IntrinsicSize.Min)) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
/* Time */
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = DATE_TIME_FORMAT.format(time),
|
||||
style = TextStyle(fontWeight = FontWeight.Bold),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
/* SNR and RSSI */
|
||||
SnrAndRssi(meshPacket.rxSnr, meshPacket.rxRssi)
|
||||
}
|
||||
}
|
||||
|
||||
/* Signal Indicator */
|
||||
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
|
||||
LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.metrics
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.PersonOff
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.core.model.fullRouteDiscovery
|
||||
import org.meshtastic.core.model.getTracerouteResponse
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.SNR_FAIR_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SNR_GOOD_THRESHOLD
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import java.text.DateFormat
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun TracerouteLogScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
onNavigateUp: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
|
||||
|
||||
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
|
||||
|
||||
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
|
||||
|
||||
if (showDialog != null) {
|
||||
val message = showDialog ?: AnnotatedString("") // Should not be null if dialog is shown
|
||||
SimpleAlertDialog(
|
||||
title = R.string.traceroute,
|
||||
text = { SelectionContainer { Text(text = message) } },
|
||||
onDismiss = { showDialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = state.node?.user?.longName ?: "",
|
||||
ourNode = null,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize().padding(innerPadding),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
items(state.tracerouteRequests, key = { it.uuid }) { log ->
|
||||
val result =
|
||||
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
|
||||
state.tracerouteResults.find {
|
||||
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
|
||||
}
|
||||
}
|
||||
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
|
||||
|
||||
val time = dateFormat.format(log.received_date)
|
||||
val (text, icon) = route.getTextAndIcon()
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
val tracerouteDetailsAnnotated: AnnotatedString? =
|
||||
result?.let { res ->
|
||||
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
|
||||
val seconds =
|
||||
(res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
|
||||
val annotatedBase =
|
||||
annotateTraceroute(res.fromRadio.packet.getTracerouteResponse(::getUsername))
|
||||
buildAnnotatedString {
|
||||
append(annotatedBase)
|
||||
append("\n\nDuration: ${"%.1f".format(seconds)} s")
|
||||
}
|
||||
} else {
|
||||
// For cases where there's a result but no full route, display plain text
|
||||
res.fromRadio.packet.getTracerouteResponse(::getUsername)?.let { AnnotatedString(it) }
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
TracerouteItem(
|
||||
icon = icon,
|
||||
text = "$time - $text",
|
||||
modifier =
|
||||
Modifier.combinedClickable(onLongClick = { expanded = true }) {
|
||||
if (tracerouteDetailsAnnotated != null) {
|
||||
showDialog = tracerouteDetailsAnnotated
|
||||
} else if (result != null) {
|
||||
// Fallback for results that couldn't be fully annotated but have basic info
|
||||
val basicInfo = result.fromRadio.packet.getTracerouteResponse(::getUsername)
|
||||
if (basicInfo != null) {
|
||||
showDialog = AnnotatedString(basicInfo)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||
DeleteItem {
|
||||
viewModel.deleteLog(log.uuid)
|
||||
expanded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteItem(onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(id = R.string.delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(text = stringResource(id = R.string.delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TracerouteItem(icon: ImageVector, text: String, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(vertical = 2.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(imageVector = icon, contentDescription = stringResource(id = R.string.traceroute))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = text, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a display string and icon based on the route discovery information. */
|
||||
@Composable
|
||||
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
|
||||
this == null -> {
|
||||
stringResource(R.string.routing_error_no_response) to Icons.Default.PersonOff
|
||||
}
|
||||
// A direct route means the sender and receiver are the only two nodes in the route.
|
||||
routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust
|
||||
stringResource(R.string.traceroute_direct) to Icons.Default.Group
|
||||
}
|
||||
|
||||
routeCount == routeBackCount -> {
|
||||
val hops = routeCount - 2
|
||||
pluralStringResource(R.plurals.traceroute_hops, hops, hops) to Icons.Default.Groups
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Asymmetric route
|
||||
val towards = maxOf(0, routeCount - 2)
|
||||
val back = maxOf(0, routeBackCount - 2)
|
||||
stringResource(R.string.traceroute_diff, towards, back) to Icons.Default.Groups
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a raw traceroute string into an [AnnotatedString] with SNR values highlighted according to their quality.
|
||||
*
|
||||
* @param inString The raw string output from a traceroute response.
|
||||
* @return An [AnnotatedString] with SNR values styled, or an empty [AnnotatedString] if input is null.
|
||||
*/
|
||||
@Composable
|
||||
fun annotateTraceroute(inString: String?): AnnotatedString {
|
||||
if (inString == null) return buildAnnotatedString { append("") }
|
||||
return buildAnnotatedString {
|
||||
inString.lines().forEachIndexed { i, line ->
|
||||
if (i > 0) append("\n")
|
||||
// Example line: "⇊ -8.75 dB SNR"
|
||||
if (line.trimStart().startsWith("⇊")) {
|
||||
val snrRegex = Regex("""⇊ ([\d\.\?-]+) dB""")
|
||||
val snrMatch = snrRegex.find(line)
|
||||
val snrValue = snrMatch?.groupValues?.getOrNull(1)?.toFloatOrNull()
|
||||
|
||||
if (snrValue != null) {
|
||||
val snrColor =
|
||||
when {
|
||||
snrValue >= SNR_GOOD_THRESHOLD -> MaterialTheme.colorScheme.StatusGreen
|
||||
snrValue >= SNR_FAIR_THRESHOLD -> MaterialTheme.colorScheme.StatusYellow
|
||||
else -> MaterialTheme.colorScheme.StatusOrange
|
||||
}
|
||||
withStyle(style = SpanStyle(color = snrColor, fontWeight = FontWeight.Bold)) { append(line) }
|
||||
} else {
|
||||
// Append line as is if SNR value cannot be parsed
|
||||
append(line)
|
||||
}
|
||||
} else {
|
||||
// Append non-SNR lines as is
|
||||
append(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun TracerouteItemPreview() {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
|
||||
AppTheme {
|
||||
TracerouteItem(icon = Icons.Default.Group, text = "${dateFormat.format(System.currentTimeMillis())} - Direct")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.database.entity.FirmwareRelease
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.ui.component.SharedContactDialog
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.node.component.AdministrationSection
|
||||
import org.meshtastic.feature.node.component.DeviceActions
|
||||
import org.meshtastic.feature.node.component.DeviceDetailsSection
|
||||
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
|
||||
import org.meshtastic.feature.node.component.MetricsSection
|
||||
import org.meshtastic.feature.node.component.NodeDetailsSection
|
||||
import org.meshtastic.feature.node.component.NotesSection
|
||||
import org.meshtastic.feature.node.component.PositionSection
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.MetricsState
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
|
||||
@Composable
|
||||
fun NodeDetailContent(
|
||||
node: Node,
|
||||
ourNode: Node?,
|
||||
metricsState: MetricsState,
|
||||
lastTracerouteTime: Long?,
|
||||
availableLogs: Set<LogsType>,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
onSaveNotes: (nodeNum: Int, notes: String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showShareDialog by remember { mutableStateOf(false) }
|
||||
if (showShareDialog) {
|
||||
SharedContactDialog(node) { showShareDialog = false }
|
||||
}
|
||||
|
||||
NodeDetailList(
|
||||
node = node,
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
ourNode = ourNode,
|
||||
metricsState = metricsState,
|
||||
onAction = { action ->
|
||||
if (action is NodeDetailAction.ShareContact) {
|
||||
showShareDialog = true
|
||||
} else {
|
||||
onAction(action)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
availableLogs = availableLogs,
|
||||
onSaveNotes = onSaveNotes,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NodeDetailList(
|
||||
node: Node,
|
||||
lastTracerouteTime: Long?,
|
||||
ourNode: Node?,
|
||||
metricsState: MetricsState,
|
||||
onAction: (NodeDetailAction) -> Unit,
|
||||
availableLogs: Set<LogsType>,
|
||||
onSaveNotes: (Int, String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showFirmwareSheet by remember { mutableStateOf(false) }
|
||||
var selectedFirmware by remember { mutableStateOf<FirmwareRelease?>(null) }
|
||||
|
||||
if (showFirmwareSheet) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
ModalBottomSheet(onDismissRequest = { showFirmwareSheet = false }, sheetState = sheetState) {
|
||||
selectedFirmware?.let { FirmwareReleaseSheetContent(firmwareRelease = it) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
if (metricsState.deviceHardware != null) {
|
||||
DeviceDetailsSection(metricsState)
|
||||
}
|
||||
|
||||
NodeDetailsSection(node)
|
||||
|
||||
NotesSection(node = node, onSaveNotes = onSaveNotes)
|
||||
|
||||
DeviceActions(
|
||||
isLocal = metricsState.isLocal,
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
node = node,
|
||||
onAction = onAction,
|
||||
)
|
||||
|
||||
PositionSection(
|
||||
node = node,
|
||||
ourNode = ourNode,
|
||||
metricsState = metricsState,
|
||||
availableLogs = availableLogs,
|
||||
onAction = onAction,
|
||||
)
|
||||
|
||||
MetricsSection(node, metricsState, availableLogs, onAction)
|
||||
|
||||
if (!metricsState.isManaged) {
|
||||
AdministrationSection(
|
||||
node = node,
|
||||
metricsState = metricsState,
|
||||
onAction = onAction,
|
||||
onFirmwareSelect = { firmware ->
|
||||
selectedFirmware = firmware
|
||||
showFirmwareSheet = true
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun NodeDetailsPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
|
||||
AppTheme {
|
||||
NodeDetailList(
|
||||
node = node,
|
||||
ourNode = node,
|
||||
lastTracerouteTime = null,
|
||||
metricsState = MetricsState.Companion.Empty,
|
||||
availableLogs = emptySet(),
|
||||
onAction = {},
|
||||
onSaveNotes = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import org.meshtastic.feature.node.detail.NodeDetailViewModel
|
||||
import org.meshtastic.feature.node.model.LogsType
|
||||
import org.meshtastic.feature.node.model.NodeDetailAction
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun NodeDetailScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
nodeDetailViewModel: NodeDetailViewModel = hiltViewModel(),
|
||||
navigateToMessages: (String) -> Unit = {},
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
onNavigateUp: () -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
||||
val lastTracerouteTime by nodeDetailViewModel.lastTraceRouteTime.collectAsStateWithLifecycle()
|
||||
val ourNode by nodeDetailViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
val availableLogs by
|
||||
remember(state, environmentState) {
|
||||
derivedStateOf {
|
||||
buildSet {
|
||||
if (state.hasDeviceMetrics()) add(LogsType.DEVICE)
|
||||
if (state.hasPositionLogs()) {
|
||||
add(LogsType.NODE_MAP)
|
||||
add(LogsType.POSITIONS)
|
||||
}
|
||||
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
|
||||
if (state.hasSignalMetrics()) add(LogsType.SIGNAL)
|
||||
if (state.hasPowerMetrics()) add(LogsType.POWER)
|
||||
if (state.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
|
||||
if (state.hasHostMetrics()) add(LogsType.HOST)
|
||||
if (state.hasPaxMetrics()) add(LogsType.PAX)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val node = state.node
|
||||
|
||||
@Suppress("ModifierNotUsedAtRoot")
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = node?.user?.longName ?: "",
|
||||
ourNode = ourNode,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = true,
|
||||
onNavigateUp = onNavigateUp,
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
if (node != null) {
|
||||
@Suppress("ViewModelForwarding")
|
||||
NodeDetailContent(
|
||||
node = node,
|
||||
ourNode = ourNode,
|
||||
metricsState = state,
|
||||
lastTracerouteTime = lastTracerouteTime,
|
||||
availableLogs = availableLogs,
|
||||
onAction = { action ->
|
||||
handleNodeAction(
|
||||
action = action,
|
||||
ourNode = ourNode,
|
||||
node = node,
|
||||
navigateToMessages = navigateToMessages,
|
||||
onNavigateUp = onNavigateUp,
|
||||
onNavigate = onNavigate,
|
||||
viewModel = viewModel,
|
||||
handleNodeMenuAction = { nodeDetailViewModel.handleNodeMenuAction(it) },
|
||||
)
|
||||
},
|
||||
modifier = modifier.padding(paddingValues),
|
||||
onSaveNotes = { num, notes -> nodeDetailViewModel.setNodeNotes(num, notes) },
|
||||
)
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize().padding(paddingValues), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNodeAction(
|
||||
action: NodeDetailAction,
|
||||
ourNode: Node?,
|
||||
node: Node,
|
||||
navigateToMessages: (String) -> Unit,
|
||||
onNavigateUp: () -> Unit,
|
||||
onNavigate: (Route) -> Unit,
|
||||
viewModel: MetricsViewModel,
|
||||
handleNodeMenuAction: (NodeMenuAction) -> Unit,
|
||||
) {
|
||||
when (action) {
|
||||
is NodeDetailAction.Navigate -> onNavigate(action.route)
|
||||
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
|
||||
is NodeDetailAction.HandleNodeMenuAction -> {
|
||||
when (val menuAction = action.action) {
|
||||
is NodeMenuAction.DirectMessage -> {
|
||||
val hasPKC = ourNode?.hasPKC == true
|
||||
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
|
||||
navigateToMessages("$channel${node.user.id}")
|
||||
}
|
||||
|
||||
is NodeMenuAction.Remove -> {
|
||||
handleNodeMenuAction(menuAction)
|
||||
onNavigateUp()
|
||||
}
|
||||
|
||||
else -> handleNodeMenuAction(menuAction)
|
||||
}
|
||||
}
|
||||
|
||||
is NodeDetailAction.ShareContact -> {
|
||||
/* Handled in NodeDetailContent */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,281 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DoDisturbOn
|
||||
import androidx.compose.material.icons.outlined.DoDisturbOn
|
||||
import androidx.compose.material.icons.rounded.DeleteOutline
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material.icons.rounded.StarBorder
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.animateFloatingActionButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DeviceVersion
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.AddContactFAB
|
||||
import org.meshtastic.core.ui.component.MainAppBar
|
||||
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
|
||||
import org.meshtastic.core.ui.component.supportsQrCodeSharing
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.feature.node.component.NodeActionDialogs
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
import org.meshtastic.feature.node.list.NodeListViewModel
|
||||
import org.meshtastic.proto.AdminProtos
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
|
||||
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
|
||||
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
|
||||
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
|
||||
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val currentTimeMillis = rememberTimeTickWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
val isScrollInProgress by remember {
|
||||
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
|
||||
}
|
||||
Scaffold(
|
||||
topBar = {
|
||||
MainAppBar(
|
||||
title = stringResource(R.string.nodes),
|
||||
subtitle = stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount),
|
||||
ourNode = ourNode,
|
||||
showNodeChip = false,
|
||||
canNavigateUp = false,
|
||||
onNavigateUp = {},
|
||||
actions = {},
|
||||
onClickChip = {},
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
|
||||
val shareCapable = firmwareVersion.supportsQrCodeSharing()
|
||||
val sharedContact: AdminProtos.SharedContact? by
|
||||
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
|
||||
AddContactFAB(
|
||||
sharedContact = sharedContact,
|
||||
modifier =
|
||||
Modifier.animateFloatingActionButton(
|
||||
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
|
||||
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||
stickyHeader {
|
||||
val animatedAlpha by
|
||||
animateFloatAsState(targetValue = if (!isScrollInProgress) 1.0f else 0f, label = "alpha")
|
||||
NodeFilterTextField(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.graphicsLayer(alpha = animatedAlpha)
|
||||
.background(MaterialTheme.colorScheme.surfaceDim)
|
||||
.padding(8.dp),
|
||||
filterText = state.filter.filterText,
|
||||
onTextChange = viewModel::setNodeFilterText,
|
||||
currentSortOption = state.sort,
|
||||
onSortSelect = viewModel::setSortOption,
|
||||
includeUnknown = state.filter.includeUnknown,
|
||||
onToggleIncludeUnknown = viewModel::toggleIncludeUnknown,
|
||||
onlyOnline = state.filter.onlyOnline,
|
||||
onToggleOnlyOnline = viewModel::toggleOnlyOnline,
|
||||
onlyDirect = state.filter.onlyDirect,
|
||||
onToggleOnlyDirect = viewModel::toggleOnlyDirect,
|
||||
showIgnored = state.filter.showIgnored,
|
||||
onToggleShowIgnored = viewModel::toggleShowIgnored,
|
||||
ignoredNodeCount = ignoredNodeCount,
|
||||
)
|
||||
}
|
||||
|
||||
items(nodes, key = { it.num }) { node ->
|
||||
var displayFavoriteDialog by remember { mutableStateOf(false) }
|
||||
var displayIgnoreDialog by remember { mutableStateOf(false) }
|
||||
var displayRemoveDialog by remember { mutableStateOf(false) }
|
||||
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayFavoriteDialog,
|
||||
displayIgnoreDialog = displayIgnoreDialog,
|
||||
displayRemoveDialog = displayRemoveDialog,
|
||||
onDismissMenuRequest = {
|
||||
displayFavoriteDialog = false
|
||||
displayIgnoreDialog = false
|
||||
displayRemoveDialog = false
|
||||
},
|
||||
onConfirmFavorite = viewModel::favoriteNode,
|
||||
onConfirmIgnore = viewModel::ignoreNode,
|
||||
onConfirmRemove = { viewModel.removeNode(it.num) },
|
||||
)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
||||
val longClick =
|
||||
if (node.num != ourNode?.num) {
|
||||
{ expanded = true }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
NodeItem(
|
||||
modifier = Modifier.animateItem(),
|
||||
thisNode = ourNode,
|
||||
thatNode = node,
|
||||
distanceUnits = state.distanceUnits,
|
||||
tempInFahrenheit = state.tempInFahrenheit,
|
||||
onClick = { navigateToNodeDetails(node.num) },
|
||||
onLongClick = longClick,
|
||||
currentTimeMillis = currentTimeMillis,
|
||||
isConnected = connectionState.isConnected(),
|
||||
)
|
||||
val isThisNode = remember(node) { ourNode?.num == node.num }
|
||||
if (!isThisNode) {
|
||||
ContextMenu(
|
||||
expanded = expanded,
|
||||
node = node,
|
||||
onClickFavorite = { displayFavoriteDialog = true },
|
||||
onClickIgnore = { displayIgnoreDialog = true },
|
||||
onClickRemove = { displayRemoveDialog = true },
|
||||
onDismiss = { expanded = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Spacer(modifier = Modifier.height(88.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextMenu(
|
||||
expanded: Boolean,
|
||||
node: Node,
|
||||
onClickFavorite: (Node) -> Unit,
|
||||
onClickIgnore: (Node) -> Unit,
|
||||
onClickRemove: (Node) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
DropdownMenu(expanded = expanded, onDismissRequest = onDismiss) {
|
||||
val isFavorite = node.isFavorite
|
||||
val isIgnored = node.isIgnored
|
||||
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onClickFavorite(node)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = !isIgnored,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Rounded.Star else Icons.Rounded.StarBorder,
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = { Text(stringResource(if (isFavorite) R.string.remove_favorite else R.string.add_favorite)) },
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onClickIgnore(node)
|
||||
onDismiss()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = if (isIgnored) Icons.Filled.DoDisturbOn else Icons.Outlined.DoDisturbOn,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(if (isIgnored) R.string.remove_ignored else R.string.ignore),
|
||||
color = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onClickRemove(node)
|
||||
onDismiss()
|
||||
},
|
||||
enabled = !isIgnored,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.DeleteOutline,
|
||||
contentDescription = null,
|
||||
tint = if (isIgnored) LocalContentColor.current else MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.remove),
|
||||
color = if (isIgnored) Color.Unspecified else MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.util
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.asAndroidPath
|
||||
import androidx.compose.ui.graphics.asComposePath
|
||||
import androidx.compose.ui.graphics.drawscope.DrawContext
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.proto.TelemetryProtos.Telemetry
|
||||
|
||||
object GraphUtil {
|
||||
|
||||
val RADIUS = Resources.getSystem().displayMetrics.density * 2
|
||||
|
||||
/**
|
||||
* @param value Must be zero-scaled before passing.
|
||||
* @param divisor The range for the data set.
|
||||
*/
|
||||
fun plotPoint(drawContext: DrawContext, color: Color, x: Float, value: Float, divisor: Float) {
|
||||
val height = drawContext.size.height
|
||||
val ratio = value / divisor
|
||||
val y = height - (ratio * height)
|
||||
drawContext.canvas.drawCircle(
|
||||
center = Offset(x, y),
|
||||
radius = RADIUS,
|
||||
paint = androidx.compose.ui.graphics.Paint().apply { this.color = color },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [Path] that could be used to draw a line from the `index` to the end of `telemetries` or the last point
|
||||
* before a time separation between [Telemetry]s.
|
||||
*
|
||||
* @param telemetries data used to create the [Path]
|
||||
* @param index current place in the [List]
|
||||
* @param path [Path] that will be used to draw
|
||||
* @param timeRange The time range for the data set
|
||||
* @param width of the [DrawContext]
|
||||
* @param timeThreshold to determine significant breaks in time between [Telemetry]s
|
||||
* @param calculateY (`index`) -> `y` coordinate
|
||||
* @return the current index after iterating
|
||||
*/
|
||||
fun createPath(
|
||||
telemetries: List<Telemetry>,
|
||||
index: Int,
|
||||
path: Path,
|
||||
oldestTime: Int,
|
||||
timeRange: Int,
|
||||
width: Float,
|
||||
timeThreshold: Long,
|
||||
calculateY: (Int) -> Float,
|
||||
): Int {
|
||||
var i = index
|
||||
var isNewLine = true
|
||||
with(path) {
|
||||
while (i < telemetries.size) {
|
||||
val telemetry = telemetries[i]
|
||||
val nextTelemetry = telemetries.getOrNull(i + 1) ?: telemetries.last()
|
||||
|
||||
/* Check to see if we have a significant time break between telemetries. */
|
||||
if (nextTelemetry.time - telemetry.time > timeThreshold) {
|
||||
i++
|
||||
break
|
||||
}
|
||||
|
||||
val x1Ratio = (telemetry.time - oldestTime).toFloat() / timeRange
|
||||
val x1 = x1Ratio * width
|
||||
val y1 = calculateY(i)
|
||||
|
||||
val x2Ratio = (nextTelemetry.time - oldestTime).toFloat() / timeRange
|
||||
val x2 = x2Ratio * width
|
||||
val y2 = calculateY(i + 1)
|
||||
|
||||
if (isNewLine || i == 0) {
|
||||
isNewLine = false
|
||||
moveTo(x1, y1)
|
||||
}
|
||||
|
||||
quadraticTo(x1, y1, (x1 + x2) / 2f, (y1 + y2) / 2f)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
fun DrawScope.drawPathWithGradient(path: Path, color: Color, height: Float, x1: Float, x2: Float) {
|
||||
drawPath(path = path, color = color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round))
|
||||
val fillPath =
|
||||
android.graphics.Path(path.asAndroidPath()).asComposePath().apply {
|
||||
lineTo(x1, height)
|
||||
lineTo(x2, height)
|
||||
close()
|
||||
}
|
||||
drawPath(
|
||||
path = fillPath,
|
||||
brush = Brush.verticalGradient(colors = listOf(color.copy(alpha = 0.5f), Color.Transparent), endY = height),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.util
|
||||
|
||||
import org.meshtastic.proto.MeshProtos
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Safely extracts the hardware model number from a HardwareModel enum.
|
||||
*
|
||||
* This function handles unknown enum values gracefully by catching IllegalArgumentException and returning a fallback
|
||||
* value. This prevents crashes when the app receives data from devices with hardware models not yet defined in the
|
||||
* current protobuf version.
|
||||
*
|
||||
* @param fallbackValue The value to return if the enum is unknown (defaults to 0 for UNSET)
|
||||
* @return The hardware model number, or the fallback value if the enum is unknown
|
||||
*/
|
||||
@Suppress("detekt:SwallowedException")
|
||||
fun MeshProtos.HardwareModel.safeNumber(fallbackValue: Int = -1): Int = try {
|
||||
this.number
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w("Unknown hardware model enum value: $this, using fallback value: $fallbackValue")
|
||||
fallbackValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the hardware model is a known/supported value.
|
||||
*
|
||||
* @return true if the hardware model is known and supported, false otherwise
|
||||
*/
|
||||
@Suppress("detekt:SwallowedException")
|
||||
fun MeshProtos.HardwareModel.isKnown(): Boolean = try {
|
||||
this.number
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue