diff --git a/app/build.gradle b/app/build.gradle index 62a99902..4d695b14 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -150,12 +150,12 @@ dependencies { implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.core:core-location-altitude:1.0.0-alpha03' - implementation 'androidx.fragment:fragment-ktx:1.8.5' + implementation 'androidx.fragment:fragment-ktx:1.8.6' implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.constraintlayout:constraintlayout:2.2.0' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.viewpager2:viewpager2:1.1.0' - implementation 'androidx.datastore:datastore:1.1.1' + implementation 'androidx.datastore:datastore:1.1.2' // Lifecycle def lifecycle_version = '2.8.7' @@ -184,12 +184,12 @@ dependencies { kspAndroidTest "com.google.dagger:hilt-compiler:$hilt_version" // Navigation - def nav_version = "2.8.5" + def nav_version = "2.8.7" implementation "androidx.navigation:navigation-compose:$nav_version" androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" // Compose - def composeBom = platform('androidx.compose:compose-bom:2024.12.01') + def composeBom = platform('androidx.compose:compose-bom:2025.02.00') implementation composeBom androidTestImplementation composeBom @@ -224,13 +224,13 @@ dependencies { androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' // kotlin serialization - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0" // rate this app - googleImplementation 'com.suddenh4x.ratingdialog:awesome-app-rating:2.7.0' + googleImplementation 'com.suddenh4x.ratingdialog:awesome-app-rating:2.8.0' // Coroutines - def coroutines_version = '1.9.0' + def coroutines_version = '1.10.1' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-guava:$coroutines_version" @@ -241,7 +241,7 @@ dependencies { implementation 'com.github.mik3y:usb-serial-for-android:3.8.1' // For Firebase Crashlytics & Analytics - googleImplementation platform('com.google.firebase:firebase-bom:33.7.0') + googleImplementation platform('com.google.firebase:firebase-bom:33.9.0') googleImplementation 'com.google.firebase:firebase-crashlytics' googleImplementation 'com.google.firebase:firebase-analytics' diff --git a/app/src/main/assets/device_hardware.json b/app/src/main/assets/device_hardware.json index 8f857c67..ef52decd 100644 --- a/app/src/main/assets/device_hardware.json +++ b/app/src/main/assets/device_hardware.json @@ -270,6 +270,22 @@ ], "requiresDfu": true }, + { + "hwModel": 22, + "hwModelSlug": "WISMESH_HUB", + "platformioTarget": "rak2560", + "architecture": "nrf52840", + "activelySupported": true, + "supportLevel": 1, + "displayName": "RAK WisMesh Repeater", + "tags": [ + "RAK" + ], + "images": [ + "rak2560.svg" + ], + "requiresDfu": true + }, { "hwModel": 25, "hwModelSlug": "STATION_G1", diff --git a/app/src/main/java/com/geeksville/mesh/model/DeviceHardware.kt b/app/src/main/java/com/geeksville/mesh/model/DeviceHardware.kt index 43fa03d1..73a412f9 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DeviceHardware.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DeviceHardware.kt @@ -78,6 +78,7 @@ private fun getDrawableFrom(hwModel: Int): Int = when (hwModel) { HardwareModel.RPI_PICO_VALUE -> R.drawable.hw_pico HardwareModel.NRF52_PROMICRO_DIY_VALUE -> R.drawable.hw_promicro HardwareModel.RAK11310_VALUE -> R.drawable.hw_rak11310 + HardwareModel.RAK2560_VALUE -> R.drawable.hw_rak2560 HardwareModel.RAK4631_VALUE -> R.drawable.hw_rak4631_case HardwareModel.RPI_PICO2_VALUE -> R.drawable.hw_rpipicow HardwareModel.SENSECAP_INDICATOR_VALUE -> R.drawable.hw_seeed_sensecap_indicator diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index f55e1508..e4c2c731 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -74,6 +74,7 @@ data class MetricsState( val deviceMetrics: List = emptyList(), val environmentMetrics: List = emptyList(), val signalMetrics: List = emptyList(), + val powerMetrics: List = emptyList(), val tracerouteRequests: List = emptyList(), val tracerouteResults: List = emptyList(), val positionLogs: List = emptyList(), @@ -82,6 +83,7 @@ data class MetricsState( fun hasDeviceMetrics() = deviceMetrics.isNotEmpty() fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() fun hasSignalMetrics() = signalMetrics.isNotEmpty() + fun hasPowerMetrics() = powerMetrics.isNotEmpty() fun hasTracerouteLogs() = tracerouteRequests.isNotEmpty() fun hasPositionLogs() = positionLogs.isNotEmpty() @@ -100,6 +102,11 @@ data class MetricsState( return signalMetrics.filter { it.rxTime >= oldestTime } } + fun powerMetricsFiltered(timeFrame: TimeFrame): List { + val oldestTime = timeFrame.calculateOldestTime() + return powerMetrics.filter { it.time >= oldestTime } + } + companion object { val Empty = MetricsState() } @@ -247,7 +254,8 @@ class MetricsViewModel @Inject constructor( deviceMetrics = telemetry.filter { it.hasDeviceMetrics() }, environmentMetrics = telemetry.filter { it.hasEnvironmentMetrics() && it.environmentMetrics.relativeHumidity >= 0f - } + }, + powerMetrics = telemetry.filter { it.hasPowerMetrics() } ) } }.launchIn(viewModelScope) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index d1eb1570..b5767d36 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -86,13 +86,13 @@ import javax.inject.Inject import kotlin.math.roundToInt // Given a human name, strip out the first letter of the first three words and return that as the initials for -// that user. If the original name is only one word, strip vowels from the original name and if the result is -// 3 or more characters, use the first three characters. If not, just take the first 3 characters of the -// original name. +// that user, ignoring emojis. If the original name is only one word, strip vowels from the original +// name and if the result is 3 or more characters, use the first three characters. If not, just take +// the first 3 characters of the original name. fun getInitials(nameIn: String): String { val nchars = 4 val minchars = 2 - val name = nameIn.trim() + val name = nameIn.trim().withoutEmojis() val words = name.split(Regex("\\s+")).filter { it.isNotEmpty() } val initials = when (words.size) { @@ -109,6 +109,8 @@ fun getInitials(nameIn: String): String { return initials.take(nchars) } +private fun String.withoutEmojis(): String = filterNot { char -> char.isSurrogate() } + /** * Builds a [Channel] list from the difference between two [ChannelSettings] lists. * Only changes are included in the resulting list. @@ -483,6 +485,14 @@ class UIViewModel @Inject constructor( updateLoraConfig { it.copy { region = value } } } + fun favoriteNode(node: Node) = viewModelScope.launch { + try { + radioConfigRepository.onServiceAction(ServiceAction.Favorite(node)) + } catch (ex: RemoteException) { + errormsg("Favorite node error:", ex) + } + } + fun ignoreNode(node: Node) = viewModelScope.launch { try { radioConfigRepository.onServiceAction(ServiceAction.Ignore(node)) diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt index 76796cb7..057a9da0 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailNavigation.kt @@ -28,6 +28,7 @@ import com.geeksville.mesh.ui.components.DeviceMetricsScreen import com.geeksville.mesh.ui.components.EnvironmentMetricsScreen import com.geeksville.mesh.ui.components.NodeMapScreen import com.geeksville.mesh.ui.components.PositionLogScreen +import com.geeksville.mesh.ui.components.PowerMetricsScreen import com.geeksville.mesh.ui.components.SignalMetricsScreen import com.geeksville.mesh.ui.components.TracerouteLogScreen @@ -67,6 +68,12 @@ fun NavGraphBuilder.addNodDetailSection(navController: NavController) { viewModel = hiltViewModel(parentEntry), ) } + composable { + val parentEntry = remember { navController.getBackStackEntry() } + PowerMetricsScreen( + viewModel = hiltViewModel(parentEntry), + ) + } composable { val parentEntry = remember { navController.getBackStackEntry() } TracerouteLogScreen( diff --git a/app/src/main/java/com/geeksville/mesh/navigation/Route.kt b/app/src/main/java/com/geeksville/mesh/navigation/Route.kt index 9aa2e7df..459c22c1 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/Route.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/Route.kt @@ -66,5 +66,6 @@ sealed interface Route { @Serializable data object PositionLog : Route @Serializable data object EnvironmentMetrics : Route @Serializable data object SignalMetrics : Route + @Serializable data object PowerMetrics : Route @Serializable data object TracerouteLog : Route } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 6aaac59f..14f7a96a 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -106,6 +106,7 @@ import kotlin.math.absoluteValue sealed class ServiceAction { data class GetDeviceMetadata(val destNum: Int) : ServiceAction() + data class Favorite(val node: Node) : ServiceAction() data class Ignore(val node: Node) : ServiceAction() data class Reaction(val emoji: String, val replyId: Int, val contactKey: String) : ServiceAction() } @@ -1791,6 +1792,7 @@ class MeshService : Service(), Logging { private fun onServiceAction(action: ServiceAction) { when (action) { is ServiceAction.GetDeviceMetadata -> getDeviceMetadata(action.destNum) + is ServiceAction.Favorite -> favoriteNode(action.node) is ServiceAction.Ignore -> ignoreNode(action.node) is ServiceAction.Reaction -> sendReaction(action) } @@ -1802,6 +1804,21 @@ class MeshService : Service(), Logging { }) } + private fun favoriteNode(node: Node) = toRemoteExceptions { + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { + if (node.isFavorite) { + debug("removing node ${node.num} from favorite list") + removeFavoriteNode = node.num + } else { + debug("adding node ${node.num} to favorite list") + setFavoriteNode = node.num + } + }) + updateNodeInfo(node.num) { + it.isFavorite = !node.isFavorite + } + } + private fun ignoreNode(node: Node) = toRemoteExceptions { sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { if (node.isIgnored) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index fc0fa23a..c14de547 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -363,6 +363,14 @@ fun LogNavigationList(state: MetricsState, onNavigate: (Route) -> Unit) { onNavigate(Route.SignalMetrics) } + NavCard( + title = stringResource(R.string.power_metrics_log), + icon = Icons.Default.Power, + enabled = state.hasPowerMetrics() + ) { + onNavigate(Route.PowerMetrics) + } + NavCard( title = stringResource(R.string.traceroute_log), icon = Icons.Default.Route, @@ -457,14 +465,14 @@ private fun EnvironmentMetrics( InfoCard( icon = Icons.Default.Speed, text = "Pressure", - value = "%.0f".format(barometricPressure) + value = "%.0f hPa".format(barometricPressure) ) } if (gasResistance != 0f) { InfoCard( icon = Icons.Default.BlurOn, text = "Gas Resistance", - value = "%.0f".format(gasResistance) + value = "%.0f MΩ".format(gasResistance) ) } if (voltage != 0f) { @@ -499,7 +507,7 @@ private fun EnvironmentMetrics( InfoCard( icon = Icons.Default.LightMode, text = "Lux", - value = "%.0f".format(lux) + value = "%.0f lx".format(lux) ) } if (hasWindSpeed()) { @@ -523,7 +531,7 @@ private fun EnvironmentMetrics( InfoCard( icon = ImageVector.vectorResource(R.drawable.ic_filled_radioactive_24), text = "Radiation", - value = "%.1f µR".format(radiation) + value = "%.1f µR/h".format(radiation) ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt index 74807325..f5e5c9a5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -84,6 +84,7 @@ fun NodeItem( currentTimeMillis: Long, isConnected: Boolean = false, ) { + val isFavorite = thatNode.isFavorite val isIgnored = thatNode.isIgnored val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) } @@ -150,7 +151,7 @@ fun NodeItem( Text( modifier = Modifier.fillMaxWidth(), text = thatNode.user.shortName.ifEmpty { "???" }, - fontWeight = FontWeight.Normal, + fontWeight = if (isFavorite) FontWeight.Bold else FontWeight.Normal, fontSize = MaterialTheme.typography.button.fontSize, textDecoration = TextDecoration.LineThrough.takeIf { isIgnored }, textAlign = TextAlign.Center, @@ -173,6 +174,7 @@ fun NodeItem( Text( modifier = Modifier.weight(1f), text = longName, + fontWeight = if (isFavorite) FontWeight.Bold else FontWeight.Normal, style = style, textDecoration = TextDecoration.LineThrough.takeIf { isIgnored }, softWrap = true, diff --git a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt index c9adfe5f..a8d34e88 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/UsersFragment.kt @@ -136,6 +136,7 @@ fun NodesScreen( when (menuItem) { is NodeMenuAction.Remove -> model.removeNode(node.num) is NodeMenuAction.Ignore -> model.ignoreNode(node) + is NodeMenuAction.Favorite -> model.favoriteNode(node) is NodeMenuAction.DirectMessage -> navigateToMessages(node) is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num) is NodeMenuAction.RequestPosition -> model.requestPosition(node.num) diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt index 0444a2f2..17400398 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/CommonCharts.kt @@ -61,6 +61,7 @@ import com.geeksville.mesh.ui.components.CommonCharts.LINE_LIMIT import com.geeksville.mesh.ui.components.CommonCharts.TEXT_PAINT_ALPHA import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT import com.geeksville.mesh.ui.components.CommonCharts.LEFT_LABEL_SPACING +import com.geeksville.mesh.ui.components.CommonCharts.MAX_PERCENT_VALUE import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import java.text.DateFormat @@ -71,12 +72,13 @@ object CommonCharts { const val MS_PER_SEC = 1000L const val LINE_LIMIT = 4 const val TEXT_PAINT_ALPHA = 192 + const val MAX_PERCENT_VALUE = 100f } -private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM) -private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT) 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 data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false) @@ -104,7 +106,7 @@ fun ChartHeader(amount: Int) { * @param lineColors A list of 5 `Color`s for the chart lines, 0 being the lowest line on the chart. * @param leaveSpace When true the lines will leave space for Y labels on the left side of the graph. */ -@Deprecated("Will soon be replaced with YAxisLabels() and HorizontalLines()", level = DeprecationLevel.WARNING) +@Deprecated("Will soon be replaced with YAxisLabels() and HorizontalLinesOverlay()", level = DeprecationLevel.WARNING) @Composable fun ChartOverlay( modifier: Modifier, @@ -166,7 +168,7 @@ fun ChartOverlay( } /** - * Draws chart lines with respect to the Y-axis range; defined by (`maxValue` - `minValue`). + * 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. */ @@ -174,21 +176,18 @@ fun ChartOverlay( fun HorizontalLinesOverlay( modifier: Modifier, lineColors: List, - minValue: Float, - maxValue: Float, ) { - val range = maxValue - minValue - val verticalSpacing = range / LINE_LIMIT + /* 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 = minValue + var lineY = 0f for (i in 0..LINE_LIMIT) { - val ratio = (lineY - minValue) / range + val ratio = lineY / MAX_PERCENT_VALUE val y = height - (ratio * height) drawLine( start = Offset(lineStart, y), @@ -322,11 +321,7 @@ fun TimeLabels( oldest: Int, newest: Int ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { + Row { Text( text = DATE_TIME_FORMAT.format(oldest * MS_PER_SEC), modifier = Modifier.wrapContentWidth(), @@ -350,7 +345,11 @@ fun TimeLabels( * @param promptInfoDialog Executes when the user presses the info icon. */ @Composable -fun Legend(legendData: List, promptInfoDialog: () -> Unit) { +fun Legend( + legendData: List, + displayInfoIcon: Boolean = true, + promptInfoDialog: () -> Unit = {} +) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -367,13 +366,14 @@ fun Legend(legendData: List, promptInfoDialog: () -> Unit) { Spacer(modifier = Modifier.weight(1f)) } } - Spacer(modifier = Modifier.width(4.dp)) - - Icon( - imageVector = Icons.Default.Info, - modifier = Modifier.clickable { promptInfoDialog() }, - contentDescription = stringResource(R.string.info) - ) + 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)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt index 77c15bd8..01f4c380 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/DeviceMetrics.kt @@ -64,22 +64,21 @@ import com.geeksville.mesh.model.TimeFrame import com.geeksville.mesh.ui.BatteryInfo import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT +import com.geeksville.mesh.ui.components.CommonCharts.MAX_PERCENT_VALUE import com.geeksville.mesh.ui.theme.Orange import com.geeksville.mesh.util.GraphUtil import com.geeksville.mesh.util.GraphUtil.plotPoint import com.geeksville.mesh.util.GraphUtil.createPath -private val DEVICE_METRICS_COLORS = listOf(Color.Green, Color.Magenta, Color.Cyan) -private const val MAX_PERCENT_VALUE = 100f -private enum class Device { - BATTERY, - CH_UTIL, - AIR_UTIL +private enum class Device(val color: Color) { + BATTERY(Color.Green), + CH_UTIL(Color.Magenta), + AIR_UTIL(Color.Cyan) } private val LEGEND_DATA = listOf( - LegendData(nameRes = R.string.battery, color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], isLine = true), - LegendData(nameRes = R.string.channel_utilization, color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal]), - LegendData(nameRes = R.string.air_utilization, color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal]), + LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true), + LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color), + LegendData(nameRes = R.string.air_utilization, color = Device.AIR_UTIL.color), ) @Composable @@ -112,11 +111,12 @@ fun DeviceMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedTimeFrame, onOptionSelected = { viewModel.setTimeFrame(it) } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } /* Device Metric Cards */ @@ -180,8 +180,6 @@ private fun DeviceMetricsChart( HorizontalLinesOverlay( modifier.width(dp), lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor), - minValue = 0f, - maxValue = 100f ) TimeAxisOverlay( @@ -206,7 +204,7 @@ private fun DeviceMetricsChart( /* Channel Utilization */ plotPoint( drawContext = drawContext, - color = DEVICE_METRICS_COLORS[Device.CH_UTIL.ordinal], + color = Device.CH_UTIL.color, x = x, value = telemetry.deviceMetrics.channelUtilization, divisor = MAX_PERCENT_VALUE @@ -215,7 +213,7 @@ private fun DeviceMetricsChart( /* Air Utilization Transmit */ plotPoint( drawContext = drawContext, - color = DEVICE_METRICS_COLORS[Device.AIR_UTIL.ordinal], + color = Device.AIR_UTIL.color, x = x, value = telemetry.deviceMetrics.airUtilTx, divisor = MAX_PERCENT_VALUE @@ -242,7 +240,7 @@ private fun DeviceMetricsChart( } drawPath( path = path, - color = DEVICE_METRICS_COLORS[Device.BATTERY.ordinal], + color = Device.BATTERY.color, style = Stroke( width = GraphUtil.RADIUS, cap = StrokeCap.Round @@ -260,7 +258,7 @@ private fun DeviceMetricsChart( } Spacer(modifier = Modifier.height(16.dp)) - Legend(legendData = LEGEND_DATA, promptInfoDialog) + Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt index bf9254c8..9e81206e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/EnvironmentMetrics.kt @@ -61,30 +61,30 @@ import com.geeksville.mesh.R import com.geeksville.mesh.TelemetryProtos.Telemetry import com.geeksville.mesh.copy import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.TimeFrame import com.geeksville.mesh.ui.components.CommonCharts.X_AXIS_SPACING import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT -private val ENVIRONMENT_METRICS_COLORS = listOf(Color.Red, Color.Blue, Color.Green) -private enum class Environment { - TEMPERATURE, - HUMIDITY, - IAQ +private enum class Environment(val color: Color) { + TEMPERATURE(Color.Red), + HUMIDITY(Color.Blue), + IAQ(Color.Green) } private val LEGEND_DATA = listOf( LegendData( nameRes = R.string.temperature, - color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + color = Environment.TEMPERATURE.color, isLine = true ), LegendData( nameRes = R.string.humidity, - color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + color = Environment.HUMIDITY.color, isLine = true ), LegendData( nameRes = R.string.iaq, - color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + color = Environment.IAQ.color, isLine = true ), ) @@ -137,11 +137,12 @@ fun EnvironmentMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedTimeFrame, onOptionSelected = { viewModel.setTimeFrame(it) } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } /* Environment Metric Cards */ @@ -178,13 +179,13 @@ private fun EnvironmentMetricsChart( val graphColor = MaterialTheme.colors.onSurface val transparentTemperatureColor = remember { - ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal].copy(alpha = 0.5f) + Environment.TEMPERATURE.color.copy(alpha = 0.5f) } val transparentHumidityColor = remember { - ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal].copy(alpha = 0.5f) + Environment.HUMIDITY.color.copy(alpha = 0.5f) } val transparentIAQColor = remember { - ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal].copy(alpha = 0.5f) + Environment.IAQ.color.copy(alpha = 0.5f) } val spacing = X_AXIS_SPACING @@ -280,7 +281,7 @@ private fun EnvironmentMetricsChart( drawPath( path = temperaturePath, - color = ENVIRONMENT_METRICS_COLORS[Environment.TEMPERATURE.ordinal], + color = Environment.TEMPERATURE.color, style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -333,7 +334,7 @@ private fun EnvironmentMetricsChart( drawPath( path = humidityPath, - color = ENVIRONMENT_METRICS_COLORS[Environment.HUMIDITY.ordinal], + color = Environment.HUMIDITY.color, style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -388,7 +389,7 @@ private fun EnvironmentMetricsChart( drawPath( path = iaqPath, - color = ENVIRONMENT_METRICS_COLORS[Environment.IAQ.ordinal], + color = Environment.IAQ.color, style = Stroke( width = 2.dp.toPx(), cap = StrokeCap.Round @@ -399,7 +400,7 @@ private fun EnvironmentMetricsChart( Spacer(modifier = Modifier.height(16.dp)) - Legend(LEGEND_DATA, promptInfoDialog) + Legend(LEGEND_DATA, promptInfoDialog = promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt index 50d0f7bf..ae029ac2 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt @@ -47,8 +47,25 @@ fun NodeMenu( expanded: Boolean = false, onAction: (NodeMenuAction) -> Unit ) { + var displayFavoriteDialog by remember { mutableStateOf(false) } var displayIgnoreDialog by remember { mutableStateOf(false) } var displayRemoveDialog by remember { mutableStateOf(false) } + if (displayFavoriteDialog) { + SimpleAlertDialog( + title = R.string.favorite, + text = stringResource( + id = if (node.isFavorite) R.string.favorite_remove else R.string.favorite_add, + node.user.longName + ), + onConfirm = { + displayFavoriteDialog = false + onAction(NodeMenuAction.Favorite(node)) + }, + onDismiss = { + displayFavoriteDialog = false + } + ) + } if (displayIgnoreDialog) { SimpleAlertDialog( title = R.string.ignore, @@ -113,6 +130,25 @@ fun NodeMenu( }, content = { Text(stringResource(R.string.traceroute)) } ) + DropdownMenuItem( + onClick = { + onDismissRequest() + displayFavoriteDialog = true + }, + enabled = !node.isIgnored, + ) { + Text(stringResource(R.string.favorite)) + Spacer(Modifier.weight(1f)) + Checkbox( + checked = node.isFavorite, + onCheckedChange = { + onDismissRequest() + displayFavoriteDialog = true + }, + modifier = Modifier.size(24.dp), + enabled = !node.isIgnored, + ) + } DropdownMenuItem( onClick = { onDismissRequest() @@ -152,6 +188,7 @@ fun NodeMenu( sealed class NodeMenuAction { data class Remove(val node: Node) : NodeMenuAction() data class Ignore(val node: Node) : NodeMenuAction() + data class Favorite(val node: Node) : NodeMenuAction() data class DirectMessage(val node: Node) : NodeMenuAction() data class RequestUserInfo(val node: Node) : NodeMenuAction() data class RequestPosition(val node: Node) : NodeMenuAction() diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt new file mode 100644 index 00000000..d98a30d4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/PowerMetrics.kt @@ -0,0 +1,371 @@ +/* + * 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 . + */ + +package com.geeksville.mesh.ui.components + +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.material.Card +import androidx.compose.material.Surface +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.LocalConfiguration +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.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.R +import com.geeksville.mesh.TelemetryProtos.Telemetry +import com.geeksville.mesh.model.MetricsViewModel +import com.geeksville.mesh.model.TimeFrame +import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC +import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT +import com.geeksville.mesh.util.GraphUtil +import com.geeksville.mesh.util.GraphUtil.createPath + +@Suppress("MagicNumber") +private enum class Power(val color: Color, val min: Float, val max: Float) { + CURRENT(Color(75, 119, 190), -500f, 500f), + VOLTAGE(Color.Red, 0f, 20f); + /** + * 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 val LEGEND_DATA = listOf( + LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true), + LegendData(nameRes = R.string.voltage, color = Power.VOLTAGE.color, isLine = true), +) + +@Composable +fun PowerMetricsScreen( + viewModel: MetricsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val selectedTimeFrame by viewModel.timeFrame.collectAsState() + var selectedChannel by remember { mutableStateOf(PowerChannel.ONE) } + val data = state.powerMetricsFiltered(selectedTimeFrame) + + Column { + + 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, + 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 + + TimeLabels( + oldest = oldest.time, + newest = newest.time + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val graphColor = MaterialTheme.colors.onSurface + val currentDiff = Power.CURRENT.difference() + val voltageDiff = Power.VOLTAGE.difference() + + val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + val screenWidth = configuration.screenWidthDp + val dp by remember(key1 = selectedTime) { + mutableStateOf(selectedTime.dp(screenWidth, time = (newest.time - oldest.time).toLong())) + } + + Row { + YAxisLabels( + modifier = modifier.weight(weight = .1f), + 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) / voltageDiff + val y = height - (ratio * height) + return@createPath y + } + drawPath( + path = path, + color = Power.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 = .1f), + Power.VOLTAGE.color, + minValue = Power.VOLTAGE.min, + maxValue = Power.VOLTAGE.max, + ) + } + + 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.button.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.button.fontSize + ) + Text( + text = "%.2fV".format(voltage), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + Text( + text = "%.1fmA".format(current), + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.button.fontSize + ) + } +} + +/** + * Retrieves the appropriate voltage depending on `channelSelected`. + */ +private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float { + return 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 { + return when (channelSelected) { + PowerChannel.ONE -> telemetry.powerMetrics.ch1Current + PowerChannel.TWO -> telemetry.powerMetrics.ch2Current + PowerChannel.THREE -> telemetry.powerMetrics.ch3Current + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt index 6a62ca1e..927d1233 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SignalMetrics.kt @@ -63,20 +63,18 @@ import com.geeksville.mesh.ui.components.CommonCharts.MS_PER_SEC import com.geeksville.mesh.ui.components.CommonCharts.DATE_TIME_FORMAT import com.geeksville.mesh.util.GraphUtil.plotPoint -private val METRICS_COLORS = listOf(Color.Green, Color.Blue) - @Suppress("MagicNumber") -private enum class Metric(val min: Float, val max: Float) { - SNR(-20f, 12f), /* Selected 12 as the max to get 4 equal vertical sections. */ - RSSI(-140f, -20f); +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 val LEGEND_DATA = listOf( - LegendData(nameRes = R.string.rssi, color = METRICS_COLORS[Metric.RSSI.ordinal]), - LegendData(nameRes = R.string.snr, color = METRICS_COLORS[Metric.SNR.ordinal]) + LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color), + LegendData(nameRes = R.string.snr, color = Metric.SNR.color) ) @Composable @@ -109,11 +107,12 @@ fun SignalMetricsScreen( promptInfoDialog = { displayInfoDialog = true } ) - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedTimeFrame, onOptionSelected = { viewModel.setTimeFrame(it) } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } LazyColumn( @@ -166,7 +165,7 @@ private fun SignalMetricsChart( Row { YAxisLabels( modifier = modifier.weight(weight = .1f), - METRICS_COLORS[Metric.RSSI.ordinal], + Metric.RSSI.color, minValue = Metric.RSSI.min, maxValue = Metric.RSSI.max, ) @@ -179,8 +178,6 @@ private fun SignalMetricsChart( HorizontalLinesOverlay( modifier.width(dp), lineColors = List(size = 5) { graphColor }, - minValue = Metric.SNR.min, - maxValue = Metric.SNR.max ) TimeAxisOverlay( @@ -202,7 +199,7 @@ private fun SignalMetricsChart( /* SNR */ plotPoint( drawContext = drawContext, - color = METRICS_COLORS[Metric.SNR.ordinal], + color = Metric.SNR.color, x = x, value = packet.rxSnr - Metric.SNR.min, divisor = snrDiff @@ -211,7 +208,7 @@ private fun SignalMetricsChart( /* RSSI */ plotPoint( drawContext = drawContext, - color = METRICS_COLORS[Metric.RSSI.ordinal], + color = Metric.RSSI.color, x = x, value = packet.rxRssi - Metric.RSSI.min, divisor = rssiDiff @@ -221,7 +218,7 @@ private fun SignalMetricsChart( } YAxisLabels( modifier = modifier.weight(weight = .1f), - METRICS_COLORS[Metric.SNR.ordinal], + Metric.SNR.color, minValue = Metric.SNR.min, maxValue = Metric.SNR.max, ) @@ -229,7 +226,7 @@ private fun SignalMetricsChart( Spacer(modifier = Modifier.height(16.dp)) - Legend(legendData = LEGEND_DATA, promptInfoDialog) + Legend(legendData = LEGEND_DATA, promptInfoDialog = promptInfoDialog) Spacer(modifier = Modifier.height(16.dp)) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SlidingSelector.kt similarity index 88% rename from app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt rename to app/src/main/java/com/geeksville/mesh/ui/components/SlidingSelector.kt index 21dc65b6..fdd5969e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/MetricsTimeSelector.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SlidingSelector.kt @@ -94,20 +94,22 @@ private const val PRESSED_UNSELECTED_ALPHA = .6f private val BACKGROUND_SHAPE = RoundedCornerShape(8.dp) /** - * Provides the user with a set of time options they can choose from that controls - * the time frame the data being plotted was received. + * Provides the user with a set of options they can choose from. + * * (Inspired by https://gist.github.com/zach-klippenstein/7ae8874db304f957d6bb91263e292117) */ @Composable -fun MetricsTimeSelector( - selectedTime: TimeFrame, - onOptionSelected: (TimeFrame) -> Unit, +fun SlidingSelector( + options: List, + selectedOption: T, + onOptionSelected: (T) -> Unit, modifier: Modifier = Modifier, - content: @Composable (TimeFrame) -> Unit + content: @Composable (T) -> Unit ) { - val state = remember { TimeSelectorState() } - state.selectedOption = state.options.indexOf(selectedTime) - state.onOptionSelected = { onOptionSelected(state.options[it]) } + val state = remember { SelectorState() } + state.optionCount = options.size + state.selectedOption = options.indexOf(selectedOption) + state.onOptionSelected = { onOptionSelected(options[it]) } /* Animate between whole-number indices so we don't need to do pixel calculations. */ val selectedIndexOffset by animateFloatAsState(state.selectedOption.toFloat(), label = "Selected Index Offset") @@ -116,7 +118,7 @@ fun MetricsTimeSelector( content = { SelectedIndicator(state) Dividers(state) - TimeOptions(state, content) + Options(state, options, content) }, modifier = modifier .fillMaxWidth() @@ -133,7 +135,7 @@ fun MetricsTimeSelector( /* Measure the indicator and dividers to be the right size. */ val indicatorPlaceable = indicatorMeasurable.measure( Constraints.fixed( - width = optionsPlaceable.width / state.options.size, + width = optionsPlaceable.width / options.size, height = optionsPlaceable.height ) ) @@ -146,7 +148,7 @@ fun MetricsTimeSelector( ) layout(optionsPlaceable.width, optionsPlaceable.height) { - val optionWidth = optionsPlaceable.width / state.options.size + val optionWidth = optionsPlaceable.width / options.size /* Place the indicator first so that it's below the option labels. */ indicatorPlaceable.placeRelative( @@ -160,18 +162,18 @@ fun MetricsTimeSelector( } /** - * Visual representation of the time option the user may select. + * Visual representation of the option the user may select. */ @Composable -fun TimeLabel(text: String) { +fun OptionLabel(text: String) { Text(text, maxLines = 1, overflow = Ellipsis) } /** - * Draws the selected indicator on the [MetricsTimeSelector] track. + * Draws the selected indicator on the [SlidingSelector] track. */ @Composable -private fun SelectedIndicator(state: TimeSelectorState) { +private fun SelectedIndicator(state: SelectorState) { Box( Modifier .then( @@ -186,18 +188,18 @@ private fun SelectedIndicator(state: TimeSelectorState) { } /** - * Draws dividers between [TimeLabel]s. + * Draws dividers between [OptionLabel]s. */ @Composable -private fun Dividers(state: TimeSelectorState) { +private fun Dividers(state: SelectorState) { /* Animate each divider independently. */ - val alphas = (0 until state.options.size).map { i -> + val alphas = (0 until state.optionCount).map { i -> val selectionAdjacent = i == state.selectedOption || i - 1 == state.selectedOption animateFloatAsState(if (selectionAdjacent) 0f else 1f, label = "Dividers") } Canvas(Modifier.fillMaxSize()) { - val optionWidth = size.width / state.options.size + val optionWidth = size.width / state.optionCount val dividerPadding = TRACK_PADDING + PRESSED_TRACK_PADDING alphas.forEachIndexed { i, alpha -> @@ -213,12 +215,13 @@ private fun Dividers(state: TimeSelectorState) { } /** - * Draws the time options available to the user. + * Draws the options available to the user. */ @Composable -private fun TimeOptions( - state: TimeSelectorState, - content: @Composable (TimeFrame) -> Unit +private fun Options( + state: SelectorState, + options: List, + content: @Composable (T) -> Unit ) { CompositionLocalProvider( LocalTextStyle provides TextStyle(fontWeight = FontWeight.Medium) @@ -229,7 +232,7 @@ private fun TimeOptions( .fillMaxWidth() .selectableGroup() ) { - state.options.forEachIndexed { i, timeFrame -> + options.forEachIndexed { i, timeFrame -> val isSelected = i == state.selectedOption val isPressed = i == state.pressedOption @@ -267,10 +270,10 @@ private fun TimeOptions( } /** - * Contains and handles the state necessary to present the [MetricsTimeSelector] to the user. + * Contains and handles the state necessary to present the [SlidingSelector] to the user. */ -private class TimeSelectorState { - val options = TimeFrame.entries.toTypedArray() +private class SelectorState { + var optionCount by mutableIntStateOf(0) var selectedOption by mutableIntStateOf(0) var onOptionSelected: (Int) -> Unit by mutableStateOf({}) var pressedOption by mutableIntStateOf(NO_OPTION_INDEX) @@ -317,7 +320,7 @@ private class TimeSelectorState { this.transformOrigin = TransformOrigin( pivotFractionX = when (option) { 0 -> 0f - options.size - 1 -> 1f + optionCount - 1 -> 1f else -> .5f }, pivotFractionY = .5f @@ -326,7 +329,7 @@ private class TimeSelectorState { /* But should still move inwards to keep the pressed padding consistent with top and bottom. */ this.translationX = when (option) { 0 -> xOffset.toPx() - options.size - 1 -> -xOffset.toPx() + optionCount - 1 -> -xOffset.toPx() else -> 0f } } @@ -336,14 +339,14 @@ private class TimeSelectorState { * A [Modifier] that will listen for touch gestures and update the selected and pressed properties * of this state appropriately. */ - val inputModifier = Modifier.pointerInput(options.size) { - val optionWidth = size.width / options.size + val inputModifier = Modifier.pointerInput(optionCount) { + val optionWidth = size.width / optionCount /* Helper to calculate which option an event occurred in. */ fun optionIndex(change: PointerInputChange): Int = - ((change.position.x / size.width.toFloat()) * options.size) + ((change.position.x / size.width.toFloat()) * optionCount) .toInt() - .coerceIn(0, options.size - 1) + .coerceIn(0, optionCount - 1) awaitEachGesture { val down = awaitFirstDown() @@ -401,17 +404,18 @@ private suspend fun AwaitPointerEventScope.waitForUpOrCancellation(inBounds: Rec @Preview @Composable -fun MetricsTimeSelectorPreview() { +fun SlidingSelectorPreview() { MaterialTheme { Surface { Column(Modifier.padding(8.dp)) { var selectedOption by remember { mutableStateOf(TimeFrame.TWENTY_FOUR_HOURS) } - MetricsTimeSelector( + SlidingSelector( + TimeFrame.entries.toList(), selectedOption, onOptionSelected = { selectedOption = it } ) { - TimeLabel(stringResource(it.strRes)) + OptionLabel(stringResource(it.strRes)) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index bcb94fb0..95a89806 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -101,6 +101,8 @@ import com.geeksville.mesh.ui.theme.AppTheme import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +private const val MESSAGE_CHARACTER_LIMIT = 200 + internal fun FragmentManager.navigateToMessages(contactKey: String, message: String = "") { val messagesFragment = MessagesFragment().apply { arguments = bundleOf("contactKey" to contactKey, "message" to message) @@ -253,13 +255,16 @@ internal fun MessageScreen( QuickChatRow(isConnected, quickChat) { action -> if (action.mode == QuickChatAction.Mode.Append) { val originalText = messageInput.value.text - val needsSpace = !originalText.endsWith(' ') && originalText.isNotEmpty() - val newText = buildString { - append(originalText) - if (needsSpace) append(' ') - append(action.message) + if (!originalText.contains(action.message)) { + val needsSpace = + !originalText.endsWith(' ') && originalText.isNotEmpty() + val newText = buildString { + append(originalText) + if (needsSpace) append(' ') + append(action.message) + }.take(MESSAGE_CHARACTER_LIMIT) + messageInput.value = TextFieldValue(newText, TextRange(newText.length)) } - messageInput.value = TextFieldValue(newText, TextRange(newText.length)) } else { viewModel.sendMessage(action.message, contactKey) } @@ -278,6 +283,7 @@ internal fun MessageScreen( when (action) { is NodeMenuAction.Remove -> viewModel.removeNode(action.node.num) is NodeMenuAction.Ignore -> viewModel.ignoreNode(action.node) + is NodeMenuAction.Favorite -> viewModel.favoriteNode(action.node) is NodeMenuAction.DirectMessage -> navigateToMessages(action.node) is NodeMenuAction.RequestUserInfo -> viewModel.requestUserInfo(action.node.num) is NodeMenuAction.RequestPosition -> viewModel.requestPosition(action.node.num) @@ -419,7 +425,7 @@ private fun TextInput( enabled: Boolean, message: MutableState, modifier: Modifier = Modifier, - maxSize: Int = 200, + maxSize: Int = MESSAGE_CHARACTER_LIMIT, onClick: (String) -> Unit = {} ) = Column(modifier) { val focusManager = LocalFocusManager.current diff --git a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt index 95b737b9..b2ce995b 100644 --- a/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt +++ b/app/src/main/java/com/geeksville/mesh/util/GraphUtil.kt @@ -60,6 +60,7 @@ object GraphUtil { * @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, diff --git a/app/src/main/res/drawable/hw_rak2560.xml b/app/src/main/res/drawable/hw_rak2560.xml new file mode 100644 index 00000000..219e40a0 --- /dev/null +++ b/app/src/main/res/drawable/hw_rak2560.xml @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 39c8048e..0f910772 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -313,4 +313,13 @@ Unknown Age Copy Alert Bell Character! + Favorite + Add \'%s\' as a favorite node? + Remove \'%s\' as a favorite node? + Power Metrics Log + Channel 1 + Channel 2 + Channel 3 + Current + Voltage diff --git a/app/src/test/java/com/geeksville/mesh/ExampleUnitTest.kt b/app/src/test/java/com/geeksville/mesh/ExampleUnitTest.kt deleted file mode 100644 index b184cc8c..00000000 --- a/app/src/test/java/com/geeksville/mesh/ExampleUnitTest.kt +++ /dev/null @@ -1,34 +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 . - */ - -package com.geeksville.mesh - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/app/src/test/java/com/geeksville/mesh/ui/UIUnitTest.kt b/app/src/test/java/com/geeksville/mesh/ui/UIUnitTest.kt index dbbf6f51..fb4ed6b5 100644 --- a/app/src/test/java/com/geeksville/mesh/ui/UIUnitTest.kt +++ b/app/src/test/java/com/geeksville/mesh/ui/UIUnitTest.kt @@ -18,22 +18,25 @@ package com.geeksville.mesh.ui import com.geeksville.mesh.model.getInitials -import org.junit.Assert +import org.junit.Assert.assertEquals import org.junit.Test -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ class UIUnitTest { @Test fun initialsGood() { - Assert.assertEquals("KH", getInitials("Kevin Hester")) - Assert.assertEquals("KHLC", getInitials(" Kevin Hester Lesser Cat ")) - Assert.assertEquals("", getInitials(" ")) - Assert.assertEquals("gksv", getInitials("geeksville")) - Assert.assertEquals("geek", getInitials("geek")) - Assert.assertEquals("gks1", getInitials("geeks1")) + assertEquals("KH", getInitials("Kevin Hester")) + assertEquals("KHLC", getInitials(" Kevin Hester Lesser Cat ")) + assertEquals("", getInitials(" ")) + assertEquals("gksv", getInitials("geeksville")) + assertEquals("geek", getInitials("geek")) + assertEquals("gks1", getInitials("geeks1")) + } + + @Test + fun ignoreEmojisWhenCreatingInitials() { + assertEquals("TG", getInitials("The \uD83D\uDC10 Goat")) + assertEquals("TT", getInitials("The \uD83E\uDD14Thinker")) + assertEquals("TCH", getInitials("\uD83D\uDC4F\uD83C\uDFFFThe Clapping Hands")) + assertEquals("山羊", getInitials("山羊\uD83D\uDC10")) } } diff --git a/build.gradle b/build.gradle index bd5415c5..a2cd406c 100644 --- a/build.gradle +++ b/build.gradle @@ -3,9 +3,9 @@ buildscript { ext { useCrashlytics = false - kotlin_version = '2.0.21' - hilt_version = '2.54' - protobuf_version = '4.29.2' + kotlin_version = '2.1.10' + hilt_version = '2.55' + protobuf_version = '4.29.3' } repositories { @@ -13,7 +13,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.7.3' + classpath 'com.android.tools.build:gradle:8.8.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" @@ -23,7 +23,7 @@ buildscript { // Firebase Crashlytics if (useCrashlytics) { classpath 'com.google.gms:google-services:4.4.2' - classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.2' + classpath 'com.google.firebase:firebase-crashlytics-gradle:3.0.3' } // protobuf plugin - docs here https://github.com/google/protobuf-gradle-plugin @@ -35,7 +35,7 @@ buildscript { plugins { id "org.jetbrains.kotlin.jvm" version "$kotlin_version" apply false - id "com.google.devtools.ksp" version "2.0.21-1.0.28" apply false + id "com.google.devtools.ksp" version "2.1.10-1.0.30" apply false id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index cea7a793..e18bc253 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/renovate.json b/renovate.json index 7cc78129..a12fe8ef 100644 --- a/renovate.json +++ b/renovate.json @@ -8,5 +8,6 @@ "replacements:all", "workarounds:all" ], - "commitMessageTopic": "{{depName}}" + "commitMessageTopic": "{{depName}}", + "labels": ["dependencies"] }