mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
378 lines
15 KiB
Kotlin
378 lines
15 KiB
Kotlin
/*
|
|
* Copyright (c) 2024 Meshtastic LLC
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package com.geeksville.mesh.ui
|
|
|
|
import androidx.compose.animation.animateColorAsState
|
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
|
import androidx.compose.animation.core.RepeatMode
|
|
import androidx.compose.animation.core.repeatable
|
|
import androidx.compose.animation.core.tween
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.IntrinsicSize
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.defaultMinSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.size
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.layout.wrapContentSize
|
|
import androidx.compose.material.Card
|
|
import androidx.compose.material.Chip
|
|
import androidx.compose.material.ChipDefaults
|
|
import androidx.compose.material.Divider
|
|
import androidx.compose.material.ExperimentalMaterialApi
|
|
import androidx.compose.material.LocalTextStyle
|
|
import androidx.compose.material.MaterialTheme
|
|
import androidx.compose.material.Surface
|
|
import androidx.compose.material.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.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.res.stringResource
|
|
import androidx.compose.ui.text.font.FontStyle
|
|
import androidx.compose.ui.text.font.FontWeight
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.text.style.TextDecoration
|
|
import androidx.compose.ui.tooling.preview.Preview
|
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
|
import androidx.compose.ui.unit.dp
|
|
import com.geeksville.mesh.ConfigProtos.Config.DeviceConfig
|
|
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig
|
|
import com.geeksville.mesh.MeshProtos
|
|
import com.geeksville.mesh.R
|
|
import com.geeksville.mesh.database.entity.NodeEntity
|
|
import com.geeksville.mesh.ui.components.MenuItemAction
|
|
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
|
|
import com.geeksville.mesh.ui.components.NodeMenu
|
|
import com.geeksville.mesh.ui.components.SimpleAlertDialog
|
|
import com.geeksville.mesh.ui.compose.ElevationInfo
|
|
import com.geeksville.mesh.ui.compose.SatelliteCountInfo
|
|
import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider
|
|
import com.geeksville.mesh.ui.theme.AppTheme
|
|
import com.geeksville.mesh.util.toDistanceString
|
|
|
|
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
|
@OptIn(ExperimentalMaterialApi::class)
|
|
@Composable
|
|
fun NodeItem(
|
|
thisNode: NodeEntity?,
|
|
thatNode: NodeEntity,
|
|
gpsFormat: Int,
|
|
distanceUnits: Int,
|
|
tempInFahrenheit: Boolean,
|
|
ignoreIncomingList: List<Int> = emptyList(),
|
|
menuItemActionClicked: (MenuItemAction) -> Unit = {},
|
|
blinking: Boolean = false,
|
|
expanded: Boolean = false,
|
|
currentTimeMillis: Long,
|
|
isConnected: Boolean = false,
|
|
) {
|
|
val isIgnored = ignoreIncomingList.contains(thatNode.num)
|
|
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
|
|
|
|
val isThisNode = thisNode?.num == thatNode.num
|
|
val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) }
|
|
val distance = remember(thisNode, thatNode) {
|
|
thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system)
|
|
}
|
|
val (textColor, nodeColor) = thatNode.colors
|
|
|
|
val hwInfoString = when (val hwModel = thatNode.user.hwModel) {
|
|
MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name
|
|
else -> hwModel.name.replace('_', '-').replace('p', '.').lowercase()
|
|
}
|
|
val roleName = if (thatNode.isUnknownUser) {
|
|
DeviceConfig.Role.UNRECOGNIZED.name
|
|
} else {
|
|
thatNode.user.role.name
|
|
}
|
|
|
|
val bgColor by animateColorAsState(
|
|
targetValue = if (blinking) Color(color = 0x33FFFFFF) else Color.Transparent,
|
|
animationSpec = repeatable(
|
|
iterations = 6,
|
|
animation = tween(durationMillis = 250, easing = FastOutSlowInEasing),
|
|
repeatMode = RepeatMode.Reverse
|
|
),
|
|
label = "blinking node"
|
|
)
|
|
|
|
val style = if (thatNode.isUnknownUser) {
|
|
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
|
|
} else {
|
|
LocalTextStyle.current
|
|
}
|
|
|
|
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
|
|
|
|
var showEncryptionDialog by remember { mutableStateOf(false) }
|
|
if (showEncryptionDialog) {
|
|
val (title, text) = when {
|
|
thatNode.mismatchKey -> R.string.encryption_error to R.string.encryption_error_text
|
|
thatNode.hasPKC -> R.string.encryption_pkc to R.string.encryption_pkc_text
|
|
else -> R.string.encryption_psk to R.string.encryption_psk_text
|
|
}
|
|
SimpleAlertDialog(title, text) { showEncryptionDialog = false }
|
|
}
|
|
|
|
Card(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
.defaultMinSize(minHeight = 80.dp),
|
|
elevation = 4.dp,
|
|
onClick = { showDetails(!detailsShown) },
|
|
) {
|
|
Surface {
|
|
Column(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.padding(8.dp)
|
|
.background(bgColor),
|
|
) {
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth(),
|
|
verticalAlignment = Alignment.CenterVertically,
|
|
) {
|
|
var menuExpanded by remember { mutableStateOf(false) }
|
|
Box(
|
|
modifier = Modifier.wrapContentSize(Alignment.TopStart),
|
|
) {
|
|
Chip(
|
|
modifier = Modifier
|
|
.width(IntrinsicSize.Min)
|
|
.defaultMinSize(minHeight = 32.dp, minWidth = 72.dp),
|
|
colors = ChipDefaults.chipColors(
|
|
backgroundColor = Color(nodeColor),
|
|
contentColor = Color(textColor),
|
|
),
|
|
onClick = {
|
|
menuExpanded = !menuExpanded
|
|
},
|
|
) {
|
|
Text(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
text = thatNode.user.shortName.ifEmpty { "???" },
|
|
fontWeight = FontWeight.Normal,
|
|
fontSize = MaterialTheme.typography.button.fontSize,
|
|
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
|
textAlign = TextAlign.Center,
|
|
)
|
|
}
|
|
NodeMenu(
|
|
node = thatNode,
|
|
ignoreIncomingList = ignoreIncomingList,
|
|
isThisNode = isThisNode,
|
|
onMenuItemAction = menuItemActionClicked,
|
|
expanded = menuExpanded,
|
|
onDismissRequest = { menuExpanded = false },
|
|
isConnected = isConnected,
|
|
)
|
|
}
|
|
NodeKeyStatusIcon(
|
|
hasPKC = thatNode.hasPKC,
|
|
mismatchKey = thatNode.mismatchKey,
|
|
modifier = Modifier.size(32.dp)
|
|
) { showEncryptionDialog = true }
|
|
Text(
|
|
modifier = Modifier.weight(1f),
|
|
text = longName,
|
|
style = style,
|
|
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
|
softWrap = true,
|
|
)
|
|
|
|
LastHeardInfo(
|
|
lastHeard = thatNode.lastHeard,
|
|
currentTimeMillis = currentTimeMillis
|
|
)
|
|
}
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
) {
|
|
if (distance != null) {
|
|
Text(
|
|
text = distance,
|
|
fontSize = MaterialTheme.typography.button.fontSize,
|
|
)
|
|
} else {
|
|
Spacer(modifier = Modifier.width(16.dp))
|
|
}
|
|
BatteryInfo(
|
|
batteryLevel = thatNode.batteryLevel,
|
|
voltage = thatNode.voltage
|
|
)
|
|
}
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
) {
|
|
signalInfo(
|
|
node = thatNode,
|
|
isThisNode = isThisNode
|
|
)
|
|
thatNode.validPosition?.let { position ->
|
|
val satCount = position.satsInView
|
|
if (satCount > 0) {
|
|
SatelliteCountInfo(satCount = satCount)
|
|
}
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
) {
|
|
val telemetryString = thatNode.getTelemetryString(tempInFahrenheit)
|
|
if (telemetryString.isNotEmpty()) {
|
|
Text(
|
|
text = telemetryString,
|
|
color = MaterialTheme.colors.onSurface,
|
|
fontSize = MaterialTheme.typography.button.fontSize,
|
|
)
|
|
}
|
|
}
|
|
|
|
if (detailsShown || expanded) {
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Divider()
|
|
Spacer(modifier = Modifier.height(8.dp))
|
|
Row(
|
|
modifier = Modifier
|
|
.fillMaxWidth(),
|
|
horizontalArrangement = Arrangement.SpaceBetween,
|
|
) {
|
|
thatNode.validPosition?.let {
|
|
LinkedCoordinates(
|
|
latitude = thatNode.latitude,
|
|
longitude = thatNode.longitude,
|
|
format = gpsFormat,
|
|
nodeName = longName
|
|
)
|
|
}
|
|
thatNode.validPosition?.let { position ->
|
|
ElevationInfo(
|
|
altitude = position.altitude,
|
|
system = system,
|
|
suffix = stringResource(id = R.string.elevation_suffix)
|
|
)
|
|
}
|
|
}
|
|
Spacer(modifier = Modifier.height(4.dp))
|
|
Row(
|
|
modifier = Modifier.fillMaxWidth(),
|
|
) {
|
|
Text(
|
|
modifier = Modifier.weight(1f),
|
|
text = hwInfoString,
|
|
fontSize = MaterialTheme.typography.button.fontSize,
|
|
style = style,
|
|
)
|
|
Text(
|
|
modifier = Modifier.weight(1f),
|
|
text = roleName,
|
|
textAlign = TextAlign.Center,
|
|
fontSize = MaterialTheme.typography.button.fontSize,
|
|
style = style,
|
|
)
|
|
Text(
|
|
modifier = Modifier.weight(1f),
|
|
text = thatNode.user.id.ifEmpty { "???" },
|
|
textAlign = TextAlign.End,
|
|
fontSize = MaterialTheme.typography.button.fontSize,
|
|
style = style,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
@Preview(showBackground = false)
|
|
fun NodeInfoSimplePreview() {
|
|
AppTheme {
|
|
val thisNode = NodeEntityPreviewParameterProvider().values.first()
|
|
val thatNode = NodeEntityPreviewParameterProvider().values.last()
|
|
NodeItem(
|
|
thisNode = thisNode,
|
|
thatNode = thatNode,
|
|
1,
|
|
0,
|
|
true,
|
|
currentTimeMillis = System.currentTimeMillis()
|
|
)
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
@Preview(
|
|
showBackground = true,
|
|
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
|
|
)
|
|
fun NodeInfoPreview(
|
|
@PreviewParameter(NodeEntityPreviewParameterProvider::class)
|
|
thatNode: NodeEntity
|
|
) {
|
|
AppTheme {
|
|
val thisNode = NodeEntityPreviewParameterProvider().values.first()
|
|
Column {
|
|
Text(
|
|
text = "Details Collapsed",
|
|
color = MaterialTheme.colors.onBackground
|
|
)
|
|
NodeItem(
|
|
thisNode = thisNode,
|
|
thatNode = thatNode,
|
|
gpsFormat = 0,
|
|
distanceUnits = 1,
|
|
tempInFahrenheit = true,
|
|
expanded = false,
|
|
currentTimeMillis = System.currentTimeMillis()
|
|
)
|
|
Text(
|
|
text = "Details Shown",
|
|
color = MaterialTheme.colors.onBackground
|
|
)
|
|
NodeItem(
|
|
thisNode = thisNode,
|
|
thatNode = thatNode,
|
|
gpsFormat = 0,
|
|
distanceUnits = 1,
|
|
tempInFahrenheit = true,
|
|
expanded = true,
|
|
currentTimeMillis = System.currentTimeMillis()
|
|
)
|
|
}
|
|
}
|
|
}
|