mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: Move common UI components to core:ui and add screenshot tests
Relocate messaging and node-related UI components from feature modules to the shared core:ui module to improve reusability across the project. Enable the screenshot testing plugin and add comprehensive baseline tests for shared components. - **feature/messaging**: Move `MessageItem`, `MessageActions`, `MessageBubble`, and `Reaction` components to core:ui. - **feature/node**: Move `NodeItem`, `NodeStatusIcons`, `InfoCard`, and `CooldownIconButton` to core:ui. - **core/ui**: Add new screenshot tests for `MainAppBar`, `NodeItem`, `MessageItem`, and various common UI components. - **build.gradle.kts**: Configure the screenshot testing plugin and dependencies for node and messaging feature modules. - Update imports and references across the codebase to reflect relocated components.
This commit is contained in:
parent
a19c463b37
commit
85847e1144
50 changed files with 332 additions and 138 deletions
|
|
@ -21,6 +21,7 @@ plugins {
|
|||
alias(libs.plugins.meshtastic.android.library.flavors)
|
||||
alias(libs.plugins.meshtastic.android.library.compose)
|
||||
alias(libs.plugins.meshtastic.hilt)
|
||||
alias(libs.plugins.screenshot)
|
||||
}
|
||||
|
||||
configure<LibraryExtension> {
|
||||
|
|
@ -29,6 +30,8 @@ configure<LibraryExtension> {
|
|||
defaultConfig { manifestPlaceholders["MAPS_API_KEY"] = "DEBUG_KEY" }
|
||||
|
||||
testOptions { unitTests { isIncludeAndroidResources = true } }
|
||||
|
||||
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
|
@ -70,4 +73,10 @@ dependencies {
|
|||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(libs.robolectric)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
screenshotTestImplementation(libs.screenshot.validation.api)
|
||||
screenshotTestImplementation(libs.androidx.compose.ui.tooling)
|
||||
screenshotTestImplementation(libs.compose.multiplatform.runtime)
|
||||
screenshotTestImplementation(libs.compose.multiplatform.resources)
|
||||
screenshotTestImplementation(projects.core.resources)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,142 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.common.util.nowMillis
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Refresh
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
internal const val COOL_DOWN_TIME_MS = 30000L
|
||||
internal const val REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS = 180000L // 3 minutes
|
||||
|
||||
@Composable
|
||||
fun CooldownIconButton(
|
||||
onClick: () -> Unit,
|
||||
cooldownTimestamp: Long?,
|
||||
cooldownDuration: Long = COOL_DOWN_TIME_MS,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val progress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(cooldownTimestamp) {
|
||||
if (cooldownTimestamp == null) {
|
||||
progress.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val timeSinceLast = nowMillis - cooldownTimestamp
|
||||
if (timeSinceLast < cooldownDuration) {
|
||||
val remainingTime = cooldownDuration - timeSinceLast
|
||||
progress.snapTo(remainingTime / cooldownDuration.toFloat())
|
||||
progress.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
|
||||
)
|
||||
} else {
|
||||
progress.snapTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
val isCoolingDown = progress.value > 0f
|
||||
|
||||
IconButton(
|
||||
onClick = { if (!isCoolingDown) onClick() },
|
||||
enabled = !isCoolingDown,
|
||||
colors = IconButtonDefaults.iconButtonColors(),
|
||||
) {
|
||||
if (isCoolingDown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress.value },
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CooldownOutlinedIconButton(
|
||||
onClick: () -> Unit,
|
||||
cooldownTimestamp: Long?,
|
||||
cooldownDuration: Long = COOL_DOWN_TIME_MS,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val progress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(cooldownTimestamp) {
|
||||
if (cooldownTimestamp == null) {
|
||||
progress.snapTo(0f)
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val timeSinceLast = nowMillis - cooldownTimestamp
|
||||
if (timeSinceLast < cooldownDuration) {
|
||||
val remainingTime = cooldownDuration - timeSinceLast
|
||||
progress.snapTo(remainingTime / cooldownDuration.toFloat())
|
||||
progress.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
|
||||
)
|
||||
} else {
|
||||
progress.snapTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
val isCoolingDown = progress.value > 0f
|
||||
|
||||
OutlinedIconButton(
|
||||
onClick = { if (!isCoolingDown) onClick() },
|
||||
enabled = !isCoolingDown,
|
||||
colors = IconButtonDefaults.outlinedIconButtonColors(),
|
||||
) {
|
||||
if (isCoolingDown) {
|
||||
CircularProgressIndicator(
|
||||
progress = { progress.value },
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeCap = StrokeCap.Round,
|
||||
)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun CooldownOutlinedIconButtonPreview() {
|
||||
AppTheme {
|
||||
CooldownOutlinedIconButton(onClick = {}, cooldownTimestamp = nowMillis - 15000L) {
|
||||
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,8 @@ import org.meshtastic.core.resources.uv_lux
|
|||
import org.meshtastic.core.resources.voltage
|
||||
import org.meshtastic.core.resources.weight
|
||||
import org.meshtastic.core.resources.wind
|
||||
import org.meshtastic.core.ui.component.DrawableInfoCard
|
||||
import org.meshtastic.core.ui.component.InfoCard
|
||||
import org.meshtastic.feature.node.model.DrawableMetricInfo
|
||||
import org.meshtastic.feature.node.model.VectorMetricInfo
|
||||
import org.meshtastic.proto.Config
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.node.component
|
||||
|
||||
import android.content.ClipData
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.Clipboard
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.copy
|
||||
import org.meshtastic.core.ui.util.thenIf
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun InfoCard(
|
||||
text: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector? = null,
|
||||
iconRes: DrawableResource? = null,
|
||||
rotateIcon: Float = 0f,
|
||||
) {
|
||||
val clipboard: Clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
val copyLabel = stringResource(Res.string.copy)
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.clip(shape)
|
||||
.combinedClickable(
|
||||
onLongClick = {
|
||||
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, value))) }
|
||||
},
|
||||
onLongClickLabel = copyLabel,
|
||||
onClick = {},
|
||||
role = Role.Button,
|
||||
)
|
||||
.semantics(mergeDescendants = true) { contentDescription = "$text: $value" },
|
||||
shape = shape,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val iconModifier = Modifier.size(20.dp).thenIf(rotateIcon != 0f) { rotate(rotateIcon) }
|
||||
val iconTint = MaterialTheme.colorScheme.primary
|
||||
if (icon != null) {
|
||||
Icon(imageVector = icon, contentDescription = null, modifier = iconModifier, tint = iconTint)
|
||||
}
|
||||
if (iconRes != null) {
|
||||
Icon(
|
||||
painter = painterResource(iconRes),
|
||||
contentDescription = null,
|
||||
modifier = iconModifier,
|
||||
tint = iconTint,
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.labelLargeEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DrawableInfoCard(iconRes: DrawableResource, text: String, value: String, rotateIcon: Float = 0f) {
|
||||
InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
|
||||
}
|
||||
|
|
@ -1,93 +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 org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Navigation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(name = "Wind Dir -359°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirectionn359() {
|
||||
PreviewWindDirectionItem(-359f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 0°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection0() {
|
||||
PreviewWindDirectionItem(0f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 45°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection45() {
|
||||
PreviewWindDirectionItem(45f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 90°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection90() {
|
||||
PreviewWindDirectionItem(90f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 180°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection180() {
|
||||
PreviewWindDirectionItem(180f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 225°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection225() {
|
||||
PreviewWindDirectionItem(225f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 270°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection270() {
|
||||
PreviewWindDirectionItem(270f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir 315°")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirection315() {
|
||||
PreviewWindDirectionItem(315f)
|
||||
}
|
||||
|
||||
@Preview(name = "Wind Dir -45")
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirectionN45() {
|
||||
PreviewWindDirectionItem(-45f)
|
||||
}
|
||||
|
||||
@Suppress("detekt:MagicNumber")
|
||||
@Composable
|
||||
private fun PreviewWindDirectionItem(windDirection: Float, windSpeed: String = "5 m/s") {
|
||||
val normalizedBearing = (windDirection + 180) % 360
|
||||
InfoCard(icon = Icons.Outlined.Navigation, text = "Wind", value = windSpeed, rotateIcon = normalizedBearing)
|
||||
}
|
||||
|
|
@ -1,510 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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/>.
|
||||
*/
|
||||
@file:Suppress("MagicNumber")
|
||||
|
||||
package org.meshtastic.feature.node.component
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.Notes
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.isUnmessageableRole
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.air_utilization
|
||||
import org.meshtastic.core.resources.channel_utilization
|
||||
import org.meshtastic.core.resources.current
|
||||
import org.meshtastic.core.resources.elevation_suffix
|
||||
import org.meshtastic.core.resources.signal_quality
|
||||
import org.meshtastic.core.resources.unknown_username
|
||||
import org.meshtastic.core.resources.voltage
|
||||
import org.meshtastic.core.ui.component.AirQualityInfo
|
||||
import org.meshtastic.core.ui.component.ChannelInfo
|
||||
import org.meshtastic.core.ui.component.DistanceInfo
|
||||
import org.meshtastic.core.ui.component.ElevationInfo
|
||||
import org.meshtastic.core.ui.component.HardwareInfo
|
||||
import org.meshtastic.core.ui.component.HopsInfo
|
||||
import org.meshtastic.core.ui.component.HumidityInfo
|
||||
import org.meshtastic.core.ui.component.IconInfo
|
||||
import org.meshtastic.core.ui.component.LastHeardInfo
|
||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.component.NodeIdInfo
|
||||
import org.meshtastic.core.ui.component.NodeKeyStatusIcon
|
||||
import org.meshtastic.core.ui.component.PaxcountInfo
|
||||
import org.meshtastic.core.ui.component.PowerInfo
|
||||
import org.meshtastic.core.ui.component.PressureInfo
|
||||
import org.meshtastic.core.ui.component.RoleInfo
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.SatelliteCountInfo
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.component.SoilMoistureInfo
|
||||
import org.meshtastic.core.ui.component.SoilTemperatureInfo
|
||||
import org.meshtastic.core.ui.component.TemperatureInfo
|
||||
import org.meshtastic.core.ui.component.TransportIcon
|
||||
import org.meshtastic.core.ui.component.determineSignalQuality
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.icon.AirUtilization
|
||||
import org.meshtastic.core.ui.icon.ChannelUtilization
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.proto.Config
|
||||
|
||||
private const val ACTIVE_ALPHA = 0.5f
|
||||
private const val INACTIVE_ALPHA = 0.2f
|
||||
private const val GRID_COLUMNS = 3
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
@Suppress("LongMethod")
|
||||
fun NodeItem(
|
||||
thisNode: Node?,
|
||||
thatNode: Node,
|
||||
distanceUnits: Int,
|
||||
tempInFahrenheit: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
connectionState: ConnectionState,
|
||||
isActive: Boolean = false,
|
||||
) {
|
||||
val isFavorite = remember(thatNode) { thatNode.isFavorite }
|
||||
val isMuted = remember(thatNode) { thatNode.isMuted }
|
||||
val isIgnored = thatNode.isIgnored
|
||||
val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) }
|
||||
|
||||
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
|
||||
val system =
|
||||
remember(distanceUnits) {
|
||||
Config.DisplayConfig.DisplayUnits.fromValue(distanceUnits) ?: Config.DisplayConfig.DisplayUnits.METRIC
|
||||
}
|
||||
val distance =
|
||||
remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
|
||||
|
||||
var contentColor = MaterialTheme.colorScheme.onSurface
|
||||
val cardColors =
|
||||
if (isThisNode) {
|
||||
thisNode?.colors?.second
|
||||
} else {
|
||||
thatNode.colors.second
|
||||
}
|
||||
?.let {
|
||||
val alpha = if (isActive) ACTIVE_ALPHA else INACTIVE_ALPHA
|
||||
val containerColor = Color(it).copy(alpha = alpha)
|
||||
contentColor = contentColorFor(containerColor)
|
||||
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
|
||||
} ?: (CardDefaults.cardColors())
|
||||
|
||||
val style =
|
||||
if (thatNode.isUnknownUser) {
|
||||
FontStyle.Italic
|
||||
} else {
|
||||
FontStyle.Normal
|
||||
}
|
||||
|
||||
val unmessageable =
|
||||
remember(thatNode) {
|
||||
when {
|
||||
thatNode.user.is_unmessagable != null -> thatNode.user.is_unmessagable!!
|
||||
else -> thatNode.user.role.isUnmessageableRole()
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = modifier.fillMaxWidth(), colors = cardColors) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NodeItemHeader(
|
||||
thatNode = thatNode,
|
||||
isThisNode = isThisNode,
|
||||
longName = originalLongName,
|
||||
style = style,
|
||||
isIgnored = isIgnored,
|
||||
isFavorite = isFavorite,
|
||||
isMuted = isMuted,
|
||||
isUnmessageable = unmessageable,
|
||||
connectionState = connectionState,
|
||||
contentColor = contentColor,
|
||||
)
|
||||
|
||||
thatNode.nodeStatus?.let { status ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.Notes,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = contentColor.copy(alpha = 0.7f),
|
||||
)
|
||||
Text(
|
||||
text = status,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NodeBatteryPositionRow(
|
||||
thatNode = thatNode,
|
||||
distance = distance,
|
||||
system = system,
|
||||
contentColor = contentColor,
|
||||
)
|
||||
|
||||
NodeSignalRow(thatNode = thatNode, isThisNode = isThisNode, contentColor = contentColor)
|
||||
|
||||
val sensorItems = gatherSensors(thatNode, tempInFahrenheit, contentColor)
|
||||
if (sensorItems.isNotEmpty()) {
|
||||
MetricsGrid(sensorItems)
|
||||
}
|
||||
|
||||
NodeItemFooter(thatNode = thatNode, contentColor = contentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeBatteryPositionRow(
|
||||
thatNode: Node,
|
||||
distance: String?,
|
||||
system: Config.DisplayConfig.DisplayUnits,
|
||||
contentColor: Color,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MaterialBatteryInfo(
|
||||
level = thatNode.batteryLevel ?: 0,
|
||||
voltage = thatNode.voltage ?: 0f,
|
||||
contentColor = contentColor,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (distance != null) {
|
||||
DistanceInfo(distance = distance, contentColor = contentColor)
|
||||
}
|
||||
thatNode.validPosition?.let { position ->
|
||||
ElevationInfo(
|
||||
altitude = position.altitude ?: 0,
|
||||
system = system,
|
||||
suffix = stringResource(Res.string.elevation_suffix),
|
||||
contentColor = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Color) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isThisNode) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconInfo(
|
||||
icon = MeshtasticIcons.ChannelUtilization,
|
||||
contentDescription = stringResource(Res.string.channel_utilization),
|
||||
label = stringResource(Res.string.channel_utilization),
|
||||
text = "%.1f%%".format(thatNode.deviceMetrics.channel_utilization),
|
||||
contentColor = contentColor,
|
||||
)
|
||||
IconInfo(
|
||||
icon = MeshtasticIcons.AirUtilization,
|
||||
contentDescription = stringResource(Res.string.air_utilization),
|
||||
label = stringResource(Res.string.air_utilization),
|
||||
text = "%.1f%%".format(thatNode.deviceMetrics.air_util_tx),
|
||||
contentColor = contentColor,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (thatNode.hopsAway > 0) {
|
||||
HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor)
|
||||
} else if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (thatNode.snr < 100f) Snr(thatNode.snr)
|
||||
if (thatNode.rssi < 0) Rssi(thatNode.rssi)
|
||||
if (thatNode.snr < 100f && thatNode.rssi < 0) {
|
||||
val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
|
||||
IconInfo(
|
||||
icon = quality.imageVector,
|
||||
contentDescription = stringResource(Res.string.signal_quality),
|
||||
contentColor = quality.color.invoke(),
|
||||
text = stringResource(quality.nameRes),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (thatNode.channel > 0) {
|
||||
ChannelInfo(channel = thatNode.channel, contentColor = contentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val satCount = thatNode.validPosition?.sats_in_view ?: 0
|
||||
if (satCount > 0) {
|
||||
SatelliteCountInfo(satCount = satCount, contentColor = contentColor)
|
||||
} else {
|
||||
Spacer(Modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: Color): List<@Composable () -> Unit> {
|
||||
val items = mutableListOf<@Composable () -> Unit>()
|
||||
val env = node.environmentMetrics
|
||||
val pax = node.paxcounter
|
||||
|
||||
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
|
||||
items.add { PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor) }
|
||||
}
|
||||
if ((env.temperature ?: 0f) != 0f) {
|
||||
val temp =
|
||||
if (tempInFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
|
||||
} else {
|
||||
"%.1f°C".format(env.temperature ?: 0f)
|
||||
}
|
||||
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
|
||||
}
|
||||
if ((env.relative_humidity ?: 0f) != 0f) {
|
||||
items.add { HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) }
|
||||
}
|
||||
if ((env.barometric_pressure ?: 0f) != 0f) {
|
||||
items.add {
|
||||
PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
|
||||
}
|
||||
}
|
||||
if ((env.soil_temperature ?: 0f) != 0f) {
|
||||
val temp =
|
||||
if (tempInFahrenheit) {
|
||||
"%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
|
||||
} else {
|
||||
"%.1f°C".format(env.soil_temperature ?: 0f)
|
||||
}
|
||||
items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
|
||||
}
|
||||
if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) {
|
||||
items.add { SoilMoistureInfo(moisture = "${env.soil_moisture}%", contentColor = contentColor) }
|
||||
}
|
||||
if ((env.voltage ?: 0f) != 0f) {
|
||||
items.add {
|
||||
PowerInfo(
|
||||
value = "%.2fV".format(env.voltage ?: 0f),
|
||||
label = stringResource(Res.string.voltage),
|
||||
contentColor = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
if ((env.current ?: 0f) != 0f) {
|
||||
items.add {
|
||||
PowerInfo(
|
||||
value = "%.1fmA".format(env.current ?: 0f),
|
||||
label = stringResource(Res.string.current),
|
||||
contentColor = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
if ((env.iaq ?: 0) != 0) {
|
||||
items.add { AirQualityInfo(iaq = "${env.iaq}", contentColor = contentColor) }
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun MetricsGrid(items: List<@Composable () -> Unit>) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxItemsInEachRow = GRID_COLUMNS,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
val remainder = items.size % GRID_COLUMNS
|
||||
items.forEach { item -> Box(Modifier.weight(1f)) { item() } }
|
||||
if (remainder != 0) {
|
||||
repeat(GRID_COLUMNS - remainder) { Spacer(Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun NodeItemHeader(
|
||||
thatNode: Node,
|
||||
isThisNode: Boolean,
|
||||
longName: String,
|
||||
style: FontStyle,
|
||||
isIgnored: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isMuted: Boolean,
|
||||
isUnmessageable: Boolean,
|
||||
connectionState: ConnectionState,
|
||||
contentColor: Color,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NodeChip(node = thatNode)
|
||||
|
||||
NodeKeyStatusIcon(
|
||||
hasPKC = thatNode.hasPKC,
|
||||
mismatchKey = thatNode.mismatchKey,
|
||||
publicKey = thatNode.user.public_key,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = longName,
|
||||
style = MaterialTheme.typography.titleMediumEmphasized.copy(fontStyle = style),
|
||||
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
TransportIcon(
|
||||
transport = thatNode.lastTransport,
|
||||
viaMqtt = thatNode.viaMqtt,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
LastHeardInfo(lastHeard = thatNode.lastHeard, showLabel = false, contentColor = contentColor)
|
||||
}
|
||||
|
||||
NodeStatusIcons(
|
||||
isThisNode = isThisNode,
|
||||
isFavorite = isFavorite,
|
||||
isMuted = isMuted,
|
||||
isUnmessageable = isUnmessageable,
|
||||
connectionState = connectionState,
|
||||
contentColor = contentColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HardwareInfo(hwModel = thatNode.user.hw_model.name, contentColor = contentColor)
|
||||
RoleInfo(role = thatNode.user.role, contentColor = contentColor)
|
||||
NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
fun NodeInfoSimplePreview() {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode = NodePreviewParameterProvider().values.last().copy(lastHeard = 0)
|
||||
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
fun NodeInfoStatusPreview() {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode =
|
||||
NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
|
||||
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
fun NodeInfoSignalPreview() {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode = NodePreviewParameterProvider().values.last().copy(hopsAway = 0, snr = 5.5f, rssi = -100)
|
||||
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
connectionState = ConnectionState.Connected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025-2026 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 org.meshtastic.feature.node.component
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.ConnectionState
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.connected
|
||||
import org.meshtastic.core.resources.connecting
|
||||
import org.meshtastic.core.resources.device_sleeping
|
||||
import org.meshtastic.core.resources.disconnected
|
||||
import org.meshtastic.core.resources.favorite
|
||||
import org.meshtastic.core.resources.mute_always
|
||||
import org.meshtastic.core.resources.unmessageable
|
||||
import org.meshtastic.core.resources.unmonitored_or_infrastructure
|
||||
import org.meshtastic.core.ui.icon.CloudDone
|
||||
import org.meshtastic.core.ui.icon.CloudOffTwoTone
|
||||
import org.meshtastic.core.ui.icon.CloudSync
|
||||
import org.meshtastic.core.ui.icon.CloudTwoTone
|
||||
import org.meshtastic.core.ui.icon.Favorite
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
import org.meshtastic.core.ui.icon.Unmessageable
|
||||
import org.meshtastic.core.ui.icon.VolumeOff
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NodeStatusIcons(
|
||||
isThisNode: Boolean,
|
||||
isUnmessageable: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isMuted: Boolean,
|
||||
connectionState: ConnectionState,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = LocalContentColor.current,
|
||||
) {
|
||||
Row(modifier = modifier.padding(4.dp)) {
|
||||
if (isThisNode) {
|
||||
ThisNodeStatusBadge(connectionState)
|
||||
}
|
||||
|
||||
if (isUnmessageable) {
|
||||
StatusBadge(
|
||||
imageVector = MeshtasticIcons.Unmessageable,
|
||||
contentDescription = Res.string.unmessageable,
|
||||
tooltipText = Res.string.unmonitored_or_infrastructure,
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
if (isMuted && !isThisNode) {
|
||||
StatusBadge(
|
||||
imageVector = MeshtasticIcons.VolumeOff,
|
||||
contentDescription = Res.string.mute_always,
|
||||
tooltipText = Res.string.mute_always,
|
||||
tint = contentColor,
|
||||
)
|
||||
}
|
||||
if (isFavorite && !isThisNode) {
|
||||
StatusBadge(
|
||||
imageVector = MeshtasticIcons.Favorite,
|
||||
contentDescription = Res.string.favorite,
|
||||
tooltipText = Res.string.favorite,
|
||||
tint = MaterialTheme.colorScheme.StatusYellow,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ThisNodeStatusBadge(connectionState: ConnectionState) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
stringResource(
|
||||
when (connectionState) {
|
||||
ConnectionState.Connected -> Res.string.connected
|
||||
ConnectionState.Connecting -> Res.string.connecting
|
||||
ConnectionState.Disconnected -> Res.string.disconnected
|
||||
ConnectionState.DeviceSleep -> Res.string.device_sleeping
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
when (connectionState) {
|
||||
ConnectionState.Connected -> ConnectedStatusIcon()
|
||||
ConnectionState.Connecting -> ConnectingStatusIcon()
|
||||
ConnectionState.Disconnected -> DisconnectedStatusIcon()
|
||||
ConnectionState.DeviceSleep -> DeviceSleepStatusIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedStatusIcon() {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.CloudDone,
|
||||
contentDescription = stringResource(Res.string.connected),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.StatusGreen,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectingStatusIcon() {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.CloudSync,
|
||||
contentDescription = stringResource(Res.string.connecting),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.StatusOrange,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DisconnectedStatusIcon() {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.CloudOffTwoTone,
|
||||
contentDescription = stringResource(Res.string.disconnected),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceSleepStatusIcon() {
|
||||
Icon(
|
||||
imageVector = MeshtasticIcons.CloudTwoTone,
|
||||
contentDescription = stringResource(Res.string.device_sleeping),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.StatusYellow,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun StatusBadge(
|
||||
imageVector: ImageVector,
|
||||
contentDescription: StringResource,
|
||||
tooltipText: StringResource,
|
||||
tint: Color = LocalContentColor.current,
|
||||
) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = { PlainTooltip { Text(stringResource(tooltipText)) } },
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = stringResource(contentDescription),
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun StatusIconsPreview() {
|
||||
NodeStatusIcons(
|
||||
isThisNode = true,
|
||||
isUnmessageable = true,
|
||||
isFavorite = true,
|
||||
isMuted = true,
|
||||
connectionState = ConnectionState.Connected,
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import org.meshtastic.core.resources.Res
|
|||
import org.meshtastic.core.resources.channel_1
|
||||
import org.meshtastic.core.resources.channel_2
|
||||
import org.meshtastic.core.resources.channel_3
|
||||
import org.meshtastic.core.ui.component.InfoCard
|
||||
import org.meshtastic.feature.node.model.VectorMetricInfo
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -55,6 +55,9 @@ import org.meshtastic.core.resources.request_air_quality_metrics
|
|||
import org.meshtastic.core.resources.request_telemetry
|
||||
import org.meshtastic.core.resources.telemetry
|
||||
import org.meshtastic.core.resources.userinfo
|
||||
import org.meshtastic.core.ui.component.COOL_DOWN_TIME_MS
|
||||
import org.meshtastic.core.ui.component.CooldownOutlinedIconButton
|
||||
import org.meshtastic.core.ui.component.REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS
|
||||
import org.meshtastic.core.ui.icon.AirQuality
|
||||
import org.meshtastic.core.ui.icon.LineAxis
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
|
|||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.util.showToast
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
import org.meshtastic.core.ui.component.NodeItem
|
||||
import org.meshtastic.proto.SharedContact
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ 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.core.ui.util.annotateNeighborInfo
|
||||
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||
import org.meshtastic.core.ui.component.CooldownIconButton
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
|||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.core.ui.util.annotateTraceroute
|
||||
import org.meshtastic.feature.map.model.TracerouteOverlay
|
||||
import org.meshtastic.feature.node.component.CooldownIconButton
|
||||
import org.meshtastic.core.ui.component.CooldownIconButton
|
||||
import org.meshtastic.feature.node.detail.NodeRequestEffect
|
||||
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
|
||||
import org.meshtastic.proto.RouteDiscovery
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue