Clean up list item component API (#3465)

This commit is contained in:
Phil Oliver 2025-10-14 14:37:59 -04:00 committed by GitHub
parent 1b9f0f9736
commit 51ccc59b24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 407 additions and 476 deletions

View file

@ -35,8 +35,7 @@ import org.meshtastic.core.model.DeviceVersion
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.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
@ -56,13 +55,13 @@ fun AdministrationSection(
modifier: Modifier = Modifier,
) {
TitledCard(stringResource(id = R.string.administration), modifier = modifier) {
SettingsItem(
ListItem(
text = stringResource(id = R.string.request_metadata),
leadingIcon = Icons.Default.Memory,
trailingContent = {},
trailingIcon = null,
onClick = { onAction(NodeDetailAction.TriggerServiceAction(ServiceAction.GetDeviceMetadata(node.num))) },
)
SettingsItem(
ListItem(
text = stringResource(id = R.string.remote_admin),
leadingIcon = Icons.Default.Settings,
enabled = metricsState.isLocal || node.metadata != null,
@ -81,10 +80,12 @@ fun AdministrationSection(
else -> Icons.Default.ForkLeft
}
SettingsItemDetail(
ListItem(
text = stringResource(R.string.firmware_edition),
icon = icon,
leadingIcon = icon,
supportingText = it.name,
copyable = true,
trailingIcon = null,
)
}
firmwareVersion?.let { firmwareVersion ->
@ -94,25 +95,31 @@ fun AdministrationSection(
val deviceVersion = DeviceVersion(firmwareVersion.substringBeforeLast("."))
val statusColor = deviceVersion.determineFirmwareStatusColor(latestStable, latestAlpha)
SettingsItemDetail(
ListItem(
text = stringResource(R.string.installed_firmware_version),
icon = Icons.Default.Memory,
leadingIcon = Icons.Default.Memory,
supportingText = firmwareVersion.substringBeforeLast("."),
iconTint = statusColor,
copyable = true,
leadingIconTint = statusColor,
trailingIcon = null,
)
HorizontalDivider()
SettingsItemDetail(
ListItem(
text = stringResource(R.string.latest_stable_firmware),
icon = Icons.Default.Memory,
leadingIcon = Icons.Default.Memory,
supportingText = latestStable.id.substringBeforeLast(".").replace("v", ""),
iconTint = MaterialTheme.colorScheme.StatusGreen,
copyable = true,
leadingIconTint = MaterialTheme.colorScheme.StatusGreen,
trailingIcon = null,
onClick = { onFirmwareSelect(latestStable) },
)
SettingsItemDetail(
ListItem(
text = stringResource(R.string.latest_alpha_firmware),
icon = Icons.Default.Memory,
leadingIcon = Icons.Default.Memory,
supportingText = latestAlpha.id.substringBeforeLast(".").replace("v", ""),
iconTint = MaterialTheme.colorScheme.StatusYellow,
copyable = true,
leadingIconTint = MaterialTheme.colorScheme.StatusYellow,
trailingIcon = null,
onClick = { onFirmwareSelect(latestAlpha) },
)
}

View file

@ -35,8 +35,8 @@ import androidx.compose.ui.graphics.Color
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.SettingsItem
import org.meshtastic.core.ui.component.SettingsItemSwitch
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.NodeDetailAction
@ -67,33 +67,33 @@ fun DeviceActions(
onConfirmRemove = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.Remove(it))) },
)
TitledCard(title = stringResource(R.string.actions), modifier = modifier) {
SettingsItem(
ListItem(
text = stringResource(id = R.string.share_contact),
leadingIcon = Icons.Rounded.QrCode2,
trailingContent = {},
trailingIcon = null,
onClick = { onAction(NodeDetailAction.ShareContact) },
)
if (!isLocal) {
RemoteDeviceActions(node = node, lastTracerouteTime = lastTracerouteTime, onAction = onAction)
}
SettingsItemSwitch(
SwitchListItem(
text = stringResource(R.string.favorite),
leadingIcon = if (node.isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
leadingIconTint = if (node.isFavorite) Color.Yellow else LocalContentColor.current,
checked = node.isFavorite,
onClick = { displayFavoriteDialog = true },
)
SettingsItemSwitch(
SwitchListItem(
text = stringResource(R.string.ignore),
leadingIcon =
if (node.isIgnored) Icons.AutoMirrored.Outlined.VolumeMute else Icons.AutoMirrored.Default.VolumeUp,
checked = node.isIgnored,
onClick = { displayIgnoreDialog = true },
)
SettingsItem(
ListItem(
text = stringResource(id = R.string.remove),
leadingIcon = Icons.Rounded.Delete,
trailingContent = {},
trailingIcon = null,
onClick = { displayRemoveDialog = true },
)
}

View file

@ -45,7 +45,7 @@ import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@ -71,26 +71,28 @@ fun DeviceDetailsSection(state: MetricsState, modifier: Modifier = Modifier) {
Spacer(modifier = Modifier.height(16.dp))
SettingsItemDetail(
ListItem(
text = stringResource(R.string.hardware),
icon = Icons.Default.Router,
leadingIcon = Icons.Default.Router,
supportingText = hwModelName,
copyable = true,
trailingIcon = null,
)
SettingsItemDetail(
ListItem(
text =
if (isSupported) {
stringResource(R.string.supported)
} else {
stringResource(R.string.supported_by_community)
},
icon =
leadingIcon =
if (isSupported) {
Icons.TwoTone.Verified
} else {
ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
},
supportingText = null,
iconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
trailingIcon = null,
)
}
}

View file

@ -1,119 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import 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,
fontStyle = MaterialTheme.typography.titleLarge.fontStyle,
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,
style = MaterialTheme.typography.titleLarge,
)
}
@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
private fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinates(latitude = 37.7749, longitude = -122.4194, nodeName = "Test Node Name") }
}

View file

@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
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.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.core.net.toUri
import kotlinx.coroutines.launch
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.icon
import org.meshtastic.core.ui.theme.AppTheme
import timber.log.Timber
import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkedCoordinatesItem(node: Node) {
val context = LocalContext.current
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val ago = formatAgo(node.position.time)
val coordinates = GPSFormat.toDec(node.latitude, node.longitude)
BasicListItem(
text = stringResource(R.string.last_position_update),
leadingIcon = Icons.Default.LocationOn,
supportingText = "$ago$coordinates",
trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(),
onClick = {
val label = URLEncoder.encode(node.user.longName, "utf-8")
val uri = "geo:0,0?q=${node.latitude},${node.longitude}&z=17&label=$label".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")
}
},
onLongClick = {
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", coordinates))) }
},
)
}
@PreviewLightDark
@Composable
private fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinatesItem(Node(0)) }
}

View file

@ -25,7 +25,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
@ -57,7 +57,7 @@ fun MetricsSection(
if (nonPositionLogs.isNotEmpty()) {
TitledCard(title = stringResource(id = R.string.logs), modifier = modifier) {
nonPositionLogs.forEach { type ->
SettingsItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
ListItem(text = stringResource(type.titleRes), leadingIcon = type.icon) {
onAction(NodeDetailAction.Navigate(type.route))
}
}

View file

@ -45,7 +45,7 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.R
import org.meshtastic.core.ui.component.SettingsItemDetail
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@ -80,48 +80,59 @@ fun NodeDetailsSection(node: Node, modifier: Modifier = Modifier) {
@Composable
private fun MainNodeDetails(node: Node) {
SettingsItemDetail(
ListItem(
text = stringResource(R.string.long_name),
icon = Icons.TwoTone.Person,
leadingIcon = Icons.TwoTone.Person,
supportingText = node.user.longName.ifEmpty { "???" },
copyable = true,
trailingIcon = null,
)
SettingsItemDetail(
ListItem(
text = stringResource(R.string.short_name),
icon = Icons.Outlined.Person,
leadingIcon = Icons.Outlined.Person,
supportingText = node.user.shortName.ifEmpty { "???" },
copyable = true,
trailingIcon = null,
)
SettingsItemDetail(
ListItem(
text = stringResource(R.string.node_number),
icon = Icons.Default.Numbers,
leadingIcon = Icons.Default.Numbers,
supportingText = node.num.toUInt().toString(),
copyable = true,
trailingIcon = null,
)
SettingsItemDetail(
ListItem(
text = stringResource(R.string.user_id),
icon = Icons.Default.Person,
leadingIcon = Icons.Default.Person,
supportingText = node.user.id,
copyable = true,
trailingIcon = null,
)
SettingsItemDetail(
ListItem(
text = stringResource(R.string.role),
icon = Icons.Default.Work,
leadingIcon = Icons.Default.Work,
supportingText = node.user.role.name,
trailingIcon = null,
)
if (node.isEffectivelyUnmessageable) {
SettingsItemDetail(
ListItem(
text = stringResource(R.string.unmonitored_or_infrastructure),
icon = Icons.Outlined.NoCell,
supportingText = null,
leadingIcon = Icons.Outlined.NoCell,
trailingIcon = null,
)
}
if (node.deviceMetrics.uptimeSeconds > 0) {
SettingsItemDetail(
ListItem(
text = stringResource(R.string.uptime),
icon = Icons.Default.CheckCircle,
leadingIcon = Icons.Default.CheckCircle,
supportingText = formatUptime(node.deviceMetrics.uptimeSeconds),
trailingIcon = null,
)
}
SettingsItemDetail(
ListItem(
text = stringResource(R.string.node_sort_last_heard),
icon = Icons.Default.History,
leadingIcon = Icons.Default.History,
supportingText = formatAgo(node.lastHeard),
trailingIcon = null,
)
}

View file

@ -17,26 +17,19 @@
package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.SocialDistance
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.unit.dp
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.formatAgo
import org.meshtastic.core.model.util.toDistanceString
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.ListItem
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
@ -61,52 +54,39 @@ fun PositionSection(
// Current position coordinates (linked)
if (hasValidPosition) {
InlineMap(node = node, Modifier.fillMaxWidth().height(200.dp))
SettingsItemDetail(
text = stringResource(R.string.last_position_update),
icon = Icons.Default.LocationOn,
supportingContent = {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(formatAgo(node.position.time), style = MaterialTheme.typography.titleLarge)
LinkedCoordinates(
latitude = node.latitude,
longitude = node.longitude,
nodeName = node.user.longName,
)
}
},
)
LinkedCoordinatesItem(node)
}
// Distance (if available)
if (distance != null && distance.isNotEmpty()) {
SettingsItemDetail(
ListItem(
text = stringResource(R.string.node_sort_distance),
icon = Icons.Default.SocialDistance,
leadingIcon = Icons.Default.SocialDistance,
supportingText = distance,
copyable = true,
trailingIcon = null,
)
}
// Exchange position action
SettingsItem(
ListItem(
text = stringResource(id = R.string.exchange_position),
leadingIcon = Icons.Default.LocationOn,
trailingContent = {},
trailingIcon = null,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
)
// Node Map log
if (availableLogs.contains(LogsType.NODE_MAP)) {
SettingsItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
ListItem(text = stringResource(LogsType.NODE_MAP.titleRes), leadingIcon = LogsType.NODE_MAP.icon) {
onAction(NodeDetailAction.Navigate(LogsType.NODE_MAP.route))
}
}
// Positions Log
if (availableLogs.contains(LogsType.POSITIONS)) {
SettingsItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
ListItem(text = stringResource(LogsType.POSITIONS.titleRes), leadingIcon = LogsType.POSITIONS.icon) {
onAction(NodeDetailAction.Navigate(LogsType.POSITIONS.route))
}
}

View file

@ -24,24 +24,24 @@ 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.SettingsItem
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
@Composable
internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction: (NodeDetailAction) -> Unit) {
if (!node.isEffectivelyUnmessageable) {
SettingsItem(
ListItem(
text = stringResource(id = R.string.direct_message),
leadingIcon = Icons.AutoMirrored.TwoTone.Message,
trailingContent = {},
trailingIcon = null,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.DirectMessage(node))) },
)
}
SettingsItem(
ListItem(
text = stringResource(id = R.string.exchange_userinfo),
leadingIcon = Icons.Default.Person,
trailingContent = {},
trailingIcon = null,
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestUserInfo(node))) },
)
TracerouteButton(

View file

@ -35,7 +35,7 @@ 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.component.SettingsItem
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.theme.AppTheme
private const val COOL_DOWN_TIME_MS = 30000L
@ -70,7 +70,7 @@ private fun TracerouteButton(text: String, progress: Float, onClick: () -> Unit)
val stroke = Stroke(width = with(LocalDensity.current) { 2.dp.toPx() }, cap = StrokeCap.Round)
SettingsItem(
BasicListItem(
text = text,
enabled = !isCoolingDown,
leadingIcon = Icons.Default.Route,