mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Add :feature:node (#3275)
This commit is contained in:
parent
5a6cd5acbc
commit
d553cdfee6
44 changed files with 164 additions and 178 deletions
|
|
@ -50,8 +50,8 @@ import com.geeksville.mesh.ui.metrics.PowerMetricsScreen
|
|||
import com.geeksville.mesh.ui.metrics.SignalMetricsScreen
|
||||
import com.geeksville.mesh.ui.metrics.TracerouteLogScreen
|
||||
import com.geeksville.mesh.ui.node.NodeDetailScreen
|
||||
import com.geeksville.mesh.ui.node.NodeListScreen
|
||||
import com.geeksville.mesh.ui.node.NodeMapScreen
|
||||
import com.geeksville.mesh.ui.node.NodeScreen
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
|
||||
import org.meshtastic.core.navigation.NodeDetailRoutes
|
||||
|
|
@ -64,7 +64,7 @@ fun NavGraphBuilder.nodesGraph(navController: NavHostController, uiViewModel: UI
|
|||
composable<NodesRoutes.Nodes>(
|
||||
deepLinks = listOf(navDeepLink<NodesRoutes.Nodes>(basePath = "$DEEP_LINK_BASE_URI/nodes")),
|
||||
) {
|
||||
NodeScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) })
|
||||
NodeListScreen(navigateToNodeDetails = { navController.navigate(NodesRoutes.NodeDetailGraph(it)) })
|
||||
}
|
||||
nodeDetailGraph(navController, uiViewModel)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,11 +43,11 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.navigation.ContactsRoutes
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
|
|
|
|||
|
|
@ -1,134 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.component.determineSignalQuality
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
const val MAX_VALID_SNR = 100F
|
||||
const val MAX_VALID_RSSI = 0
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun SignalInfo(modifier: Modifier = Modifier, node: Node, isThisNode: Boolean) {
|
||||
val text =
|
||||
if (isThisNode) {
|
||||
stringResource(R.string.channel_air_util)
|
||||
.format(node.deviceMetrics.channelUtilization, node.deviceMetrics.airUtilTx)
|
||||
} else {
|
||||
buildList {
|
||||
val hopsString =
|
||||
"%s: %s"
|
||||
.format(
|
||||
stringResource(R.string.hops_away),
|
||||
if (node.hopsAway == -1) {
|
||||
"?"
|
||||
} else {
|
||||
node.hopsAway.toString()
|
||||
},
|
||||
)
|
||||
if (node.channel > 0) {
|
||||
add("ch:${node.channel}")
|
||||
}
|
||||
if (node.hopsAway != 0) add(hopsString)
|
||||
}
|
||||
.joinToString(" ")
|
||||
}
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (text.isNotEmpty()) {
|
||||
Text(text = text, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
/* We only know the Signal Quality from direct nodes aka 0 hop. */
|
||||
if (node.hopsAway <= 0) {
|
||||
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
|
||||
val quality = determineSignalQuality(node.snr, node.rssi)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Snr(node.snr)
|
||||
Rssi(node.rssi)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = quality.imageVector,
|
||||
contentDescription = stringResource(R.string.signal_quality),
|
||||
tint = quality.color.invoke(),
|
||||
)
|
||||
Text(
|
||||
text = "${stringResource(R.string.signal)} ${stringResource(quality.nameRes)}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun SignalInfoSimplePreview() {
|
||||
AppTheme {
|
||||
SignalInfo(
|
||||
node = Node(num = 1, lastHeard = 0, channel = 0, snr = 12.5F, rssi = -42, hopsAway = 0),
|
||||
isThisNode = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun SignalInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
|
||||
AppTheme { SignalInfo(node = node, isThisNode = false) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
@PreviewLightDark
|
||||
fun SignalInfoSelfPreview(@PreviewParameter(NodePreviewParameterProvider::class) node: Node) {
|
||||
AppTheme { SignalInfo(node = node, isThisNode = true) }
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
|
||||
@Preview(name = "Large Font", fontScale = 2f)
|
||||
annotation class LargeFontPreview
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.common.preview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import com.geeksville.mesh.ConfigProtos
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.deviceMetrics
|
||||
import com.geeksville.mesh.environmentMetrics
|
||||
import com.geeksville.mesh.paxcount
|
||||
import com.geeksville.mesh.position
|
||||
import com.geeksville.mesh.user
|
||||
import com.google.protobuf.ByteString
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.DeviceMetrics.Companion.currentTime
|
||||
import kotlin.random.Random
|
||||
|
||||
class NodePreviewParameterProvider : PreviewParameterProvider<Node> {
|
||||
val mickeyMouse =
|
||||
Node(
|
||||
num = 1955,
|
||||
user =
|
||||
user {
|
||||
id = "mickeyMouseId"
|
||||
longName = "Mickey Mouse"
|
||||
shortName = "MM"
|
||||
hwModel = MeshProtos.HardwareModel.TBEAM
|
||||
role = ConfigProtos.Config.DeviceConfig.Role.ROUTER
|
||||
},
|
||||
position =
|
||||
position {
|
||||
latitudeI = 338125110
|
||||
longitudeI = -1179189760
|
||||
altitude = 138
|
||||
satsInView = 4
|
||||
},
|
||||
lastHeard = currentTime(),
|
||||
channel = 0,
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
deviceMetrics =
|
||||
deviceMetrics {
|
||||
channelUtilization = 2.4F
|
||||
airUtilTx = 3.5F
|
||||
batteryLevel = 85
|
||||
voltage = 3.7F
|
||||
uptimeSeconds = 3600
|
||||
},
|
||||
isFavorite = true,
|
||||
hopsAway = 0,
|
||||
)
|
||||
|
||||
val minnieMouse =
|
||||
mickeyMouse.copy(
|
||||
num = Random.nextInt(),
|
||||
user =
|
||||
user {
|
||||
longName = "Minnie Mouse"
|
||||
shortName = "MiMo"
|
||||
id = "minnieMouseId"
|
||||
hwModel = MeshProtos.HardwareModel.HELTEC_V3
|
||||
},
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
position = position {},
|
||||
hopsAway = 1,
|
||||
)
|
||||
|
||||
private val donaldDuck =
|
||||
Node(
|
||||
num = Random.nextInt(),
|
||||
position =
|
||||
position {
|
||||
latitudeI = 338052347
|
||||
longitudeI = -1179208460
|
||||
altitude = 121
|
||||
satsInView = 66
|
||||
},
|
||||
lastHeard = currentTime() - 300,
|
||||
channel = 0,
|
||||
snr = 12.5F,
|
||||
rssi = -42,
|
||||
deviceMetrics =
|
||||
deviceMetrics {
|
||||
channelUtilization = 2.4F
|
||||
airUtilTx = 3.5F
|
||||
batteryLevel = 85
|
||||
voltage = 3.7F
|
||||
uptimeSeconds = 3600
|
||||
},
|
||||
user =
|
||||
user {
|
||||
id = "donaldDuckId"
|
||||
longName = "Donald Duck, the Grand Duck of the Ducks"
|
||||
shortName = "DoDu"
|
||||
hwModel = MeshProtos.HardwareModel.HELTEC_V3
|
||||
publicKey = ByteString.copyFrom(ByteArray(32) { 1 })
|
||||
},
|
||||
environmentMetrics =
|
||||
environmentMetrics {
|
||||
temperature = 28.0F
|
||||
relativeHumidity = 50.0F
|
||||
barometricPressure = 1013.25F
|
||||
gasResistance = 0.0F
|
||||
voltage = 3.7F
|
||||
current = 0.0F
|
||||
iaq = 100
|
||||
},
|
||||
paxcounter =
|
||||
paxcount {
|
||||
wifi = 30
|
||||
ble = 39
|
||||
uptime = 420
|
||||
},
|
||||
isFavorite = true,
|
||||
hopsAway = 2,
|
||||
)
|
||||
|
||||
private val unknown =
|
||||
donaldDuck.copy(
|
||||
user =
|
||||
user {
|
||||
id = "myId"
|
||||
longName = "Meshtastic myId"
|
||||
shortName = "myId"
|
||||
hwModel = MeshProtos.HardwareModel.UNSET
|
||||
},
|
||||
environmentMetrics = environmentMetrics {},
|
||||
paxcounter = paxcount {},
|
||||
)
|
||||
|
||||
private val almostNothing = Node(num = Random.nextInt())
|
||||
|
||||
override val values: Sequence<Node>
|
||||
get() =
|
||||
sequenceOf(
|
||||
mickeyMouse, // "this" node
|
||||
unknown,
|
||||
almostNothing,
|
||||
minnieMouse,
|
||||
donaldDuck,
|
||||
)
|
||||
}
|
||||
|
|
@ -70,7 +70,6 @@ import com.geeksville.mesh.ui.connections.components.ConnectionsSegmentedBar
|
|||
import com.geeksville.mesh.ui.connections.components.CurrentlyConnectedInfo
|
||||
import com.geeksville.mesh.ui.connections.components.NetworkDevices
|
||||
import com.geeksville.mesh.ui.connections.components.UsbDevices
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItem
|
||||
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
|
||||
import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
|
|
@ -79,6 +78,7 @@ import org.meshtastic.core.navigation.Route
|
|||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.service.ConnectionState
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SettingsItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
|
||||
fun String?.isIPAddress(): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
|
|
|
|||
|
|
@ -39,11 +39,11 @@ import androidx.compose.ui.unit.dp
|
|||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.PaxcountProtos
|
||||
import com.geeksville.mesh.TelemetryProtos
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
|
||||
import org.meshtastic.core.ui.component.MaterialBluetoothSignalInfo
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
||||
|
|
|
|||
|
|
@ -96,7 +96,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.AppOnlyProtos
|
||||
import com.geeksville.mesh.ui.common.components.SecurityIcon
|
||||
import com.geeksville.mesh.ui.node.components.NodeKeyStatusIcon
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -107,6 +106,7 @@ import org.meshtastic.core.model.DataPacket
|
|||
import org.meshtastic.core.model.util.getChannel
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.feature.node.component.NodeKeyStatusIcon
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
private const val MESSAGE_CHARACTER_LIMIT_BYTES = 200
|
||||
|
|
|
|||
|
|
@ -49,16 +49,16 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import org.meshtastic.core.database.entity.Reaction
|
||||
import org.meshtastic.core.database.model.Message
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.MessageStatus
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MDText
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
import org.meshtastic.core.ui.component.Rssi
|
||||
import org.meshtastic.core.ui.component.Snr
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.MessageItemColors
|
||||
|
||||
|
|
|
|||
|
|
@ -134,13 +134,6 @@ import com.geeksville.mesh.MeshProtos
|
|||
import com.geeksville.mesh.model.MetricsState
|
||||
import com.geeksville.mesh.model.MetricsViewModel
|
||||
import com.geeksville.mesh.ui.common.components.MainAppBar
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import com.geeksville.mesh.ui.node.components.TracerouteButton
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItem
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItemDetail
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
|
||||
import com.geeksville.mesh.ui.sharing.SharedContactDialog
|
||||
import com.geeksville.mesh.util.thenIf
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
|
|
@ -163,12 +156,20 @@ import org.meshtastic.core.navigation.Route
|
|||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SettingsItem
|
||||
import org.meshtastic.core.ui.component.SettingsItemDetail
|
||||
import org.meshtastic.core.ui.component.SettingsItemSwitch
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
import org.meshtastic.feature.node.component.NodeActionDialogs
|
||||
import org.meshtastic.feature.node.component.NodeMenuAction
|
||||
import org.meshtastic.feature.node.component.TracerouteButton
|
||||
import org.meshtastic.feature.node.detail.NodeDetailViewModel
|
||||
import timber.log.Timber
|
||||
|
||||
private data class VectorMetricInfo(
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import android.os.RemoteException
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.ui.node.components.NodeMenuAction
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NodeDetailViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||
|
||||
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
|
||||
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
|
||||
|
||||
fun handleNodeMenuAction(action: NodeMenuAction) {
|
||||
when (action) {
|
||||
is NodeMenuAction.Remove -> removeNode(action.node.num)
|
||||
is NodeMenuAction.Ignore -> ignoreNode(action.node)
|
||||
is NodeMenuAction.Favorite -> favoriteNode(action.node)
|
||||
is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num)
|
||||
is NodeMenuAction.RequestPosition -> requestPosition(action.node.num)
|
||||
is NodeMenuAction.TraceRoute -> {
|
||||
requestTraceroute(action.node.num)
|
||||
_lastTraceRouteTime.value = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
fun setNodeNotes(nodeNum: Int, notes: String) = viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
nodeRepository.setNodeNotes(nodeNum, notes)
|
||||
} catch (ex: java.io.IOException) {
|
||||
Timber.e("Set node notes IO error: ${ex.message}")
|
||||
} catch (ex: java.sql.SQLException) {
|
||||
Timber.e("Set node notes SQL error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
|
||||
Timber.i("Removing node '$nodeNum'")
|
||||
try {
|
||||
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
||||
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
|
||||
nodeRepository.deleteNode(nodeNum)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Remove node error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun ignoreNode(node: Node) = viewModelScope.launch {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e(ex, "Ignore node error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun favoriteNode(node: Node) = viewModelScope.launch {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e(ex, "Favorite node error")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestUserInfo(destNum: Int) {
|
||||
Timber.i("Requesting UserInfo for '$destNum'")
|
||||
try {
|
||||
serviceRepository.meshService?.requestUserInfo(destNum)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Request NodeInfo error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) {
|
||||
Timber.i("Requesting position for '$destNum'")
|
||||
try {
|
||||
serviceRepository.meshService?.requestPosition(destNum, position)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Request position error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestTraceroute(destNum: Int) {
|
||||
Timber.i("Requesting traceroute for '$destNum'")
|
||||
try {
|
||||
val packetId = serviceRepository.meshService?.packetId ?: return
|
||||
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Request traceroute error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -59,9 +59,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.AdminProtos
|
||||
import com.geeksville.mesh.ui.common.components.MainAppBar
|
||||
import com.geeksville.mesh.ui.node.components.NodeActionDialogs
|
||||
import com.geeksville.mesh.ui.node.components.NodeFilterTextField
|
||||
import com.geeksville.mesh.ui.node.components.NodeItem
|
||||
import com.geeksville.mesh.ui.sharing.AddContactFAB
|
||||
import com.geeksville.mesh.ui.sharing.supportsQrCodeSharing
|
||||
import org.meshtastic.core.database.model.Node
|
||||
|
|
@ -70,24 +67,28 @@ import org.meshtastic.core.service.ConnectionState
|
|||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.rememberTimeTickWithLifecycle
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.feature.node.component.NodeActionDialogs
|
||||
import org.meshtastic.feature.node.component.NodeFilterTextField
|
||||
import org.meshtastic.feature.node.component.NodeItem
|
||||
import org.meshtastic.feature.node.list.NodeListViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
|
||||
val state by nodesViewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
fun NodeListScreen(viewModel: NodeListViewModel = hiltViewModel(), navigateToNodeDetails: (Int) -> Unit) {
|
||||
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
|
||||
|
||||
val nodes by nodesViewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by nodesViewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val onlineNodeCount by nodesViewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
|
||||
val totalNodeCount by nodesViewModel.totalNodeCount.collectAsStateWithLifecycle(0)
|
||||
val unfilteredNodes by nodesViewModel.unfilteredNodeList.collectAsStateWithLifecycle()
|
||||
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
|
||||
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
|
||||
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
|
||||
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
|
||||
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
|
||||
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
|
||||
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val currentTimeMillis = rememberTimeTickWithLifecycle()
|
||||
val connectionState by nodesViewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
|
||||
val isScrollInProgress by remember {
|
||||
derivedStateOf { listState.isScrollInProgress && (listState.canScrollForward || listState.canScrollBackward) }
|
||||
|
|
@ -109,7 +110,7 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
|
|||
val firmwareVersion = DeviceVersion(ourNode?.metadata?.firmwareVersion ?: "0.0.0")
|
||||
val shareCapable = firmwareVersion.supportsQrCodeSharing()
|
||||
val scannedContact: AdminProtos.SharedContact? by
|
||||
nodesViewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
|
||||
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
|
||||
AddContactFAB(
|
||||
unfilteredNodes = unfilteredNodes,
|
||||
scannedContact = scannedContact,
|
||||
|
|
@ -118,8 +119,8 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
|
|||
visible = !isScrollInProgress && connectionState == ConnectionState.CONNECTED && shareCapable,
|
||||
alignment = Alignment.BottomEnd,
|
||||
),
|
||||
onSharedContactImport = { contact -> nodesViewModel.addSharedContact(contact) },
|
||||
onSharedContactRequested = { contact -> nodesViewModel.setSharedContactRequested(contact) },
|
||||
onSharedContactImport = { contact -> viewModel.addSharedContact(contact) },
|
||||
onSharedContactRequested = { contact -> viewModel.setSharedContactRequested(contact) },
|
||||
)
|
||||
},
|
||||
) { contentPadding ->
|
||||
|
|
@ -135,17 +136,17 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
|
|||
.background(MaterialTheme.colorScheme.surfaceDim)
|
||||
.padding(8.dp),
|
||||
filterText = state.filter.filterText,
|
||||
onTextChange = nodesViewModel::setNodeFilterText,
|
||||
onTextChange = viewModel::setNodeFilterText,
|
||||
currentSortOption = state.sort,
|
||||
onSortSelect = nodesViewModel::setSortOption,
|
||||
onSortSelect = viewModel::setSortOption,
|
||||
includeUnknown = state.filter.includeUnknown,
|
||||
onToggleIncludeUnknown = nodesViewModel::toggleIncludeUnknown,
|
||||
onToggleIncludeUnknown = viewModel::toggleIncludeUnknown,
|
||||
onlyOnline = state.filter.onlyOnline,
|
||||
onToggleOnlyOnline = nodesViewModel::toggleOnlyOnline,
|
||||
onToggleOnlyOnline = viewModel::toggleOnlyOnline,
|
||||
onlyDirect = state.filter.onlyDirect,
|
||||
onToggleOnlyDirect = nodesViewModel::toggleOnlyDirect,
|
||||
onToggleOnlyDirect = viewModel::toggleOnlyDirect,
|
||||
showIgnored = state.filter.showIgnored,
|
||||
onToggleShowIgnored = nodesViewModel::toggleShowIgnored,
|
||||
onToggleShowIgnored = viewModel::toggleShowIgnored,
|
||||
ignoredNodeCount = ignoredNodeCount,
|
||||
)
|
||||
}
|
||||
|
|
@ -165,9 +166,9 @@ fun NodeScreen(nodesViewModel: NodesViewModel = hiltViewModel(), navigateToNodeD
|
|||
displayIgnoreDialog = false
|
||||
displayRemoveDialog = false
|
||||
},
|
||||
onConfirmFavorite = nodesViewModel::favoriteNode,
|
||||
onConfirmIgnore = nodesViewModel::ignoreNode,
|
||||
onConfirmRemove = { nodesViewModel.removeNode(it.num) },
|
||||
onConfirmFavorite = viewModel::favoriteNode,
|
||||
onConfirmIgnore = viewModel::ignoreNode,
|
||||
onConfirmRemove = { viewModel.removeNode(it.num) },
|
||||
)
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node
|
||||
|
||||
import android.os.RemoteException
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.geeksville.mesh.AdminProtos
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.data.repository.NodeRepository
|
||||
import org.meshtastic.core.data.repository.RadioConfigRepository
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.NodeSortOption
|
||||
import org.meshtastic.core.datastore.UiPreferencesDataSource
|
||||
import org.meshtastic.core.service.ServiceAction
|
||||
import org.meshtastic.core.service.ServiceRepository
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class NodesViewModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val nodeRepository: NodeRepository,
|
||||
radioConfigRepository: RadioConfigRepository,
|
||||
private val serviceRepository: ServiceRepository,
|
||||
private val uiPreferencesDataSource: UiPreferencesDataSource,
|
||||
) : ViewModel() {
|
||||
|
||||
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
|
||||
|
||||
val onlineNodeCount =
|
||||
nodeRepository.onlineNodeCount.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = 0,
|
||||
)
|
||||
|
||||
val totalNodeCount =
|
||||
nodeRepository.totalNodeCount.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = 0,
|
||||
)
|
||||
|
||||
val connectionState = serviceRepository.connectionState
|
||||
|
||||
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
|
||||
val sharedContactRequested = _sharedContactRequested.asStateFlow()
|
||||
|
||||
private val nodeSortOption =
|
||||
uiPreferencesDataSource.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } }
|
||||
|
||||
private val nodeFilterText = MutableStateFlow("")
|
||||
private val includeUnknown = uiPreferencesDataSource.includeUnknown
|
||||
private val onlyOnline = uiPreferencesDataSource.onlyOnline
|
||||
private val onlyDirect = uiPreferencesDataSource.onlyDirect
|
||||
private val showIgnored = uiPreferencesDataSource.showIgnored
|
||||
|
||||
private val nodeFilter: Flow<NodeFilterState> =
|
||||
combine(nodeFilterText, includeUnknown, onlyOnline, onlyDirect, showIgnored) {
|
||||
filterText,
|
||||
includeUnknown,
|
||||
onlyOnline,
|
||||
onlyDirect,
|
||||
showIgnored,
|
||||
->
|
||||
NodeFilterState(filterText, includeUnknown, onlyOnline, onlyDirect, showIgnored)
|
||||
}
|
||||
|
||||
val nodesUiState: StateFlow<NodesUiState> =
|
||||
combine(nodeSortOption, nodeFilter, radioConfigRepository.deviceProfileFlow) { sort, nodeFilter, profile ->
|
||||
NodesUiState(
|
||||
sort = sort,
|
||||
filter = nodeFilter,
|
||||
distanceUnits = profile.config.display.units.number,
|
||||
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
|
||||
)
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = NodesUiState(),
|
||||
)
|
||||
|
||||
val nodeList: StateFlow<List<Node>> =
|
||||
combine(nodeFilter, nodeSortOption, ::Pair)
|
||||
.flatMapLatest { (filter, sort) ->
|
||||
nodeRepository
|
||||
.getNodes(
|
||||
sort = sort,
|
||||
filter = filter.filterText,
|
||||
includeUnknown = filter.includeUnknown,
|
||||
onlyOnline = filter.onlyOnline,
|
||||
onlyDirect = filter.onlyDirect,
|
||||
)
|
||||
.map { list -> list.filter { it.isIgnored == filter.showIgnored } }
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
val unfilteredNodeList: StateFlow<List<Node>> =
|
||||
nodeRepository
|
||||
.getNodes()
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = emptyList(),
|
||||
)
|
||||
|
||||
fun setNodeFilterText(text: String) {
|
||||
nodeFilterText.value = text
|
||||
}
|
||||
|
||||
fun toggleIncludeUnknown() {
|
||||
uiPreferencesDataSource.setIncludeUnknown(!includeUnknown.value)
|
||||
}
|
||||
|
||||
fun toggleOnlyOnline() {
|
||||
uiPreferencesDataSource.setOnlyOnline(!onlyOnline.value)
|
||||
}
|
||||
|
||||
fun toggleOnlyDirect() {
|
||||
uiPreferencesDataSource.setOnlyDirect(!onlyDirect.value)
|
||||
}
|
||||
|
||||
fun toggleShowIgnored() {
|
||||
uiPreferencesDataSource.setShowIgnored(!showIgnored.value)
|
||||
}
|
||||
|
||||
fun setSortOption(sort: NodeSortOption) {
|
||||
uiPreferencesDataSource.setNodeSort(sort.ordinal)
|
||||
}
|
||||
|
||||
fun addSharedContact(sharedContact: AdminProtos.SharedContact) =
|
||||
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.AddSharedContact(sharedContact)) }
|
||||
|
||||
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
|
||||
_sharedContactRequested.value = sharedContact
|
||||
}
|
||||
|
||||
fun favoriteNode(node: Node) = viewModelScope.launch {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Favorite(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e(ex, "Favorite node error")
|
||||
}
|
||||
}
|
||||
|
||||
fun ignoreNode(node: Node) = viewModelScope.launch {
|
||||
try {
|
||||
serviceRepository.onServiceAction(ServiceAction.Ignore(node))
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e(ex, "Ignore node error")
|
||||
}
|
||||
}
|
||||
|
||||
fun removeNode(nodeNum: Int) = viewModelScope.launch(Dispatchers.IO) {
|
||||
Timber.i("Removing node '$nodeNum'")
|
||||
try {
|
||||
val packetId = serviceRepository.meshService?.packetId ?: return@launch
|
||||
serviceRepository.meshService?.removeByNodenum(packetId, nodeNum)
|
||||
nodeRepository.deleteNode(nodeNum)
|
||||
} catch (ex: RemoteException) {
|
||||
Timber.e("Remove node error: ${ex.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NodesUiState(
|
||||
val sort: NodeSortOption = NodeSortOption.LAST_HEARD,
|
||||
val filter: NodeFilterState = NodeFilterState(),
|
||||
val distanceUnits: Int = 0,
|
||||
val tempInFahrenheit: Boolean = false,
|
||||
)
|
||||
|
||||
data class NodeFilterState(
|
||||
val filterText: String = "",
|
||||
val includeUnknown: Boolean = false,
|
||||
val onlyOnline: Boolean = false,
|
||||
val onlyDirect: Boolean = false,
|
||||
val showIgnored: Boolean = false,
|
||||
)
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.SocialDistance
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun DistanceInfo(distance: String, modifier: Modifier = Modifier) {
|
||||
IconInfo(
|
||||
modifier = modifier,
|
||||
icon = Icons.Rounded.SocialDistance,
|
||||
contentDescription = stringResource(R.string.distance),
|
||||
text = distance,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun DistanceInfoPreview() {
|
||||
AppTheme { DistanceInfo(distance = "423 mi.") }
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.geeksville.mesh.ConfigProtos.Config.DisplayConfig.DisplayUnits
|
||||
import org.meshtastic.core.model.util.metersIn
|
||||
import org.meshtastic.core.model.util.toString
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.icon.Elevation
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
||||
@Composable
|
||||
fun ElevationInfo(
|
||||
modifier: Modifier = Modifier,
|
||||
altitude: Int,
|
||||
system: DisplayUnits,
|
||||
suffix: String = stringResource(R.string.elevation_suffix),
|
||||
) {
|
||||
IconInfo(
|
||||
modifier = modifier,
|
||||
icon = MeshtasticIcons.Elevation,
|
||||
contentDescription = stringResource(R.string.altitude),
|
||||
text = altitude.metersIn(system).toString(system) + " " + suffix,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
fun ElevationInfoPreview() {
|
||||
MaterialTheme { ElevationInfo(altitude = 100, system = DisplayUnits.METRIC, suffix = "ASL") }
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.ui.icon.Elevation
|
||||
import org.meshtastic.core.ui.icon.MeshtasticIcons
|
||||
|
||||
private const val SIZE_ICON = 20
|
||||
|
||||
@Composable
|
||||
fun IconInfo(
|
||||
icon: ImageVector,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
text: String? = null,
|
||||
content: @Composable () -> Unit = {},
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(SIZE_ICON.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
text?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun IconInfoPreview() {
|
||||
MaterialTheme {
|
||||
IconInfo(icon = MeshtasticIcons.Elevation, contentDescription = "Elevation", content = { Text(text = "100") })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import com.geeksville.mesh.R
|
||||
import org.meshtastic.core.model.util.formatAgo
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun LastHeardInfo(modifier: Modifier = Modifier, lastHeard: Int, currentTimeMillis: Long) {
|
||||
IconInfo(
|
||||
modifier = modifier,
|
||||
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
|
||||
contentDescription = stringResource(org.meshtastic.core.strings.R.string.node_sort_last_heard),
|
||||
text = formatAgo(lastHeard, currentTimeMillis),
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun LastHeardInfoPreview() {
|
||||
AppTheme {
|
||||
LastHeardInfo(
|
||||
lastHeard = (System.currentTimeMillis() / 1000).toInt() - 8600,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,118 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.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.core.net.toUri
|
||||
import kotlinx.coroutines.launch
|
||||
import org.meshtastic.core.model.util.GPSFormat
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.HyperlinkBlue
|
||||
import timber.log.Timber
|
||||
import java.net.URLEncoder
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun LinkedCoordinates(modifier: Modifier = Modifier, latitude: Double, longitude: Double, 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, nodeName, style)
|
||||
|
||||
Text(
|
||||
modifier =
|
||||
modifier.combinedClickable(
|
||||
onClick = { handleClick(context, annotatedString) },
|
||||
onLongClick = {
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", annotatedString)))
|
||||
Timber.d("Copied to clipboard")
|
||||
}
|
||||
},
|
||||
),
|
||||
text = annotatedString,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberAnnotatedString(latitude: Double, longitude: Double, 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 = 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) {
|
||||
Timber.d("Failed to open geo intent: $ex")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun LinkedCoordinatesPreview() {
|
||||
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.Color
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.PaxcountProtos
|
||||
import com.geeksville.mesh.TelemetryProtos
|
||||
import org.meshtastic.core.database.model.Node
|
||||
|
||||
@Composable
|
||||
fun NodeChip(modifier: Modifier = Modifier, node: Node, onClick: ((Node) -> Unit)? = null) {
|
||||
val (textColor, nodeColor) = node.colors
|
||||
val colors = CardDefaults.cardColors(containerColor = Color(nodeColor), contentColor = Color(textColor))
|
||||
|
||||
val content: @Composable () -> Unit = {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(IntrinsicSize.Min)
|
||||
.defaultMinSize(minWidth = 72.dp, minHeight = 32.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
.semantics { contentDescription = node.user.shortName.ifEmpty { "Node" } },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = node.user.shortName.ifEmpty { "???" },
|
||||
fontSize = MaterialTheme.typography.labelLarge.fontSize,
|
||||
textDecoration = TextDecoration.LineThrough.takeIf { node.isIgnored },
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (onClick == null) {
|
||||
Card(modifier = modifier, shape = MaterialTheme.shapes.small, colors = colors) { content() }
|
||||
} else {
|
||||
Card(modifier = modifier, shape = MaterialTheme.shapes.small, colors = colors, onClick = { onClick(node) }) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
@Preview
|
||||
@Composable
|
||||
fun NodeChipPreview() {
|
||||
val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build()
|
||||
val node =
|
||||
Node(
|
||||
num = 13444,
|
||||
user = user,
|
||||
isIgnored = false,
|
||||
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
|
||||
environmentMetrics =
|
||||
TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(),
|
||||
)
|
||||
NodeChip(node = node)
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
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.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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.MenuDefaults
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
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.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.common.preview.LargeFontPreview
|
||||
import org.meshtastic.core.database.model.NodeSortOption
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Composable
|
||||
fun NodeFilterTextField(
|
||||
modifier: Modifier = Modifier,
|
||||
filterText: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
currentSortOption: NodeSortOption,
|
||||
onSortSelect: (NodeSortOption) -> Unit,
|
||||
includeUnknown: Boolean,
|
||||
onToggleIncludeUnknown: () -> Unit,
|
||||
onlyOnline: Boolean,
|
||||
onToggleOnlyOnline: () -> Unit,
|
||||
onlyDirect: Boolean,
|
||||
onToggleOnlyDirect: () -> Unit,
|
||||
showIgnored: Boolean,
|
||||
onToggleShowIgnored: () -> Unit,
|
||||
ignoredNodeCount: Int,
|
||||
) {
|
||||
Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) {
|
||||
Row {
|
||||
NodeFilterTextField(filterText = filterText, onTextChange = onTextChange, modifier = Modifier.weight(1f))
|
||||
|
||||
NodeSortButton(
|
||||
modifier = Modifier.align(Alignment.CenterVertically),
|
||||
currentSortOption = currentSortOption,
|
||||
onSortSelect = onSortSelect,
|
||||
toggles =
|
||||
NodeFilterToggles(
|
||||
includeUnknown = includeUnknown,
|
||||
onToggleIncludeUnknown = onToggleIncludeUnknown,
|
||||
onlyOnline = onlyOnline,
|
||||
onToggleOnlyOnline = onToggleOnlyOnline,
|
||||
onlyDirect = onlyDirect,
|
||||
onToggleOnlyDirect = onToggleOnlyDirect,
|
||||
showIgnored = showIgnored,
|
||||
onToggleShowIgnored = onToggleShowIgnored,
|
||||
ignoredNodeCount = ignoredNodeCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (showIgnored) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceDim)
|
||||
.clickable { onToggleShowIgnored() }
|
||||
.padding(vertical = 16.dp, horizontal = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.node_filter_ignored),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
toggles: NodeFilterToggles,
|
||||
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)),
|
||||
) {
|
||||
DropdownMenuTitle(text = stringResource(R.string.node_sort_title))
|
||||
|
||||
NodeSortOption.entries.forEach { sort ->
|
||||
DropdownMenuRadio(
|
||||
text = stringResource(id = sort.stringRes),
|
||||
selected = sort == currentSortOption,
|
||||
onClick = { onSortSelect(sort) },
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(modifier = Modifier.padding(MenuDefaults.DropdownMenuItemContentPadding))
|
||||
|
||||
DropdownMenuTitle(text = stringResource(R.string.node_filter_title))
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(R.string.node_filter_include_unknown),
|
||||
checked = toggles.includeUnknown,
|
||||
onClick = toggles.onToggleIncludeUnknown,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(R.string.node_filter_only_online),
|
||||
checked = toggles.onlyOnline,
|
||||
onClick = toggles.onToggleOnlyOnline,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(R.string.node_filter_only_direct),
|
||||
checked = toggles.onlyDirect,
|
||||
onClick = toggles.onToggleOnlyDirect,
|
||||
)
|
||||
|
||||
DropdownMenuCheck(
|
||||
text = stringResource(R.string.node_filter_show_ignored),
|
||||
checked = toggles.showIgnored,
|
||||
onClick = toggles.onToggleShowIgnored,
|
||||
trailing =
|
||||
if (toggles.ignoredNodeCount > 0) {
|
||||
{
|
||||
Text(
|
||||
text = " (${toggles.ignoredNodeCount})",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DropdownMenuTitle(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier =
|
||||
Modifier.height(48.dp)
|
||||
.padding(MenuDefaults.DropdownMenuItemContentPadding)
|
||||
.wrapContentHeight(align = Alignment.CenterVertically),
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DropdownMenuRadio(text: String, selected: Boolean, onClick: () -> Unit) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
leadingIcon = { RadioButton(selected = selected, onClick = null) },
|
||||
text = { Text(text = text) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DropdownMenuCheck(
|
||||
text: String,
|
||||
checked: Boolean,
|
||||
onClick: () -> Unit,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = onClick,
|
||||
leadingIcon = { Checkbox(checked = checked, onCheckedChange = null) },
|
||||
trailingIcon = trailing,
|
||||
text = { Text(text = text) },
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@LargeFontPreview
|
||||
@Composable
|
||||
private fun NodeFilterTextFieldPreview() {
|
||||
AppTheme {
|
||||
NodeFilterTextField(
|
||||
filterText = "Filter text",
|
||||
onTextChange = {},
|
||||
currentSortOption = NodeSortOption.LAST_HEARD,
|
||||
onSortSelect = {},
|
||||
includeUnknown = false,
|
||||
onToggleIncludeUnknown = {},
|
||||
onlyOnline = false,
|
||||
onToggleOnlyOnline = {},
|
||||
onlyDirect = false,
|
||||
onToggleOnlyDirect = {},
|
||||
showIgnored = false,
|
||||
onToggleShowIgnored = {},
|
||||
ignoredNodeCount = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class NodeFilterToggles(
|
||||
val includeUnknown: Boolean,
|
||||
val onToggleIncludeUnknown: () -> Unit,
|
||||
val onlyOnline: Boolean,
|
||||
val onToggleOnlyOnline: () -> Unit,
|
||||
val onlyDirect: Boolean,
|
||||
val onToggleOnlyDirect: () -> Unit,
|
||||
val showIgnored: Boolean,
|
||||
val onToggleShowIgnored: () -> Unit,
|
||||
val ignoredNodeCount: Int,
|
||||
)
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
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.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
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.DisplayConfig
|
||||
import com.geeksville.mesh.ui.common.components.SignalInfo
|
||||
import com.geeksville.mesh.ui.common.preview.NodePreviewParameterProvider
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.database.model.isUnmessageableRole
|
||||
import org.meshtastic.core.model.util.toDistanceString
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MaterialBatteryInfo
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
fun NodeItem(
|
||||
thisNode: Node?,
|
||||
thatNode: Node,
|
||||
distanceUnits: Int,
|
||||
tempInFahrenheit: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
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) }
|
||||
|
||||
var contentColor = MaterialTheme.colorScheme.onSurface
|
||||
val cardColors =
|
||||
if (isThisNode) {
|
||||
thisNode?.colors?.second
|
||||
} else {
|
||||
thatNode.colors.second
|
||||
}
|
||||
?.let {
|
||||
val containerColor = Color(it).copy(alpha = 0.2f)
|
||||
contentColor = contentColorFor(containerColor)
|
||||
CardDefaults.cardColors().copy(containerColor = containerColor, contentColor = contentColor)
|
||||
} ?: (CardDefaults.cardColors())
|
||||
|
||||
val style =
|
||||
if (thatNode.isUnknownUser) {
|
||||
LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)
|
||||
} else {
|
||||
LocalTextStyle.current
|
||||
}
|
||||
|
||||
val unmessageable =
|
||||
remember(thatNode) {
|
||||
when {
|
||||
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
|
||||
else -> thatNode.user.role.isUnmessageableRole()
|
||||
}
|
||||
}
|
||||
|
||||
Card(modifier = modifier.fillMaxWidth().defaultMinSize(minHeight = 80.dp), colors = cardColors) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier.combinedClickable(onClick = onClick, onLongClick = onLongClick).fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
NodeChip(node = thatNode)
|
||||
|
||||
NodeKeyStatusIcon(
|
||||
hasPKC = thatNode.hasPKC,
|
||||
mismatchKey = thatNode.mismatchKey,
|
||||
publicKey = thatNode.user.publicKey,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = longName,
|
||||
style =
|
||||
MaterialTheme.typography.titleMediumEmphasized.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
textDecoration = TextDecoration.LineThrough.takeIf { isIgnored },
|
||||
softWrap = true,
|
||||
)
|
||||
LastHeardInfo(lastHeard = thatNode.lastHeard, currentTimeMillis = currentTimeMillis)
|
||||
NodeStatusIcons(
|
||||
isThisNode = isThisNode,
|
||||
isFavorite = isFavorite,
|
||||
isUnmessageable = unmessageable,
|
||||
isConnected = isConnected,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MaterialBatteryInfo(level = thatNode.batteryLevel, voltage = thatNode.voltage)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (distance != null) {
|
||||
DistanceInfo(distance = distance)
|
||||
}
|
||||
thatNode.validPosition?.let { position ->
|
||||
ElevationInfo(
|
||||
altitude = position.altitude,
|
||||
system = system,
|
||||
suffix = stringResource(id = R.string.elevation_suffix),
|
||||
)
|
||||
val satCount = position.satsInView
|
||||
if (satCount > 0) {
|
||||
SatelliteCountInfo(satCount = satCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SignalInfo(node = thatNode, isThisNode = isThisNode)
|
||||
}
|
||||
val telemetryStrings = thatNode.getTelemetryStrings(tempInFahrenheit)
|
||||
|
||||
if (telemetryStrings.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
telemetryStrings.forEach { telemetryString ->
|
||||
Text(
|
||||
text = telemetryString,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val labelStyle =
|
||||
if (thatNode.isUnknownUser) {
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
} else {
|
||||
MaterialTheme.typography.labelSmall.copy(color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text(text = thatNode.user.hwModel.name, style = labelStyle)
|
||||
Text(text = thatNode.user.role.name, style = labelStyle)
|
||||
Text(text = thatNode.user.id.ifEmpty { "???" }, style = labelStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
fun NodeInfoSimplePreview() {
|
||||
AppTheme {
|
||||
val thisNode = NodePreviewParameterProvider().values.first()
|
||||
val thatNode = NodePreviewParameterProvider().values.last()
|
||||
NodeItem(thisNode = thisNode, thatNode = thatNode, 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()
|
||||
NodeItem(
|
||||
thisNode = thisNode,
|
||||
thatNode = thatNode,
|
||||
distanceUnits = 1,
|
||||
tempInFahrenheit = true,
|
||||
currentTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.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.layout.padding
|
||||
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.MaterialTheme.colorScheme
|
||||
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.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.google.protobuf.ByteString
|
||||
import org.meshtastic.core.model.Channel
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.CopyIconButton
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
|
||||
@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(8.dp))
|
||||
CopyIconButton(valueToCopy = keyString, modifier = Modifier.padding(start = 8.dp))
|
||||
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 colorScheme.StatusRed
|
||||
hasPKC -> Icons.Default.Lock to colorScheme.StatusGreen
|
||||
else ->
|
||||
ImageVector.vectorResource(com.geeksville.mesh.R.drawable.ic_lock_open_right_24) to
|
||||
colorScheme.StatusYellow
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.meshtastic.core.database.model.Node
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SimpleAlertDialog
|
||||
|
||||
@Composable
|
||||
fun NodeActionDialogs(
|
||||
node: Node,
|
||||
displayFavoriteDialog: Boolean,
|
||||
displayIgnoreDialog: Boolean,
|
||||
displayRemoveDialog: Boolean,
|
||||
onDismissMenuRequest: () -> Unit,
|
||||
onConfirmFavorite: (Node) -> Unit,
|
||||
onConfirmIgnore: (Node) -> Unit,
|
||||
onConfirmRemove: (Node) -> 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()
|
||||
onConfirmFavorite(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()
|
||||
onConfirmIgnore(node)
|
||||
},
|
||||
onDismiss = onDismissMenuRequest,
|
||||
)
|
||||
}
|
||||
if (displayRemoveDialog) {
|
||||
SimpleAlertDialog(
|
||||
title = R.string.remove,
|
||||
text = R.string.remove_node_text,
|
||||
onConfirm = {
|
||||
onDismissMenuRequest()
|
||||
onConfirmRemove(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()
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.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.material.icons.twotone.CloudDone
|
||||
import androidx.compose.material.icons.twotone.CloudOff
|
||||
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.TooltipAnchorPosition
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NodeStatusIcons(isThisNode: Boolean, isUnmessageable: Boolean, isFavorite: Boolean, isConnected: Boolean) {
|
||||
Row(modifier = Modifier.padding(4.dp)) {
|
||||
if (isThisNode) {
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(
|
||||
stringResource(
|
||||
if (isConnected) {
|
||||
R.string.connected
|
||||
} else {
|
||||
R.string.disconnected
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState(),
|
||||
) {
|
||||
if (isConnected) {
|
||||
@Suppress("MagicNumber")
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.CloudDone,
|
||||
contentDescription = stringResource(R.string.connected),
|
||||
modifier = Modifier.size(24.dp), // Smaller size for badge
|
||||
tint = MaterialTheme.colorScheme.StatusGreen,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.CloudOff,
|
||||
contentDescription = stringResource(R.string.not_connected),
|
||||
modifier = Modifier.size(24.dp), // Smaller size for badge
|
||||
tint = MaterialTheme.colorScheme.StatusRed,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 = MaterialTheme.colorScheme.StatusYellow,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun StatusIconsPreview() {
|
||||
NodeStatusIcons(isThisNode = true, isUnmessageable = true, isFavorite = true, isConnected = false)
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.SatelliteAlt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
fun SatelliteCountInfo(modifier: Modifier = Modifier, satCount: Int) {
|
||||
IconInfo(
|
||||
modifier = modifier,
|
||||
icon = Icons.TwoTone.SatelliteAlt,
|
||||
contentDescription = stringResource(org.meshtastic.core.strings.R.string.sats),
|
||||
text = "$satCount",
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun SatelliteCountInfoPreview() {
|
||||
AppTheme { SatelliteCountInfo(satCount = 5) }
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.node.components
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Route
|
||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItem
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
private const val COOL_DOWN_TIME_MS = 30000L
|
||||
|
||||
@Composable
|
||||
fun TracerouteButton(
|
||||
text: String = stringResource(id = R.string.traceroute),
|
||||
lastTracerouteTime: Long?,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val progress = remember { Animatable(0f) }
|
||||
|
||||
LaunchedEffect(lastTracerouteTime) {
|
||||
val timeSinceLast = System.currentTimeMillis() - (lastTracerouteTime ?: 0)
|
||||
if (timeSinceLast < COOL_DOWN_TIME_MS) {
|
||||
val remainingTime = COOL_DOWN_TIME_MS - timeSinceLast
|
||||
progress.snapTo(remainingTime / COOL_DOWN_TIME_MS.toFloat())
|
||||
progress.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = remainingTime.toInt(), easing = { it }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
TracerouteButton(text = text, progress = progress.value, onClick = onClick)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit) {
|
||||
val isCoolingDown = progress > 0f
|
||||
|
||||
val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round)
|
||||
|
||||
SettingsItem(
|
||||
text = text,
|
||||
enabled = !isCoolingDown,
|
||||
leadingIcon = Icons.Default.Route,
|
||||
trailingContent = {
|
||||
if (isCoolingDown) {
|
||||
CircularWavyProgressIndicator(
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(24.dp),
|
||||
stroke = stroke,
|
||||
trackStroke = stroke,
|
||||
wavelength = 8.dp,
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
if (!isCoolingDown) {
|
||||
onClick()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TracerouteButtonPreview() {
|
||||
AppTheme { TracerouteButton(text = "Traceroute", progress = .6f, onClick = {}) }
|
||||
}
|
||||
|
|
@ -60,9 +60,6 @@ import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
|
|||
import com.geeksville.mesh.android.gpsDisabled
|
||||
import com.geeksville.mesh.navigation.getNavRouteFrom
|
||||
import com.geeksville.mesh.ui.common.components.MainAppBar
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItem
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItemDetail
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch
|
||||
import com.geeksville.mesh.ui.settings.radio.RadioConfigItemList
|
||||
import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel
|
||||
import com.geeksville.mesh.ui.settings.radio.components.EditDeviceProfileDialog
|
||||
|
|
@ -75,6 +72,9 @@ import kotlinx.coroutines.delay
|
|||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.MultipleChoiceAlertDialog
|
||||
import org.meshtastic.core.ui.component.SettingsItem
|
||||
import org.meshtastic.core.ui.component.SettingsItemDetail
|
||||
import org.meshtastic.core.ui.component.SettingsItemSwitch
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
|
||||
import java.text.SimpleDateFormat
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package com.geeksville.mesh.ui.settings.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Android
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
|
||||
/** A clickable settings button item. */
|
||||
@Composable
|
||||
fun SettingsItem(
|
||||
text: String,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingIconTint: Color = LocalContentColor.current,
|
||||
trailingIcon: ImageVector? = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
trailingIconTint: Color = LocalContentColor.current,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
SettingsItem(
|
||||
text = text,
|
||||
textColor = textColor,
|
||||
enabled = enabled,
|
||||
leadingIcon = leadingIcon,
|
||||
leadingIconTint = leadingIconTint,
|
||||
trailingContent = { trailingIcon.Icon(trailingIconTint) },
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** A clickable settings button item. */
|
||||
@Composable
|
||||
fun SettingsItem(
|
||||
text: String,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingIconTint: Color = LocalContentColor.current,
|
||||
trailingContent: @Composable (() -> Unit),
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ClickableWrapper(enabled = enabled, onClick = onClick) {
|
||||
Content(
|
||||
leading = { leadingIcon.Icon(leadingIconTint) },
|
||||
text = text,
|
||||
textColor = textColor,
|
||||
trailing = trailingContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A toggleable settings switch item. */
|
||||
@Composable
|
||||
fun SettingsItemSwitch(
|
||||
checked: Boolean,
|
||||
text: String,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
enabled: Boolean = true,
|
||||
leadingIcon: ImageVector? = null,
|
||||
leadingIconTint: Color = LocalContentColor.current,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ClickableWrapper(enabled = enabled, onClick = onClick) {
|
||||
Content(
|
||||
leading = { leadingIcon.Icon(leadingIconTint) },
|
||||
text = text,
|
||||
textColor = textColor,
|
||||
trailing = { Switch(checked = checked, enabled = enabled, onCheckedChange = null) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A settings detail item. */
|
||||
@Composable
|
||||
fun SettingsItemDetail(
|
||||
text: String,
|
||||
textColor: Color = LocalContentColor.current,
|
||||
icon: ImageVector? = null,
|
||||
iconTint: Color = LocalContentColor.current,
|
||||
trailingText: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val content: @Composable ColumnScope.() -> Unit = {
|
||||
Content(
|
||||
leading = { icon.Icon(iconTint) },
|
||||
text = text,
|
||||
textColor = textColor,
|
||||
trailing = { trailingText?.let { Text(text = it) } },
|
||||
)
|
||||
}
|
||||
|
||||
if (onClick != null) {
|
||||
ClickableWrapper(enabled = enabled, onClick = onClick, content = content)
|
||||
} else {
|
||||
Column(content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/** A clickable Card wrapper used for all clickable settings items. */
|
||||
@Composable
|
||||
private fun ClickableWrapper(enabled: Boolean, onClick: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
CardDefaults.cardColors(containerColor = Color.Transparent, disabledContainerColor = Color.Transparent),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/** The row content to display for a settings item. */
|
||||
@Composable
|
||||
private fun Content(leading: @Composable () -> Unit, text: String, textColor: Color, trailing: @Composable () -> Unit) {
|
||||
ListItem(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
headlineContent = { Text(text = text, color = textColor) },
|
||||
leadingContent = { leading() },
|
||||
trailingContent = { trailing() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ImageVector?.Icon(tint: Color = LocalContentColor.current) =
|
||||
this?.let { Icon(imageVector = it, contentDescription = null, modifier = Modifier.size(24.dp), tint = tint) }
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SettingsItemPreview() {
|
||||
AppTheme { SettingsItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = true) {} }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SettingsItemDisabledPreview() {
|
||||
AppTheme { SettingsItem(text = "Text", leadingIcon = Icons.Rounded.Android, enabled = false) {} }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SettingsItemSwitchPreview() {
|
||||
AppTheme { SettingsItemSwitch(text = "Text", leadingIcon = Icons.Rounded.Android, checked = true) {} }
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun SettingsItemDetailPreview() {
|
||||
AppTheme { SettingsItemDetail(text = "Text 1", icon = Icons.Rounded.Android, trailingText = "Text2") }
|
||||
}
|
||||
|
|
@ -46,9 +46,9 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import com.geeksville.mesh.ui.node.components.NodeChip
|
||||
import org.meshtastic.core.database.entity.NodeEntity
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.NodeChip
|
||||
|
||||
/**
|
||||
* Composable screen for cleaning the node database. Allows users to specify criteria for deleting nodes. The list of
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.navigation.ConfigRoute
|
||||
import com.geeksville.mesh.navigation.ModuleRoute
|
||||
import com.geeksville.mesh.ui.settings.components.SettingsItem
|
||||
import com.geeksville.mesh.ui.settings.radio.components.WarningDialog
|
||||
import org.meshtastic.core.navigation.Route
|
||||
import org.meshtastic.core.navigation.SettingsRoutes
|
||||
import org.meshtastic.core.strings.R
|
||||
import org.meshtastic.core.ui.component.SettingsItem
|
||||
import org.meshtastic.core.ui.component.TitledCard
|
||||
import org.meshtastic.core.ui.theme.AppTheme
|
||||
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue