refactor: migrate core UI and features to KMP, adopt Navigation 3 (#4750)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-10 12:29:47 -05:00 committed by GitHub
parent b1070321fe
commit d076361c55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 3106 additions and 1748 deletions

View file

@ -1,92 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@Preview(name = "Wind Dir -359°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionn359() {
PreviewWindDirectionItem(-359f)
}
@Preview(name = "Wind Dir 0°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection0() {
PreviewWindDirectionItem(0f)
}
@Preview(name = "Wind Dir 45°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection45() {
PreviewWindDirectionItem(45f)
}
@Preview(name = "Wind Dir 90°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection90() {
PreviewWindDirectionItem(90f)
}
@Preview(name = "Wind Dir 180°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection180() {
PreviewWindDirectionItem(180f)
}
@Preview(name = "Wind Dir 225°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection225() {
PreviewWindDirectionItem(225f)
}
@Preview(name = "Wind Dir 270°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection270() {
PreviewWindDirectionItem(270f)
}
@Preview(name = "Wind Dir 315°")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirection315() {
PreviewWindDirectionItem(315f)
}
@Preview(name = "Wind Dir -45")
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionN45() {
PreviewWindDirectionItem(-45f)
}
@Suppress("detekt:MagicNumber")
@Composable
private fun PreviewWindDirectionItem(windDirection: Float, windSpeed: String = "5 m/s") {
val normalizedBearing = (windDirection + 180) % 360
InfoCard(icon = Icons.Outlined.Navigation, text = "Wind", value = windSpeed, rotateIcon = normalizedBearing)
}

View file

@ -154,8 +154,8 @@ fun NodeListScreen(
visible = !isScrollInProgress && connectionState == ConnectionState.Connected && shareCapable,
alignment = Alignment.BottomEnd,
),
onImport = { uri ->
viewModel.handleScannedUri(uri.toString()) {
onImport = { uriString ->
viewModel.handleScannedUri(uriString) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
},

View file

@ -22,8 +22,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun ChannelInfo(
@ -39,9 +37,3 @@ fun ChannelInfo(
contentColor = contentColor,
)
}
@PreviewLightDark
@Composable
private fun ChannelInfoPreview() {
AppTheme { ChannelInfo(channel = 2) }
}

View file

@ -52,7 +52,6 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
@ -70,7 +69,6 @@ import org.meshtastic.core.resources.compass_uncertainty_unknown
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
import kotlin.math.cos
@ -422,28 +420,3 @@ private fun Float.normalizeDegrees(): Float {
val normalized = this % 360f
return if (normalized < 0f) normalized + 360f else normalized
}
@Preview(showBackground = true)
@Composable
@Suppress("MagicNumber")
private fun CompassSheetPreview() {
AppTheme {
CompassSheetContent(
uiState =
CompassUiState(
targetName = "Sample Node",
heading = 45f,
bearing = 90f,
distanceText = "1.2 km",
bearingText = "90°",
lastUpdateText = "0h 3m 10s ago",
errorRadiusText = "150 m",
angularErrorDeg = 12f,
isAligned = false,
),
onRequestLocationPermission = {},
onOpenLocationSettings = {},
onRequestPosition = {},
)
}
}

View file

@ -18,7 +18,6 @@ package org.meshtastic.feature.node.component
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.OutlinedIconButton
@ -30,13 +29,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
internal const val COOL_DOWN_TIME_MS = 30000L
internal const val REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS = 180000L // 3 minutes
@ -134,13 +129,3 @@ private fun CooldownBaseButton(
)
}
}
@Preview(showBackground = true)
@Composable
private fun CooldownOutlinedIconButtonPreview() {
AppTheme {
CooldownOutlinedIconButton(onClick = {}, cooldownTimestamp = nowMillis - 15000L) {
Icon(imageVector = MeshtasticIcons.Refresh, contentDescription = null)
}
}
}

View file

@ -37,9 +37,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceHardware
@ -130,7 +130,7 @@ private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifi
val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg"
val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg"
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
model = ImageRequest.Builder(LocalPlatformContext.current).data(imageUrl).build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder =

View file

@ -22,11 +22,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.distance
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun DistanceInfo(
@ -43,9 +41,3 @@ fun DistanceInfo(
contentColor = contentColor,
)
}
@PreviewLightDark
@Composable
private fun DistanceInfoPreview() {
AppTheme { DistanceInfo(distance = "423 mi.") }
}

View file

@ -20,7 +20,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
@ -48,9 +47,3 @@ fun ElevationInfo(
contentColor = contentColor,
)
}
@Composable
@Preview
private fun ElevationInfoPreview() {
MaterialTheme { ElevationInfo(altitude = 100, system = Config.DisplayConfig.DisplayUnits.METRIC, suffix = "ASL") }
}

View file

@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
@ -80,67 +81,115 @@ internal fun EnvironmentMetrics(
if (!temp.isNaN()) {
add(
VectorMetricInfo(
Res.string.temperature,
temp.toTempString(isFahrenheit),
Icons.Rounded.Thermostat,
label = Res.string.temperature,
value = temp.toTempString(isFahrenheit),
icon = Icons.Rounded.Thermostat,
),
)
}
}
relative_humidity?.let { rh ->
add(VectorMetricInfo(Res.string.humidity, "%.0f%%".format(rh), Icons.Rounded.WaterDrop))
add(
VectorMetricInfo(
Res.string.humidity,
"${NumberFormatter.format(rh, 0)}%",
Icons.Rounded.WaterDrop,
),
)
}
barometric_pressure?.let { bp ->
add(VectorMetricInfo(Res.string.pressure, "%.0f hPa".format(bp), Icons.Rounded.Speed))
add(
VectorMetricInfo(
Res.string.pressure,
"${NumberFormatter.format(bp, 0)} hPa",
Icons.Rounded.Speed,
),
)
}
gas_resistance?.let { gr ->
add(VectorMetricInfo(Res.string.gas_resistance, "%.0f MΩ".format(gr), Icons.Rounded.BlurOn))
add(
VectorMetricInfo(
label = Res.string.gas_resistance,
value = "${NumberFormatter.format(gr, 0)}",
icon = Icons.Rounded.BlurOn,
),
)
}
voltage?.let { v ->
add(VectorMetricInfo(Res.string.voltage, "%.2fV".format(v), Icons.Rounded.Bolt))
add(
VectorMetricInfo(
label = Res.string.voltage,
value = "${NumberFormatter.format(v, 2)}V",
icon = Icons.Rounded.Bolt,
),
)
}
current?.let { c ->
add(VectorMetricInfo(Res.string.current, "%.1fmA".format(c), Icons.Rounded.Power))
add(
VectorMetricInfo(
label = Res.string.current,
value = "${NumberFormatter.format(c, 1)}mA",
icon = Icons.Rounded.Power,
),
)
}
iaq?.let { i -> add(VectorMetricInfo(Res.string.iaq, i.toString(), Icons.Rounded.Air)) }
distance?.let { d ->
add(
VectorMetricInfo(
Res.string.distance,
d.toSmallDistanceString(displayUnits),
Icons.Rounded.Height,
label = Res.string.distance,
value = d.toSmallDistanceString(displayUnits),
icon = Icons.Rounded.Height,
),
)
}
lux?.let { l ->
add(VectorMetricInfo(Res.string.lux, "%.0f lx".format(l), Icons.Rounded.LightMode))
add(
VectorMetricInfo(
label = Res.string.lux,
value = "${NumberFormatter.format(l, 0)} lx",
icon = Icons.Rounded.LightMode,
),
)
}
uv_lux?.let { uvl ->
add(VectorMetricInfo(Res.string.uv_lux, "%.0f lx".format(uvl), Icons.Rounded.LightMode))
add(
VectorMetricInfo(
label = Res.string.uv_lux,
value = "${NumberFormatter.format(uvl, 0)} lx",
icon = Icons.Rounded.LightMode,
),
)
}
wind_speed?.let { ws ->
@Suppress("MagicNumber")
val normalizedBearing = ((wind_direction ?: 0) + 180) % 360
add(
VectorMetricInfo(
Res.string.wind,
ws.toFloat().toSpeedString(displayUnits),
Icons.Outlined.Navigation,
normalizedBearing.toFloat(),
label = Res.string.wind,
value = ws.toFloat().toSpeedString(displayUnits),
icon = Icons.Outlined.Navigation,
rotateIcon = normalizedBearing.toFloat(),
),
)
}
weight?.let { w ->
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(w), Icons.Rounded.Scale))
add(
VectorMetricInfo(
label = Res.string.weight,
value = "${NumberFormatter.format(w, 2)} kg",
icon = Icons.Rounded.Scale,
),
)
}
if (temperature != null && relative_humidity != null) {
val dewPoint = UnitConversions.calculateDewPoint(temperature!!, relative_humidity!!)
if (!dewPoint.isNaN()) {
add(
DrawableMetricInfo(
Res.string.dew_point,
dewPoint.toTempString(isFahrenheit),
Res.drawable.ic_dew_point,
label = Res.string.dew_point,
value = dewPoint.toTempString(isFahrenheit),
icon = Res.drawable.ic_dew_point,
),
)
}
@ -149,27 +198,21 @@ internal fun EnvironmentMetrics(
if (!st.isNaN()) {
add(
DrawableMetricInfo(
Res.string.soil_temperature,
st.toTempString(isFahrenheit),
Res.drawable.ic_soil_temperature,
label = Res.string.soil_temperature,
value = st.toTempString(isFahrenheit),
icon = Res.drawable.ic_soil_temperature,
),
)
}
}
soil_moisture?.let { sm ->
add(
DrawableMetricInfo(
Res.string.soil_moisture,
"%d%%".format(sm),
Res.drawable.ic_soil_moisture,
),
)
add(DrawableMetricInfo(Res.string.soil_moisture, "$sm%", Res.drawable.ic_soil_moisture))
}
radiation?.let { r ->
add(
DrawableMetricInfo(
label = Res.string.radiation,
value = "%.1f µR/h".format(r),
value = "${NumberFormatter.format(r, 1)} µR/h",
icon = Res.drawable.ic_radioactive,
),
)

View file

@ -16,8 +16,6 @@
*/
package org.meshtastic.feature.node.component
import android.content.ActivityNotFoundException
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -35,26 +33,19 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import com.mikepenz.markdown.m3.Markdown
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.download
import org.meshtastic.core.resources.error_no_app_to_handle_link
import org.meshtastic.core.resources.view_release
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.util.rememberOpenUrl
@Composable
fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val openUrl = rememberOpenUrl()
Column(
modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp).fillMaxWidth(),
@ -64,34 +55,12 @@ fun FirmwareReleaseSheetContent(firmwareRelease: FirmwareRelease, modifier: Modi
Text(text = "Version: ${firmwareRelease.id}", style = MaterialTheme.typography.bodyMedium)
Markdown(modifier = Modifier.padding(8.dp), content = firmwareRelease.releaseNotes)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.pageUrl.toUri())
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
scope.launch { context.showToast(Res.string.error_no_app_to_handle_link) }
Logger.e(e) { "Failed to handle release page URL" }
}
},
modifier = Modifier.weight(1f),
) {
Button(onClick = { openUrl(firmwareRelease.pageUrl) }, modifier = Modifier.weight(1f)) {
Icon(imageVector = Icons.Rounded.Link, contentDescription = stringResource(Res.string.view_release))
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(Res.string.view_release))
}
Button(
onClick = {
try {
val intent = Intent(Intent.ACTION_VIEW, firmwareRelease.zipUrl.toUri())
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
scope.launch { context.showToast(Res.string.error_no_app_to_handle_link) }
Logger.e(e) { "Failed to handle release zip URL" }
}
},
modifier = Modifier.weight(1f),
) {
Button(onClick = { openUrl(firmwareRelease.zipUrl) }, modifier = Modifier.weight(1f)) {
Icon(imageVector = Icons.Rounded.Download, contentDescription = stringResource(Res.string.download))
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(Res.string.download))

View file

@ -22,11 +22,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.hops_away
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = MaterialTheme.colorScheme.onSurface) {
@ -39,9 +37,3 @@ fun HopsInfo(hops: Int, modifier: Modifier = Modifier, contentColor: Color = Mat
contentColor = contentColor,
)
}
@PreviewLightDark
@Composable
private fun HopsInfoPreview() {
AppTheme { HopsInfo(hops = 3) }
}

View file

@ -28,10 +28,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
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
@ -62,11 +59,3 @@ fun IconInfo(
content()
}
}
@Composable
@Preview
private fun IconInfoPreview() {
MaterialTheme {
IconInfo(icon = MeshtasticIcons.Elevation, contentDescription = "Elevation", content = { Text(text = "100") })
}
}

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@ -38,7 +37,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.semantics.Role
@ -51,6 +49,7 @@ import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.thenIf
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@ -74,9 +73,7 @@ fun InfoCard(
.defaultMinSize(minHeight = 48.dp)
.clip(shape)
.combinedClickable(
onLongClick = {
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(text, value))) }
},
onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(value, text)) } },
onLongClickLabel = copyLabel,
onClick = {},
role = Role.Button,

View file

@ -20,14 +20,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ic_antenna
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo
@Composable
@ -46,9 +43,3 @@ fun LastHeardInfo(
contentColor = contentColor,
)
}
@PreviewLightDark
@Composable
private fun LastHeardInfoPreview() {
AppTheme { LastHeardInfo(lastHeard = nowSeconds.toInt() - 8600) }
}

View file

@ -16,9 +16,6 @@
*/
package org.meshtastic.feature.node.component
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Intent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
@ -26,18 +23,13 @@ import androidx.compose.material.icons.rounded.LocationOn
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.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.core.net.toUri
import co.touchlab.kermit.Logger
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.GPSFormat
@ -50,10 +42,10 @@ import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.icon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.core.ui.util.rememberOpenMap
import org.meshtastic.proto.Config
import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
@ -61,9 +53,9 @@ fun LinkedCoordinatesItem(
node: Node,
displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
) {
val context = LocalContext.current
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val openMap = rememberOpenMap()
val ago = formatAgo(node.position.time)
val coordinates = GPSFormat.toDec(node.latitude, node.longitude)
@ -82,9 +74,7 @@ fun LinkedCoordinatesItem(
customActions =
listOf(
CustomAccessibilityAction(copyLabel) {
coroutineScope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", coordinates)))
}
coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) }
true
},
)
@ -93,27 +83,7 @@ fun LinkedCoordinatesItem(
leadingIcon = Icons.Rounded.LocationOn,
supportingText = "$ago$coordinates$elevationText",
trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(),
onClick = {
val label = URLEncoder.encode(node.user.long_name ?: "", "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)
}
} catch (ex: ActivityNotFoundException) {
Logger.d { "Failed to open geo intent: $ex" }
}
},
onLongClick = {
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText("", coordinates))) }
},
onClick = { openMap(node.latitude, node.longitude, node.user.long_name ?: "") },
onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(coordinates, copyLabel)) } },
)
}
@PreviewLightDark
@Composable
private fun LinkedCoordinatesPreview() {
AppTheme { LinkedCoordinatesItem(Node(0)) }
}

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@ -40,7 +39,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.semantics.Role
@ -55,6 +53,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.createClipEntry
@Composable
internal fun SectionCard(
@ -102,9 +101,7 @@ internal fun InfoItem(
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp) // Minimum touch target height
.combinedClickable(
onLongClick = {
coroutineScope.launch { clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, value))) }
},
onLongClick = { coroutineScope.launch { clipboard.setClipEntry(createClipEntry(value, label)) } },
onLongClickLabel = copyLabel, // Clear intent for accessibility
onClick = {},
role = Role.Button,

View file

@ -18,8 +18,6 @@
package org.meshtastic.feature.node.component
import android.content.ClipData
import android.util.Base64
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@ -42,7 +40,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
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.semantics.Role
@ -51,10 +48,10 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
@ -78,7 +75,6 @@ import org.meshtastic.core.resources.supported
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.user_id
import org.meshtastic.core.resources.via_mqtt
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.ArrowCircleUp
import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.Cloud
@ -90,7 +86,7 @@ import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Person
import org.meshtastic.core.ui.icon.Verified
import org.meshtastic.core.ui.icon.role
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.formatAgo
@Composable
@ -321,7 +317,7 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
if (isMismatch) {
stringResource(Res.string.error)
} else {
Base64.encodeToString(publicKeyBytes, Base64.DEFAULT).trim()
Base64Factory.encode(publicKeyBytes).trim()
}
val label = stringResource(Res.string.public_key)
val copyLabel = stringResource(Res.string.copy)
@ -333,9 +329,7 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
.combinedClickable(
onLongClick = {
if (!isMismatch) {
coroutineScope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, publicKeyBase64)))
}
coroutineScope.launch { clipboard.setClipEntry(createClipEntry(publicKeyBase64, label)) }
}
},
onLongClickLabel = copyLabel,
@ -373,12 +367,3 @@ private fun PublicKeyItem(publicKeyBytes: ByteArray) {
)
}
}
@PreviewLightDark
@Composable
private fun NodeDetailsSectionPreview() {
AppTheme {
val node = NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
NodeDetailsSection(node = node)
}
}

View file

@ -56,8 +56,6 @@ import androidx.compose.ui.platform.LocalFocusManager
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.Preview
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.NodeSortOption
@ -73,7 +71,6 @@ import org.meshtastic.core.resources.node_filter_show_ignored
import org.meshtastic.core.resources.node_filter_title
import org.meshtastic.core.resources.node_sort_button
import org.meshtastic.core.resources.node_sort_title
import org.meshtastic.core.ui.theme.AppTheme
@Suppress("LongParameterList")
@Composable
@ -139,6 +136,20 @@ fun NodeFilterTextField(
}
}
data class NodeFilterToggles(
val includeUnknown: Boolean,
val onToggleIncludeUnknown: () -> Unit,
val excludeInfrastructure: Boolean,
val onToggleExcludeInfrastructure: () -> Unit,
val onlyOnline: Boolean,
val onToggleOnlyOnline: () -> Unit,
val onlyDirect: Boolean,
val onToggleOnlyDirect: () -> Unit,
val showIgnored: Boolean,
val onToggleShowIgnored: () -> Unit,
val ignoredNodeCount: Int,
)
@Composable
private fun NodeFilterTextField(filterText: String, onTextChange: (String) -> Unit, modifier: Modifier = Modifier) {
val focusManager = LocalFocusManager.current
@ -295,42 +306,3 @@ private fun DropdownMenuCheck(
text = { Text(text = text) },
)
}
@PreviewLightDark
@Preview(name = "Large Font", fontScale = 2f)
@Composable
private fun NodeFilterTextFieldPreview() {
AppTheme {
NodeFilterTextField(
filterText = "Filter text",
onTextChange = {},
currentSortOption = NodeSortOption.LAST_HEARD,
onSortSelect = {},
includeUnknown = false,
onToggleIncludeUnknown = {},
excludeInfrastructure = false,
onToggleExcludeInfrastructure = {},
onlyOnline = false,
onToggleOnlyOnline = {},
onlyDirect = false,
onToggleOnlyDirect = {},
showIgnored = false,
onToggleShowIgnored = {},
ignoredNodeCount = 0,
)
}
}
data class NodeFilterToggles(
val includeUnknown: Boolean,
val onToggleIncludeUnknown: () -> Unit,
val excludeInfrastructure: Boolean,
val onToggleExcludeInfrastructure: () -> Unit,
val onlyOnline: Boolean,
val onToggleOnlyOnline: () -> Unit,
val onlyDirect: Boolean,
val onToggleOnlyDirect: () -> Unit,
val showIgnored: Boolean,
val onToggleShowIgnored: () -> Unit,
val ignoredNodeCount: Int,
)

View file

@ -18,7 +18,6 @@
package org.meshtastic.feature.node.component
import android.content.res.Configuration
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@ -47,8 +46,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.ConnectionState
@ -89,11 +86,9 @@ import org.meshtastic.core.ui.component.SoilTemperatureInfo
import org.meshtastic.core.ui.component.TemperatureInfo
import org.meshtastic.core.ui.component.TransportIcon
import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.icon.AirUtilization
import org.meshtastic.core.ui.icon.ChannelUtilization
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.Config
private const val ACTIVE_ALPHA = 0.5f
@ -462,49 +457,3 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor)
}
}
@Composable
@Preview(showBackground = false, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSimplePreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last().copy(lastHeard = 0)
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoStatusPreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode =
NodePreviewParameterProvider().values.last().copy(nodeStatus = "Going to the farm.. to grow wheat.")
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoSignalPreview() {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
val thatNode = NodePreviewParameterProvider().values.last().copy(hopsAway = 0, snr = 5.5f, rssi = -100)
NodeItem(thisNode = thisNode, thatNode = thatNode, 0, true, connectionState = ConnectionState.Connected)
}
}
@Composable
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
fun NodeInfoPreview(@PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node) {
AppTheme {
val thisNode = NodePreviewParameterProvider().values.first()
NodeItem(
thisNode = thisNode,
thatNode = thatNode,
distanceUnits = 1,
tempInFahrenheit = true,
connectionState = ConnectionState.Connected,
)
}
}

View file

@ -33,7 +33,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
@ -194,15 +193,3 @@ private fun StatusBadge(
)
}
}
@Preview
@Composable
private fun StatusIconsPreview() {
NodeStatusIcons(
isThisNode = true,
isUnmessageable = true,
isFavorite = true,
isMuted = true,
connectionState = ConnectionState.Connected,
)
}

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
@ -41,22 +42,59 @@ import org.meshtastic.feature.node.model.VectorMetricInfo
* intended.
*/
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
internal fun PowerMetrics(node: Node) {
val metrics =
remember(node.powerMetrics) {
buildList {
with(node.powerMetrics) {
if ((ch1_voltage ?: 0f) != 0f) {
add(VectorMetricInfo(Res.string.channel_1, "%.2fV".format(ch1_voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_1, "%.1fmA".format(ch1_current), Icons.Rounded.Power))
add(
VectorMetricInfo(
Res.string.channel_1,
"${NumberFormatter.format(ch1_voltage ?: 0f, 2)}V",
Icons.Rounded.Bolt,
),
)
add(
VectorMetricInfo(
Res.string.channel_1,
"${NumberFormatter.format(ch1_current ?: 0f, 1)}mA",
Icons.Rounded.Power,
),
)
}
if ((ch2_voltage ?: 0f) != 0f) {
add(VectorMetricInfo(Res.string.channel_2, "%.2fV".format(ch2_voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_2, "%.1fmA".format(ch2_current), Icons.Rounded.Power))
add(
VectorMetricInfo(
Res.string.channel_2,
"${NumberFormatter.format(ch2_voltage ?: 0f, 2)}V",
Icons.Rounded.Bolt,
),
)
add(
VectorMetricInfo(
Res.string.channel_2,
"${NumberFormatter.format(ch2_current ?: 0f, 1)}mA",
Icons.Rounded.Power,
),
)
}
if ((ch3_voltage ?: 0f) != 0f) {
add(VectorMetricInfo(Res.string.channel_3, "%.2fV".format(ch3_voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_3, "%.1fmA".format(ch3_current), Icons.Rounded.Power))
add(
VectorMetricInfo(
Res.string.channel_3,
"${NumberFormatter.format(ch3_voltage ?: 0f, 2)}V",
Icons.Rounded.Bolt,
),
)
add(
VectorMetricInfo(
Res.string.channel_3,
"${NumberFormatter.format(ch3_current ?: 0f, 1)}mA",
Icons.Rounded.Power,
),
)
}
}
}

View file

@ -22,11 +22,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.PreviewLightDark
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sats
import org.meshtastic.core.ui.theme.AppTheme
@Composable
fun SatelliteCountInfo(
@ -43,9 +41,3 @@ fun SatelliteCountInfo(
contentColor = contentColor,
)
}
@PreviewLightDark
@Composable
private fun SatelliteCountInfoPreview() {
AppTheme { SatelliteCountInfo(satCount = 5) }
}

View file

@ -19,7 +19,6 @@ package org.meshtastic.feature.node.detail
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -34,7 +33,6 @@ import kotlinx.coroutines.launch
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.UiText
import org.meshtastic.feature.node.component.NodeMenuAction
@ -68,9 +66,7 @@ open class NodeDetailViewModel(
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : ViewModel() {
private val nodeIdFromRoute: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetail>().destNum }
.getOrElse { runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull() }
private val nodeIdFromRoute: Int? = savedStateHandle.get<Int>("destNum")
private val manualNodeId = MutableStateFlow<Int?>(null)
private val activeNodeId =