mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Refactor: organize ui screens to separate packages (#1982)
This commit is contained in:
parent
32d9f29d7e
commit
ad1897c564
108 changed files with 475 additions and 569 deletions
904
app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt
Normal file
904
app/src/main/java/com/geeksville/mesh/ui/node/NodeDetail.kt
Normal file
|
|
@ -0,0 +1,904 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.VolumeUp
|
||||
import androidx.compose.material.icons.automirrored.outlined.VolumeMute
|
||||
import androidx.compose.material.icons.filled.Air
|
||||
import androidx.compose.material.icons.filled.BlurOn
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ChargingStation
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Height
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.KeyOff
|
||||
import androidx.compose.material.icons.filled.LightMode
|
||||
import androidx.compose.material.icons.filled.LocationOn
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.Memory
|
||||
import androidx.compose.material.icons.filled.Numbers
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material.icons.filled.Power
|
||||
import androidx.compose.material.icons.filled.Route
|
||||
import androidx.compose.material.icons.filled.Router
|
||||
import androidx.compose.material.icons.filled.Scale
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.SignalCellularAlt
|
||||
import androidx.compose.material.icons.filled.Speed
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.StarBorder
|
||||
import androidx.compose.material.icons.filled.Thermostat
|
||||
import androidx.compose.material.icons.filled.WaterDrop
|
||||
import androidx.compose.material.icons.filled.Work
|
||||
import androidx.compose.material.icons.outlined.Navigation
|
||||
import androidx.compose.material.icons.outlined.NoCell
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.twotone.Person
|
||||
import androidx.compose.material.icons.twotone.Verified
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ErrorResult
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.SuccessResult
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.model.DeviceHardware
|
||||
import com.geeksville.mesh.model.MetricsState
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.isUnmessageableRole
|
||||
import com.geeksville.mesh.navigation.Route
|
||||
import com.geeksville.mesh.service.ServiceAction
|
||||
import com.geeksville.mesh.ui.common.components.PreferenceCategory
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.radioconfig.NavCard
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import com.geeksville.mesh.util.UnitConversions.calculateDewPoint
|
||||
import com.geeksville.mesh.util.UnitConversions.toTempString
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
import com.geeksville.mesh.util.formatUptime
|
||||
import com.geeksville.mesh.util.thenIf
|
||||
import com.geeksville.mesh.util.toSpeedString
|
||||
|
||||
private enum class LogsType(
|
||||
val titleRes: Int,
|
||||
val icon: ImageVector,
|
||||
val route: Route
|
||||
) {
|
||||
DEVICE(R.string.device_metrics_log, Icons.Default.ChargingStation, Route.DeviceMetrics),
|
||||
NODE_MAP(R.string.node_map, Icons.Default.Map, Route.NodeMap),
|
||||
POSITIONS(R.string.position_log, Icons.Default.LocationOn, Route.PositionLog),
|
||||
ENVIRONMENT(R.string.env_metrics_log, Icons.Default.Thermostat, Route.EnvironmentMetrics),
|
||||
SIGNAL(R.string.sig_metrics_log, Icons.Default.SignalCellularAlt, Route.SignalMetrics),
|
||||
POWER(R.string.power_metrics_log, Icons.Default.Power, Route.PowerMetrics),
|
||||
TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog),
|
||||
HOST(R.string.host_metrics_log, Icons.Default.Memory, Route.HostMetricsLog),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeDetailScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
uiViewModel: UIViewModel = hiltViewModel(),
|
||||
onNavigate: (Route) -> Unit = {},
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val environmentState by viewModel.environmentState.collectAsStateWithLifecycle()
|
||||
|
||||
/* The order is with respect to the enum above: LogsType */
|
||||
val availabilities = remember(key1 = state, key2 = environmentState) {
|
||||
booleanArrayOf(
|
||||
state.hasDeviceMetrics(),
|
||||
state.hasPositionLogs(),
|
||||
state.hasPositionLogs(),
|
||||
environmentState.hasEnvironmentMetrics(),
|
||||
state.hasSignalMetrics(),
|
||||
state.hasPowerMetrics(),
|
||||
state.hasTracerouteLogs(),
|
||||
state.hasHostMetrics(),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.node != null) {
|
||||
val node = state.node ?: return
|
||||
uiViewModel.setTitle(node.user.longName)
|
||||
var share by remember { mutableStateOf<Boolean>(false) }
|
||||
if (share) {
|
||||
SharedContactDialog(node) {
|
||||
share = false
|
||||
}
|
||||
}
|
||||
NodeDetailList(
|
||||
node = node,
|
||||
metricsState = state,
|
||||
onAction = { action ->
|
||||
when (action) {
|
||||
is Route -> onNavigate(action)
|
||||
is ServiceAction -> viewModel.onServiceAction(action)
|
||||
is NodeMenuAction -> {
|
||||
uiViewModel.handleNodeMenuAction(action)
|
||||
}
|
||||
|
||||
else -> debug("Unhandled action: $action")
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
metricsAvailability = availabilities,
|
||||
onShared = {
|
||||
share = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun NodeDetailList(
|
||||
modifier: Modifier = Modifier,
|
||||
node: Node,
|
||||
metricsState: MetricsState,
|
||||
onAction: (Any) -> Unit = {},
|
||||
metricsAvailability: BooleanArray,
|
||||
onShared: () -> Unit = {}
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
if (metricsState.deviceHardware != null) {
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.device)) {
|
||||
DeviceDetailsContent(metricsState)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.details)) {
|
||||
NodeDetailsContent(node)
|
||||
}
|
||||
}
|
||||
node.metadata?.firmwareVersion?.let { firmwareVersion ->
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.firmware)) {
|
||||
val latestStableFirmware = metricsState.latestStableFirmware
|
||||
val latestAlphaFirmware = metricsState.latestAlphaFirmware
|
||||
NodeDetailRow(
|
||||
label = "Installed",
|
||||
icon = Icons.Default.Memory,
|
||||
value = firmwareVersion.substringBeforeLast(".")
|
||||
)
|
||||
latestStableFirmware?.let { stable ->
|
||||
NodeDetailRow(
|
||||
label = "Latest stable",
|
||||
icon = Icons.Default.Memory,
|
||||
value = stable.id.substringBeforeLast(".").replace("v", "")
|
||||
)
|
||||
}
|
||||
latestAlphaFirmware?.let { alpha ->
|
||||
NodeDetailRow(
|
||||
label = "Latest alpha",
|
||||
icon = Icons.Default.Memory,
|
||||
value = alpha.id.substringBeforeLast(".").replace("v", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
DeviceActions(
|
||||
isLocal = metricsState.isLocal,
|
||||
node = node,
|
||||
onShared = onShared,
|
||||
onAction = onAction
|
||||
)
|
||||
}
|
||||
|
||||
if (node.hasEnvironmentMetrics) {
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.environment))
|
||||
EnvironmentMetrics(node, metricsState.isFahrenheit)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (node.hasPowerMetrics) {
|
||||
item {
|
||||
PreferenceCategory(stringResource(R.string.power))
|
||||
PowerMetrics(node)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/* Metric Logs Navigation */
|
||||
item {
|
||||
PreferenceCategory(stringResource(id = R.string.logs))
|
||||
for (type in LogsType.entries) {
|
||||
NavCard(
|
||||
title = stringResource(type.titleRes),
|
||||
icon = type.icon,
|
||||
enabled = metricsAvailability[type.ordinal]
|
||||
) {
|
||||
onAction(type.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!metricsState.isManaged) {
|
||||
item {
|
||||
PreferenceCategory(stringResource(id = R.string.administration))
|
||||
NavCard(
|
||||
title = stringResource(id = R.string.remote_admin),
|
||||
icon = Icons.Default.Settings,
|
||||
enabled = true
|
||||
) {
|
||||
onAction(Route.RadioConfig(node.num))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeDetailRow(
|
||||
modifier: Modifier = Modifier,
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
value: String,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = iconTint
|
||||
)
|
||||
Text(label)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(textAlign = TextAlign.End, text = value)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun DeviceActions(
|
||||
isLocal: Boolean = false,
|
||||
node: Node,
|
||||
onShared: () -> Unit,
|
||||
onAction: (Any) -> Unit,
|
||||
) {
|
||||
var displayFavoriteDialog by remember { mutableStateOf(false) }
|
||||
var displayIgnoreDialog by remember { mutableStateOf(false) }
|
||||
var displayRemoveDialog by remember { mutableStateOf(false) }
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayFavoriteDialog,
|
||||
displayIgnoreDialog = displayIgnoreDialog,
|
||||
displayRemoveDialog = displayRemoveDialog,
|
||||
onDismissMenuRequest = {
|
||||
displayFavoriteDialog = false
|
||||
displayIgnoreDialog = false
|
||||
displayRemoveDialog = false
|
||||
},
|
||||
onAction = onAction,
|
||||
)
|
||||
PreferenceCategory(text = stringResource(R.string.actions))
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.share_contact),
|
||||
icon = Icons.Default.Share,
|
||||
enabled = true,
|
||||
onClick = onShared
|
||||
)
|
||||
|
||||
if (!isLocal) {
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.request_metadata),
|
||||
icon = Icons.Default.Memory,
|
||||
enabled = true,
|
||||
onClick = { onAction(ServiceAction.GetDeviceMetadata(node.num)) }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.exchange_position),
|
||||
icon = Icons.Default.LocationOn,
|
||||
enabled = true,
|
||||
onClick = { onAction(NodeMenuAction.RequestPosition(node)) }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.exchange_userinfo),
|
||||
icon = Icons.Default.Person,
|
||||
enabled = true,
|
||||
onClick = { onAction(NodeMenuAction.RequestUserInfo(node)) }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.traceroute),
|
||||
icon = Icons.Default.Route,
|
||||
enabled = true,
|
||||
onClick = { onAction(NodeMenuAction.TraceRoute(node)) }
|
||||
)
|
||||
NodeActionSwitch(
|
||||
title = stringResource(R.string.favorite),
|
||||
icon = if (node.isFavorite) {
|
||||
Icons.Default.Star
|
||||
} else {
|
||||
Icons.Default.StarBorder
|
||||
},
|
||||
iconTint = if (node.isFavorite) {
|
||||
Color.Yellow
|
||||
} else {
|
||||
LocalContentColor.current
|
||||
},
|
||||
enabled = true,
|
||||
checked = node.isFavorite,
|
||||
onClick = { displayFavoriteDialog = true }
|
||||
)
|
||||
NodeActionSwitch(
|
||||
title = stringResource(R.string.ignore),
|
||||
icon = if (node.isIgnored) {
|
||||
Icons.AutoMirrored.Outlined.VolumeMute
|
||||
} else {
|
||||
Icons.AutoMirrored.Default.VolumeUp
|
||||
},
|
||||
enabled = true,
|
||||
checked = node.isIgnored,
|
||||
onClick = { displayIgnoreDialog = true }
|
||||
)
|
||||
NodeActionButton(
|
||||
title = stringResource(id = R.string.remove),
|
||||
icon = Icons.Default.Delete,
|
||||
enabled = true,
|
||||
onClick = { displayRemoveDialog = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceDetailsContent(
|
||||
state: MetricsState
|
||||
) {
|
||||
val node = state.node ?: return
|
||||
val deviceHardware = state.deviceHardware ?: return
|
||||
val hwModelName = deviceHardware.displayName
|
||||
val isSupported = deviceHardware.activelySupported
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.padding(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
color = Color(node.colors.second).copy(alpha = .5f),
|
||||
shape = CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize())
|
||||
}
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.hardware),
|
||||
icon = Icons.Default.Router,
|
||||
value = hwModelName
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = if (isSupported) stringResource(R.string.supported) else "Supported by Community",
|
||||
icon = if (isSupported) Icons.TwoTone.Verified else ImageVector.vectorResource(R.drawable.unverified),
|
||||
value = "",
|
||||
iconTint = if (isSupported) Color.Green else Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeviceHardwareImage(
|
||||
deviceHardware: DeviceHardware,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hwImg =
|
||||
deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0)
|
||||
?: "unknown.svg"
|
||||
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
|
||||
val listener = object : ImageRequest.Listener {
|
||||
override fun onStart(request: ImageRequest) {
|
||||
super.onStart(request)
|
||||
debug("Image request started")
|
||||
}
|
||||
|
||||
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||
super.onError(request, result)
|
||||
debug("Image request failed: ${result.throwable.message}")
|
||||
}
|
||||
|
||||
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
|
||||
super.onSuccess(request, result)
|
||||
debug("Image request succeeded: ${result.dataSource.name}")
|
||||
}
|
||||
}
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.listener(listener)
|
||||
.data(imageUrl)
|
||||
.build(),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = deviceHardware.displayName,
|
||||
placeholder = painterResource(R.drawable.hw_unknown),
|
||||
error = painterResource(R.drawable.hw_unknown),
|
||||
fallback = painterResource(R.drawable.hw_unknown),
|
||||
modifier = modifier
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun NodeDetailsContent(
|
||||
node: Node,
|
||||
) {
|
||||
if (node.mismatchKey) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.KeyOff,
|
||||
contentDescription = stringResource(id = R.string.encryption_error),
|
||||
tint = Color.Red,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.encryption_error),
|
||||
style = MaterialTheme.typography.titleLarge.copy(color = Color.Red),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.encryption_error_text),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.long_name),
|
||||
icon = Icons.TwoTone.Person,
|
||||
value = node.user.longName.ifEmpty { "???" }
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.short_name),
|
||||
icon = Icons.Outlined.Person,
|
||||
value = node.user.shortName.ifEmpty { "???" }
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.node_number),
|
||||
icon = Icons.Default.Numbers,
|
||||
value = node.num.toUInt().toString()
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.user_id),
|
||||
icon = Icons.Default.Person,
|
||||
value = node.user.id
|
||||
)
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.role),
|
||||
icon = Icons.Default.Work,
|
||||
value = node.user.role.name
|
||||
)
|
||||
val unmessageable = if (node.user.hasIsUnmessagable()) {
|
||||
node.user.isUnmessagable
|
||||
} else {
|
||||
node.user.role?.isUnmessageableRole() == true
|
||||
}
|
||||
if (unmessageable) {
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.unmonitored_or_infrastructure),
|
||||
icon = Icons.Outlined.NoCell,
|
||||
value = ""
|
||||
)
|
||||
}
|
||||
if (node.deviceMetrics.uptimeSeconds > 0) {
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.uptime),
|
||||
icon = Icons.Default.CheckCircle,
|
||||
value = formatUptime(node.deviceMetrics.uptimeSeconds)
|
||||
)
|
||||
}
|
||||
NodeDetailRow(
|
||||
label = stringResource(R.string.node_sort_last_heard),
|
||||
icon = Icons.Default.History,
|
||||
value = formatAgo(node.lastHeard)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoCard(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
value: String,
|
||||
rotateIcon: Float = 0f,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(100.dp)
|
||||
.height(100.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.width(100.dp)
|
||||
.height(100.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = text,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.thenIf(rotateIcon != 0f) { rotate(rotateIcon) },
|
||||
)
|
||||
Text(
|
||||
textAlign = TextAlign.Center,
|
||||
text = text,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
private fun EnvironmentMetrics(
|
||||
node: Node,
|
||||
isFahrenheit: Boolean = false,
|
||||
) = with(node.environmentMetrics) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (hasTemperature()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Thermostat,
|
||||
text = stringResource(R.string.temperature),
|
||||
value = temperature.toTempString(isFahrenheit)
|
||||
)
|
||||
}
|
||||
if (hasRelativeHumidity()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.WaterDrop,
|
||||
text = stringResource(R.string.humidity),
|
||||
value = "%.0f%%".format(relativeHumidity)
|
||||
)
|
||||
}
|
||||
if (hasTemperature() && hasRelativeHumidity()) {
|
||||
val dewPoint = calculateDewPoint(temperature, relativeHumidity)
|
||||
InfoCard(
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_outlined_dew_point_24),
|
||||
text = stringResource(R.string.dew_point),
|
||||
value = dewPoint.toTempString(isFahrenheit)
|
||||
)
|
||||
}
|
||||
if (hasBarometricPressure()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Speed,
|
||||
text = stringResource(R.string.pressure),
|
||||
value = "%.0f hPa".format(barometricPressure)
|
||||
)
|
||||
}
|
||||
if (hasGasResistance()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.BlurOn,
|
||||
text = stringResource(R.string.gas_resistance),
|
||||
value = "%.0f MΩ".format(gasResistance)
|
||||
)
|
||||
}
|
||||
if (hasVoltage()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Bolt,
|
||||
text = stringResource(R.string.voltage),
|
||||
value = "%.2fV".format(voltage)
|
||||
)
|
||||
}
|
||||
if (hasCurrent()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Power,
|
||||
text = stringResource(R.string.current),
|
||||
value = "%.1fmA".format(current)
|
||||
)
|
||||
}
|
||||
if (hasIaq()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Air,
|
||||
text = stringResource(R.string.iaq),
|
||||
value = iaq.toString()
|
||||
)
|
||||
}
|
||||
if (hasDistance()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Height,
|
||||
text = stringResource(R.string.distance),
|
||||
value = "%.0f mm".format(distance)
|
||||
)
|
||||
}
|
||||
if (hasLux()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.LightMode,
|
||||
text = stringResource(R.string.lux),
|
||||
value = "%.0f lx".format(lux)
|
||||
)
|
||||
}
|
||||
if (hasWindSpeed()) {
|
||||
@Suppress("MagicNumber")
|
||||
val normalizedBearing = (windDirection % 360 + 360) % 360
|
||||
InfoCard(
|
||||
icon = Icons.Outlined.Navigation,
|
||||
text = stringResource(R.string.wind),
|
||||
value = windSpeed.toSpeedString(),
|
||||
rotateIcon = normalizedBearing.toFloat(),
|
||||
)
|
||||
}
|
||||
if (hasWeight()) {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Scale,
|
||||
text = stringResource(R.string.weight),
|
||||
value = "%.2f kg".format(weight)
|
||||
)
|
||||
}
|
||||
if (hasRadiation()) {
|
||||
InfoCard(
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_filled_radioactive_24),
|
||||
text = stringResource(R.string.radiation),
|
||||
value = "%.1f µR/h".format(radiation)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerMetrics(node: Node) = with(node.powerMetrics) {
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (ch1Voltage != 0f) {
|
||||
Column {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Bolt,
|
||||
text = stringResource(R.string.channel_1),
|
||||
value = "%.2fV".format(ch1Voltage)
|
||||
)
|
||||
InfoCard(
|
||||
icon = Icons.Default.Power,
|
||||
text = stringResource(R.string.channel_1),
|
||||
value = "%.1fmA".format(ch1Current)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (ch2Voltage != 0f) {
|
||||
Column {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Bolt,
|
||||
text = stringResource(R.string.channel_2),
|
||||
value = "%.2fV".format(ch2Voltage)
|
||||
)
|
||||
InfoCard(
|
||||
icon = Icons.Default.Power,
|
||||
text = stringResource(R.string.channel_2),
|
||||
value = "%.1fmA".format(ch2Current)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (ch3Voltage != 0f) {
|
||||
Column {
|
||||
InfoCard(
|
||||
icon = Icons.Default.Bolt,
|
||||
text = stringResource(R.string.channel_3),
|
||||
value = "%.2fV".format(ch3Voltage)
|
||||
)
|
||||
InfoCard(
|
||||
icon = Icons.Default.Power,
|
||||
text = stringResource(R.string.channel_3),
|
||||
value = "%.1fmA".format(ch3Current)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeActionButton(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color? = null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = iconTint ?: LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeActionSwitch(
|
||||
title: String,
|
||||
enabled: Boolean,
|
||||
checked: Boolean,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color? = null,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.height(48.dp)
|
||||
.toggleable(
|
||||
value = checked,
|
||||
enabled = enabled,
|
||||
role = Role.Switch,
|
||||
onValueChange = { onClick() }
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
interactionSource = interactionSource,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp, horizontal = 16.dp)
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = iconTint ?: LocalContentColor.current,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun NodeDetailsPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
node: Node
|
||||
) {
|
||||
AppTheme {
|
||||
NodeDetailList(
|
||||
node = node,
|
||||
metricsState = MetricsState.Empty,
|
||||
metricsAvailability = BooleanArray(LogsType.entries.size) { false },
|
||||
)
|
||||
}
|
||||
}
|
||||
62
app/src/main/java/com/geeksville/mesh/ui/node/NodeMap.kt
Normal file
62
app/src/main/java/com/geeksville/mesh/ui/node/NodeMap.kt
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.map.rememberMapViewWithLifecycle
|
||||
import com.geeksville.mesh.util.addCopyright
|
||||
import com.geeksville.mesh.util.addPolyline
|
||||
import com.geeksville.mesh.util.addPositionMarkers
|
||||
import com.geeksville.mesh.util.addScaleBarOverlay
|
||||
import org.osmdroid.util.BoundingBox
|
||||
import org.osmdroid.util.GeoPoint
|
||||
|
||||
private const val DegD = 1e-7
|
||||
|
||||
@Composable
|
||||
fun NodeMapScreen(
|
||||
viewModel: MetricsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val geoPoints = state.positionLogs.map { GeoPoint(it.latitudeI * DegD, it.longitudeI * DegD) }
|
||||
val cameraView = remember { BoundingBox.fromGeoPoints(geoPoints) }
|
||||
val mapView = rememberMapViewWithLifecycle(cameraView, viewModel.tileSource)
|
||||
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
map.overlays.clear()
|
||||
map.addCopyright()
|
||||
map.addScaleBarOverlay(density)
|
||||
|
||||
map.addPolyline(density, geoPoints) {}
|
||||
map.addPositionMarkers(state.positionLogs) {}
|
||||
}
|
||||
)
|
||||
}
|
||||
166
app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
Normal file
166
app/src/main/java/com/geeksville/mesh/ui/node/NodeScreen.kt
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.model.DeviceVersion
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.ui.common.components.rememberTimeTickWithLifecycle
|
||||
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
|
||||
import com.geeksville.mesh.ui.node.components.NodeItem
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.sharing.AddContactFAB
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeScreen(
|
||||
model: UIViewModel = hiltViewModel(),
|
||||
navigateToMessages: (String) -> Unit,
|
||||
navigateToNodeDetails: (Int) -> Unit,
|
||||
) {
|
||||
val state by model.nodesUiState.collectAsStateWithLifecycle()
|
||||
|
||||
val nodes by model.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by model.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val currentTimeMillis = rememberTimeTickWithLifecycle()
|
||||
val connectionState by model.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
var showSharedContact: Node? by remember { mutableStateOf(null) }
|
||||
if (showSharedContact != null) {
|
||||
SharedContactDialog(
|
||||
contact = showSharedContact,
|
||||
onDismiss = { showSharedContact = null }
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
stickyHeader {
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
targetValue = if (!listState.isScrollInProgress) 1.0f else 0f,
|
||||
label = "alpha"
|
||||
)
|
||||
NodeFilterTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceDim.copy(alpha = animatedAlpha))
|
||||
.graphicsLayer(alpha = animatedAlpha)
|
||||
.padding(8.dp),
|
||||
filterText = state.filter,
|
||||
onTextChange = model::setNodeFilterText,
|
||||
currentSortOption = state.sort,
|
||||
onSortSelect = model::setSortOption,
|
||||
includeUnknown = state.includeUnknown,
|
||||
onToggleIncludeUnknown = model::toggleIncludeUnknown,
|
||||
showDetails = state.showDetails,
|
||||
onToggleShowDetails = model::toggleShowDetails,
|
||||
)
|
||||
}
|
||||
|
||||
items(nodes, key = { it.num }) { node ->
|
||||
NodeItem(
|
||||
modifier = Modifier.animateContentSize(),
|
||||
thisNode = ourNode,
|
||||
thatNode = node,
|
||||
gpsFormat = state.gpsFormat,
|
||||
distanceUnits = state.distanceUnits,
|
||||
tempInFahrenheit = state.tempInFahrenheit,
|
||||
onAction = { menuItem ->
|
||||
when (menuItem) {
|
||||
is NodeMenuAction.Remove -> model.removeNode(node.num)
|
||||
is NodeMenuAction.Ignore -> model.ignoreNode(node)
|
||||
is NodeMenuAction.Favorite -> model.favoriteNode(node)
|
||||
is NodeMenuAction.DirectMessage -> {
|
||||
val hasPKC = model.ourNodeInfo.value?.hasPKC == true && node.hasPKC
|
||||
val channel =
|
||||
if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
|
||||
navigateToMessages("$channel${node.user.id}")
|
||||
}
|
||||
|
||||
is NodeMenuAction.RequestUserInfo -> model.requestUserInfo(node.num)
|
||||
is NodeMenuAction.RequestPosition -> model.requestPosition(node.num)
|
||||
is NodeMenuAction.TraceRoute -> model.requestTraceroute(node.num)
|
||||
is NodeMenuAction.MoreDetails -> navigateToNodeDetails(node.num)
|
||||
is NodeMenuAction.Share -> showSharedContact = node
|
||||
}
|
||||
},
|
||||
expanded = state.showDetails,
|
||||
currentTimeMillis = currentTimeMillis,
|
||||
isConnected = connectionState.isConnected(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
|
||||
val shareCapable = firmwareVersion.supportsQrCodeSharing()
|
||||
|
||||
AnimatedVisibility(
|
||||
modifier = Modifier.align(Alignment.BottomEnd),
|
||||
visible = !listState.isScrollInProgress &&
|
||||
connectionState.isConnected() &&
|
||||
shareCapable
|
||||
) {
|
||||
@Suppress("NewApi")
|
||||
(
|
||||
AddContactFAB(
|
||||
model = model,
|
||||
onSharedContactImport = { contact ->
|
||||
model.addSharedContact(contact)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
import com.geeksville.mesh.util.metersIn
|
||||
import com.geeksville.mesh.util.toString
|
||||
|
||||
@Composable
|
||||
fun ElevationInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
altitude: Int,
|
||||
system: DisplayUnits,
|
||||
suffix: String
|
||||
) {
|
||||
val annotatedString = buildAnnotatedString {
|
||||
append(altitude.metersIn(system).toString(system))
|
||||
MaterialTheme.typography.labelSmall.toSpanStyle().let { style ->
|
||||
withStyle(style) {
|
||||
append(" $suffix")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
modifier = modifier,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
text = annotatedString,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun ElevationInfoPreview() {
|
||||
MaterialTheme {
|
||||
ElevationInfo(
|
||||
altitude = 100,
|
||||
system = DisplayUnits.METRIC,
|
||||
suffix = "ASL"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.util.formatAgo
|
||||
|
||||
@Composable
|
||||
fun LastHeardInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
lastHeard: Int,
|
||||
currentTimeMillis: Long,
|
||||
) {
|
||||
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.height(18.dp),
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(
|
||||
text = formatAgo(lastHeard, currentTimeMillis),
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun LastHeardInfoPreview() {
|
||||
AppTheme {
|
||||
LastHeardInfo(
|
||||
lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600,
|
||||
currentTimeMillis = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.Clipboard
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.net.toUri
|
||||
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.GpsCoordinateFormat
|
||||
import com.geeksville.mesh.android.BuildUtils.debug
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.ui.common.theme.HyperlinkBlue
|
||||
import com.geeksville.mesh.util.GPSFormat
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLEncoder
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LinkedCoordinates(
|
||||
modifier: Modifier = Modifier,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
format: Int,
|
||||
nodeName: String,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val clipboard: Clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val style = SpanStyle(
|
||||
color = HyperlinkBlue,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.Underline
|
||||
)
|
||||
|
||||
val annotatedString = rememberAnnotatedString(latitude, longitude, format, nodeName, style)
|
||||
|
||||
Text(
|
||||
modifier = modifier.combinedClickable(
|
||||
onClick = {
|
||||
handleClick(context, annotatedString)
|
||||
},
|
||||
onLongClick = {
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(
|
||||
ClipData.newPlainText("", annotatedString)
|
||||
)
|
||||
)
|
||||
debug("Copied to clipboard")
|
||||
}
|
||||
}
|
||||
),
|
||||
text = annotatedString
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberAnnotatedString(
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
format: Int,
|
||||
nodeName: String,
|
||||
style: SpanStyle
|
||||
) = buildAnnotatedString {
|
||||
pushStringAnnotation(
|
||||
tag = "gps",
|
||||
annotation = "geo:0,0?q=$latitude,$longitude&z=17&label=${
|
||||
URLEncoder.encode(nodeName, "utf-8")
|
||||
}"
|
||||
)
|
||||
withStyle(style = style) {
|
||||
val gpsString = when (format) {
|
||||
GpsCoordinateFormat.DEC_VALUE -> GPSFormat.toDEC(latitude, longitude)
|
||||
GpsCoordinateFormat.DMS_VALUE -> GPSFormat.toDMS(latitude, longitude)
|
||||
GpsCoordinateFormat.UTM_VALUE -> GPSFormat.toUTM(latitude, longitude)
|
||||
GpsCoordinateFormat.MGRS_VALUE -> GPSFormat.toMGRS(latitude, longitude)
|
||||
else -> GPSFormat.toDEC(latitude, longitude)
|
||||
}
|
||||
append(gpsString)
|
||||
}
|
||||
pop()
|
||||
}
|
||||
|
||||
private fun handleClick(context: Context, annotatedString: AnnotatedString) {
|
||||
annotatedString.getStringAnnotations(
|
||||
tag = "gps",
|
||||
start = 0,
|
||||
end = annotatedString.length
|
||||
).firstOrNull()?.let {
|
||||
val uri = it.item.toUri()
|
||||
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
try {
|
||||
if (intent.resolveActivity(context.packageManager) != null) {
|
||||
context.startActivity(intent)
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"No application available to open this location!",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
} catch (ex: ActivityNotFoundException) {
|
||||
debug("Failed to open geo intent: $ex")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun LinkedCoordinatesPreview(
|
||||
@PreviewParameter(GPSFormatPreviewParameterProvider::class) format: Int
|
||||
) {
|
||||
AppTheme {
|
||||
LinkedCoordinates(
|
||||
latitude = 37.7749,
|
||||
longitude = -122.4194,
|
||||
format = format,
|
||||
nodeName = "Test Node Name"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class GPSFormatPreviewParameterProvider : PreviewParameterProvider<Int> {
|
||||
override val values: Sequence<Int>
|
||||
get() = sequenceOf(0, 1, 2)
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.model.Node
|
||||
|
||||
@Composable
|
||||
fun NodeChip(
|
||||
modifier: Modifier = Modifier,
|
||||
node: Node,
|
||||
isThisNode: Boolean,
|
||||
isConnected: Boolean,
|
||||
onAction: (NodeMenuAction) -> Unit,
|
||||
) {
|
||||
val isIgnored = node.isIgnored
|
||||
val (textColor, nodeColor) = node.colors
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
val inputChipInteractionSource = remember { MutableInteractionSource() }
|
||||
Box {
|
||||
AssistChip(
|
||||
modifier = modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.defaultMinSize(minHeight = 32.dp, minWidth = 72.dp),
|
||||
colors = AssistChipDefaults.assistChipColors(
|
||||
containerColor = Color(nodeColor),
|
||||
labelColor = Color(textColor),
|
||||
),
|
||||
label = {
|
||||
Text(
|
||||
modifier = Modifier.Companion
|
||||
.fillMaxWidth(),
|
||||
text = node.user.shortName.ifEmpty { "???" },
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.Companion.LineThrough.takeIf { isIgnored },
|
||||
textAlign = TextAlign.Companion.Center,
|
||||
)
|
||||
},
|
||||
onClick = {},
|
||||
interactionSource = inputChipInteractionSource,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.Companion
|
||||
.matchParentSize()
|
||||
.combinedClickable(
|
||||
onClick = { onAction(NodeMenuAction.MoreDetails(node)) },
|
||||
onLongClick = { menuExpanded = true },
|
||||
interactionSource = inputChipInteractionSource,
|
||||
indication = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
NodeMenu(
|
||||
expanded = menuExpanded,
|
||||
node = node,
|
||||
showFullMenu = !isThisNode && isConnected,
|
||||
onDismissMenuRequest = { menuExpanded = false },
|
||||
onAction = {
|
||||
menuExpanded = false
|
||||
onAction(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Sort
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.NodeSortOption
|
||||
import com.geeksville.mesh.ui.common.preview.LargeFontPreview
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun NodeFilterTextField(
|
||||
modifier: Modifier = Modifier,
|
||||
filterText: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
currentSortOption: NodeSortOption,
|
||||
onSortSelect: (NodeSortOption) -> Unit,
|
||||
includeUnknown: Boolean,
|
||||
onToggleIncludeUnknown: () -> Unit,
|
||||
showDetails: Boolean,
|
||||
onToggleShowDetails: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.background(MaterialTheme.colorScheme.background),
|
||||
) {
|
||||
NodeFilterTextField(
|
||||
filterText = filterText,
|
||||
onTextChange = onTextChange,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
NodeSortButton(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
currentSortOption = currentSortOption,
|
||||
onSortSelect = onSortSelect,
|
||||
includeUnknown = includeUnknown,
|
||||
onToggleIncludeUnknown = onToggleIncludeUnknown,
|
||||
showDetails = showDetails,
|
||||
onToggleShowDetails = onToggleShowDetails,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeFilterTextField(
|
||||
filterText: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
OutlinedTextField(
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = 48.dp)
|
||||
.onFocusEvent { isFocused = it.isFocused },
|
||||
value = filterText,
|
||||
placeholder = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.node_filter_placeholder),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.35F)
|
||||
)
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
contentDescription = stringResource(id = R.string.node_filter_placeholder),
|
||||
)
|
||||
},
|
||||
onValueChange = onTextChange,
|
||||
trailingIcon = {
|
||||
if (filterText.isNotEmpty() || isFocused) {
|
||||
Icon(
|
||||
Icons.Default.Clear,
|
||||
contentDescription = stringResource(id = R.string.desc_node_filter_clear),
|
||||
modifier = Modifier.clickable {
|
||||
onTextChange("")
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
),
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { focusManager.clearFocus() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun NodeSortButton(
|
||||
currentSortOption: NodeSortOption,
|
||||
onSortSelect: (NodeSortOption) -> Unit,
|
||||
includeUnknown: Boolean,
|
||||
onToggleIncludeUnknown: () -> Unit,
|
||||
showDetails: Boolean,
|
||||
onToggleShowDetails: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) = Box(modifier) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
IconButton(onClick = { expanded = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Sort,
|
||||
contentDescription = stringResource(R.string.node_sort_button),
|
||||
modifier = Modifier.heightIn(max = 48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f))
|
||||
) {
|
||||
NodeSortOption.entries.forEach { sort ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onSortSelect(sort)
|
||||
expanded = false
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(id = sort.stringRes),
|
||||
fontWeight = if (sort == currentSortOption) FontWeight.Bold else null,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onToggleIncludeUnknown()
|
||||
expanded = false
|
||||
},
|
||||
text = {
|
||||
Row {
|
||||
AnimatedVisibility(visible = includeUnknown) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.node_filter_include_unknown),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
onToggleShowDetails()
|
||||
expanded = false
|
||||
},
|
||||
text = {
|
||||
Row {
|
||||
AnimatedVisibility(visible = showDetails) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Done,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(id = R.string.node_filter_show_details),
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@LargeFontPreview
|
||||
@Composable
|
||||
private fun NodeFilterTextFieldPreview() {
|
||||
AppTheme {
|
||||
NodeFilterTextField(
|
||||
filterText = "Filter text",
|
||||
onTextChange = {},
|
||||
currentSortOption = NodeSortOption.LAST_HEARD,
|
||||
onSortSelect = {},
|
||||
includeUnknown = false,
|
||||
onToggleIncludeUnknown = {},
|
||||
showDetails = false,
|
||||
onToggleShowDetails = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.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.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.model.DeviceVersion
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.isUnmessageableRole
|
||||
import com.geeksville.mesh.ui.common.components.BatteryInfo
|
||||
import com.geeksville.mesh.ui.common.components.SignalInfo
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.geeksville.mesh.util.toDistanceString
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeItem(
|
||||
thisNode: Node?,
|
||||
thatNode: Node,
|
||||
gpsFormat: Int,
|
||||
distanceUnits: Int,
|
||||
tempInFahrenheit: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onAction: (NodeMenuAction) -> Unit = {},
|
||||
expanded: Boolean = false,
|
||||
currentTimeMillis: Long,
|
||||
isConnected: Boolean = false,
|
||||
) {
|
||||
val isFavorite = remember(thatNode) { thatNode.isFavorite }
|
||||
val isIgnored = thatNode.isIgnored
|
||||
val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) }
|
||||
val isThisNode = remember(thatNode) { 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 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 style = if (thatNode.isUnknownUser) {
|
||||
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
|
||||
} else {
|
||||
LocalTextStyle.current
|
||||
}
|
||||
|
||||
val (detailsShown, showDetails) = remember { mutableStateOf(expanded) }
|
||||
val unmessageable = remember(thatNode) {
|
||||
when {
|
||||
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
|
||||
thatNode.user.role.isUnmessageableRole() ->
|
||||
thatNode.metadata?.firmwareVersion?.let {
|
||||
DeviceVersion(it) < DeviceVersion("2.6.8")
|
||||
} ?: true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.defaultMinSize(minHeight = 80.dp),
|
||||
onClick = { showDetails(!detailsShown) },
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NodeChip(
|
||||
node = thatNode,
|
||||
isThisNode = isThisNode,
|
||||
isConnected = isConnected,
|
||||
onAction = onAction,
|
||||
)
|
||||
|
||||
NodeKeyStatusIcon(
|
||||
hasPKC = thatNode.hasPKC,
|
||||
mismatchKey = thatNode.mismatchKey,
|
||||
publicKey = thatNode.user.publicKey,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = longName,
|
||||
style = style,
|
||||
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
||||
softWrap = true,
|
||||
)
|
||||
LastHeardInfo(
|
||||
lastHeard = thatNode.lastHeard,
|
||||
currentTimeMillis = currentTimeMillis
|
||||
)
|
||||
NodeStatusIcons(
|
||||
isThisNode = isThisNode,
|
||||
isFavorite = isFavorite,
|
||||
isUnmessageable = unmessageable
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
if (distance != null) {
|
||||
Text(
|
||||
text = distance,
|
||||
fontSize = MaterialTheme.typography.labelLarge.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,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
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.colorScheme.onSurface,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (detailsShown || expanded) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
HorizontalDivider()
|
||||
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.labelLarge.fontSize,
|
||||
style = style,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = roleName,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
style = style,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = thatNode.user.id.ifEmpty { "???" },
|
||||
textAlign = TextAlign.End,
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
style = style,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = false)
|
||||
fun NodeInfoSimplePreview() {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode = NodePreviewParameterProvider().values.last()
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
1,
|
||||
0,
|
||||
true,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(
|
||||
showBackground = true,
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
)
|
||||
fun NodeInfoPreview(
|
||||
@PreviewParameter(NodePreviewParameterProvider::class)
|
||||
thatNode: Node
|
||||
) {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
Column {
|
||||
Text(
|
||||
text = "Details Collapsed",
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
gpsFormat = 0,
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
expanded = false,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
Text(
|
||||
text = "Details Shown",
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
gpsFormat = 0,
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
expanded = true,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import android.util.Base64
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyOff
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Channel
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
import com.google.protobuf.ByteString
|
||||
|
||||
@Composable
|
||||
private fun KeyStatusDialog(
|
||||
@StringRes title: Int,
|
||||
@StringRes text: Int,
|
||||
key: ByteString?,
|
||||
onDismiss: () -> Unit = {}
|
||||
) = Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = title),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(id = text),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
if (key != null && title == R.string.encryption_pkc) {
|
||||
val keyString = Base64.encodeToString(key.toByteArray(), Base64.NO_WRAP)
|
||||
Text(
|
||||
text = stringResource(id = R.string.config_security_public_key) + ":",
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = keyString,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onDismiss,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
) { Text(text = stringResource(id = R.string.close)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeKeyStatusIcon(
|
||||
hasPKC: Boolean,
|
||||
mismatchKey: Boolean,
|
||||
publicKey: ByteString? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showEncryptionDialog by remember { mutableStateOf(false) }
|
||||
if (showEncryptionDialog) {
|
||||
val (title, text) = when {
|
||||
mismatchKey -> R.string.encryption_error to R.string.encryption_error_text
|
||||
hasPKC -> R.string.encryption_pkc to R.string.encryption_pkc_text
|
||||
else -> R.string.encryption_psk to R.string.encryption_psk_text
|
||||
}
|
||||
KeyStatusDialog(title, text, publicKey) { showEncryptionDialog = false }
|
||||
}
|
||||
|
||||
val (icon, tint) = when {
|
||||
mismatchKey -> Icons.Default.KeyOff to Color.Red
|
||||
hasPKC -> Icons.Default.Lock to Color(color = 0xFF30C047)
|
||||
else -> ImageVector.vectorResource(R.drawable.ic_lock_open_right_24) to Color(color = 0xFFFEC30A)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = { showEncryptionDialog = true },
|
||||
modifier = modifier,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = stringResource(
|
||||
id = when {
|
||||
mismatchKey -> R.string.encryption_error
|
||||
hasPKC -> R.string.encryption_pkc
|
||||
else -> R.string.encryption_psk
|
||||
}
|
||||
),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun KeyStatusDialogErrorPreview() {
|
||||
AppTheme {
|
||||
KeyStatusDialog(
|
||||
title = R.string.encryption_error,
|
||||
text = R.string.encryption_error_text,
|
||||
key = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun KeyStatusDialogPkcPreview() {
|
||||
AppTheme {
|
||||
KeyStatusDialog(
|
||||
title = R.string.encryption_pkc,
|
||||
text = R.string.encryption_pkc_text,
|
||||
key = Channel.getRandomKey(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun KeyStatusDialogPskPreview() {
|
||||
AppTheme {
|
||||
KeyStatusDialog(
|
||||
title = R.string.encryption_psk,
|
||||
text = R.string.encryption_psk_text,
|
||||
key = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,244 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.twotone.StarBorder
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.model.Node
|
||||
import com.geeksville.mesh.model.isUnmessageableRole
|
||||
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun NodeMenu(
|
||||
expanded: Boolean,
|
||||
node: Node,
|
||||
showFullMenu: Boolean = false,
|
||||
onDismissMenuRequest: () -> Unit,
|
||||
onAction: (NodeMenuAction) -> Unit,
|
||||
) {
|
||||
val isUnmessageable = if (node.user.hasIsUnmessagable()) {
|
||||
node.user.isUnmessagable
|
||||
} else {
|
||||
// for older firmwares
|
||||
node.user.role?.isUnmessageableRole() == true
|
||||
}
|
||||
|
||||
var displayFavoriteDialog by remember { mutableStateOf(false) }
|
||||
var displayIgnoreDialog by remember { mutableStateOf(false) }
|
||||
var displayRemoveDialog by remember { mutableStateOf(false) }
|
||||
val dialogDismissRequest = {
|
||||
displayFavoriteDialog = false
|
||||
displayIgnoreDialog = false
|
||||
displayRemoveDialog = false
|
||||
onDismissMenuRequest()
|
||||
}
|
||||
val onMenuAction: (NodeMenuAction) -> Unit = {
|
||||
dialogDismissRequest()
|
||||
onDismissMenuRequest()
|
||||
onAction(it)
|
||||
}
|
||||
NodeActionDialogs(
|
||||
node = node,
|
||||
displayFavoriteDialog = displayFavoriteDialog,
|
||||
displayIgnoreDialog = displayIgnoreDialog,
|
||||
displayRemoveDialog = displayRemoveDialog,
|
||||
onDismissMenuRequest = dialogDismissRequest,
|
||||
onAction = onMenuAction
|
||||
)
|
||||
DropdownMenu(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissMenuRequest,
|
||||
) {
|
||||
|
||||
if (showFullMenu) {
|
||||
if (!isUnmessageable) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.DirectMessage(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.direct_message)) }
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.RequestUserInfo(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.exchange_userinfo)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.RequestPosition(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.exchange_position)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.TraceRoute(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.traceroute)) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
displayFavoriteDialog = true
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
text = {
|
||||
Text(stringResource(R.string.favorite))
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = if (node.isFavorite) Icons.Filled.Star else Icons.TwoTone.StarBorder,
|
||||
contentDescription = stringResource(R.string.favorite),
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
displayIgnoreDialog = true
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.ignore))
|
||||
},
|
||||
trailingIcon = {
|
||||
Checkbox(
|
||||
checked = node.isIgnored,
|
||||
onCheckedChange = {
|
||||
dialogDismissRequest()
|
||||
displayIgnoreDialog = true
|
||||
},
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
displayRemoveDialog = true
|
||||
},
|
||||
enabled = !node.isIgnored,
|
||||
text = { Text(stringResource(R.string.remove)) }
|
||||
)
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
}
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.Share(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.share_contact)) }
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
dialogDismissRequest()
|
||||
onMenuAction(NodeMenuAction.MoreDetails(node))
|
||||
},
|
||||
text = { Text(stringResource(R.string.more_details)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NodeActionDialogs(
|
||||
node: Node,
|
||||
displayFavoriteDialog: Boolean,
|
||||
displayIgnoreDialog: Boolean,
|
||||
displayRemoveDialog: Boolean,
|
||||
onDismissMenuRequest: () -> Unit,
|
||||
onAction: (NodeMenuAction) -> Unit
|
||||
) {
|
||||
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 = {
|
||||
onDismissMenuRequest()
|
||||
onAction(NodeMenuAction.Favorite(node))
|
||||
},
|
||||
onDismiss = onDismissMenuRequest
|
||||
)
|
||||
}
|
||||
if (displayIgnoreDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.ignore,
|
||||
text = stringResource(
|
||||
id = if (node.isIgnored) R.string.ignore_remove else R.string.ignore_add,
|
||||
node.user.longName
|
||||
),
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onAction(NodeMenuAction.Ignore(node))
|
||||
},
|
||||
onDismiss = onDismissMenuRequest
|
||||
)
|
||||
}
|
||||
if (displayRemoveDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.remove,
|
||||
text = R.string.remove_node_text,
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onAction(NodeMenuAction.Remove(node))
|
||||
},
|
||||
onDismiss = onDismissMenuRequest
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
data class TraceRoute(val node: Node) : NodeMenuAction()
|
||||
data class MoreDetails(val node: Node) : NodeMenuAction()
|
||||
data class Share(val node: Node) : NodeMenuAction()
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.NoCell
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.Text
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NodeStatusIcons(
|
||||
isThisNode: Boolean,
|
||||
isUnmessageable: Boolean,
|
||||
isFavorite: Boolean,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
if (isUnmessageable) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(stringResource(R.string.unmonitored_or_infrastructure))
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.NoCell,
|
||||
contentDescription = stringResource(R.string.unmessageable),
|
||||
modifier = Modifier
|
||||
.size(24.dp), // Smaller size for badge
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isFavorite && !isThisNode) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(stringResource(R.string.favorite))
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier
|
||||
.size(24.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Star,
|
||||
contentDescription = stringResource(R.string.favorite),
|
||||
modifier = Modifier
|
||||
.size(24.dp), // Smaller size for badge
|
||||
tint = Color(color = 0xFFFEC30A)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StatusIconsPreview() {
|
||||
NodeStatusIcons(
|
||||
isThisNode = false,
|
||||
isUnmessageable = true,
|
||||
isFavorite = true,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.SatelliteAlt
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.common.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun SatelliteCountInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
satCount: Int,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(18.dp),
|
||||
imageVector = Icons.TwoTone.SatelliteAlt,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "$satCount",
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun SatelliteCountInfoPreview() {
|
||||
AppTheme {
|
||||
SatelliteCountInfo(
|
||||
satCount = 5,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue