refactor(ui): compose resources, domain layer (#4628)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-22 21:39:50 -06:00 committed by GitHub
parent 96adc70401
commit 2676a51647
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
322 changed files with 3031 additions and 2790 deletions

View file

@ -32,16 +32,16 @@ import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.firmware
import org.meshtastic.core.resources.firmware_edition
import org.meshtastic.core.resources.installed_firmware_version
import org.meshtastic.core.resources.latest_alpha_firmware
import org.meshtastic.core.resources.latest_stable_firmware
import org.meshtastic.core.resources.remote_admin
import org.meshtastic.core.resources.request_metadata
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.administration
import org.meshtastic.core.strings.firmware
import org.meshtastic.core.strings.firmware_edition
import org.meshtastic.core.strings.installed_firmware_version
import org.meshtastic.core.strings.latest_alpha_firmware
import org.meshtastic.core.strings.latest_stable_firmware
import org.meshtastic.core.strings.remote_admin
import org.meshtastic.core.strings.request_metadata
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange

View file

@ -56,20 +56,20 @@ 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
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.compass_bearing
import org.meshtastic.core.strings.compass_bearing_na
import org.meshtastic.core.strings.compass_distance
import org.meshtastic.core.strings.compass_location_disabled
import org.meshtastic.core.strings.compass_no_location_fix
import org.meshtastic.core.strings.compass_no_location_permission
import org.meshtastic.core.strings.compass_no_magnetometer
import org.meshtastic.core.strings.compass_title
import org.meshtastic.core.strings.compass_uncertainty
import org.meshtastic.core.strings.compass_uncertainty_unknown
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.exchange_position
import org.meshtastic.core.strings.last_position_update
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.compass_bearing
import org.meshtastic.core.resources.compass_bearing_na
import org.meshtastic.core.resources.compass_distance
import org.meshtastic.core.resources.compass_location_disabled
import org.meshtastic.core.resources.compass_no_location_fix
import org.meshtastic.core.resources.compass_no_location_permission
import org.meshtastic.core.resources.compass_no_magnetometer
import org.meshtastic.core.resources.compass_title
import org.meshtastic.core.resources.compass_uncertainty
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

View file

@ -47,14 +47,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.actions
import org.meshtastic.core.strings.direct_message
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.share_contact
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.actions
import org.meshtastic.core.resources.direct_message
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.share_contact
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchListItem
import org.meshtastic.feature.node.model.LogsType

View file

@ -36,21 +36,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device
import org.meshtastic.core.strings.hardware
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.supported_by_community
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.ic_unverified
import org.meshtastic.core.resources.img_hw_unknown
import org.meshtastic.core.resources.supported
import org.meshtastic.core.resources.supported_by_community
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@ -119,7 +118,7 @@ private fun SupportStatusItem(isSupported: Boolean) {
if (isSupported) {
Icons.TwoTone.Verified
} else {
ImageVector.vectorResource(org.meshtastic.feature.node.R.drawable.unverified)
org.jetbrains.compose.resources.vectorResource(org.meshtastic.core.resources.Res.drawable.ic_unverified)
},
leadingIconTint = if (isSupported) colorScheme.StatusGreen else colorScheme.StatusRed,
trailingIcon = null,
@ -134,9 +133,12 @@ private fun DeviceHardwareImage(deviceHardware: DeviceHardware, modifier: Modifi
model = ImageRequest.Builder(LocalContext.current).data(imageUrl).build(),
contentScale = ContentScale.Inside,
contentDescription = deviceHardware.displayName,
placeholder = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
error = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
fallback = painterResource(org.meshtastic.feature.node.R.drawable.hw_unknown),
placeholder =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
error =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
fallback =
org.jetbrains.compose.resources.painterResource(org.meshtastic.core.resources.Res.drawable.img_hw_unknown),
modifier = modifier.padding(16.dp),
)
}

View file

@ -24,8 +24,8 @@ 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.strings.Res
import org.meshtastic.core.strings.distance
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.distance
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -24,9 +24,9 @@ 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
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.altitude
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.altitude
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.Config

View file

@ -40,23 +40,27 @@ import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.UnitConversions.toTempString
import org.meshtastic.core.model.util.toSmallDistanceString
import org.meshtastic.core.model.util.toSpeedString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.dew_point
import org.meshtastic.core.strings.distance
import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.pressure
import org.meshtastic.core.strings.radiation
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.strings.weight
import org.meshtastic.core.strings.wind
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.dew_point
import org.meshtastic.core.resources.distance
import org.meshtastic.core.resources.gas_resistance
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.ic_dew_point
import org.meshtastic.core.resources.ic_radioactive
import org.meshtastic.core.resources.ic_soil_moisture
import org.meshtastic.core.resources.ic_soil_temperature
import org.meshtastic.core.resources.lux
import org.meshtastic.core.resources.pressure
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
import org.meshtastic.core.resources.uv_lux
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.resources.weight
import org.meshtastic.core.resources.wind
import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
import org.meshtastic.proto.Config
@ -136,7 +140,7 @@ internal fun EnvironmentMetrics(
DrawableMetricInfo(
Res.string.dew_point,
dewPoint.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.ic_outlined_dew_point_24,
Res.drawable.ic_dew_point,
),
)
}
@ -147,7 +151,7 @@ internal fun EnvironmentMetrics(
DrawableMetricInfo(
Res.string.soil_temperature,
st.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.soil_temperature,
Res.drawable.ic_soil_temperature,
),
)
}
@ -157,16 +161,16 @@ internal fun EnvironmentMetrics(
DrawableMetricInfo(
Res.string.soil_moisture,
"%d%%".format(sm),
org.meshtastic.feature.node.R.drawable.soil_moisture,
Res.drawable.ic_soil_moisture,
),
)
}
radiation?.let { r ->
add(
DrawableMetricInfo(
Res.string.radiation,
"%.1f µR/h".format(r),
org.meshtastic.feature.node.R.drawable.ic_filled_radioactive_24,
label = Res.string.radiation,
value = "%.1f µR/h".format(r),
icon = Res.drawable.ic_radioactive,
),
)
}

View file

@ -45,10 +45,10 @@ 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.strings.Res
import org.meshtastic.core.strings.download
import org.meshtastic.core.strings.error_no_app_to_handle_link
import org.meshtastic.core.strings.view_release
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
@Composable

View file

@ -24,8 +24,8 @@ 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.strings.Res
import org.meshtastic.core.strings.hops_away
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.hops_away
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -17,7 +17,6 @@
package org.meshtastic.feature.node.component
import android.content.ClipData
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@ -42,15 +41,16 @@ 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.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.ui.util.thenIf
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class)
@ -58,9 +58,9 @@ import org.meshtastic.core.ui.util.thenIf
fun InfoCard(
text: String,
value: String,
icon: ImageVector? = null,
@DrawableRes iconRes: Int? = null,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
iconRes: DrawableResource? = null,
rotateIcon: Float = 0f,
) {
val clipboard: Clipboard = LocalClipboard.current
@ -120,6 +120,6 @@ fun InfoCard(
}
@Composable
internal fun DrawableInfoCard(@DrawableRes iconRes: Int, text: String, value: String, rotateIcon: Float = 0f) {
internal fun DrawableInfoCard(iconRes: DrawableResource, text: String, value: String, rotateIcon: Float = 0f) {
InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
}

View file

@ -20,14 +20,14 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
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.strings.Res
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.ui.R
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
@ -40,7 +40,7 @@ fun LastHeardInfo(
) {
IconInfo(
modifier = modifier,
icon = ImageVector.vectorResource(id = R.drawable.ic_antenna_24),
icon = vectorResource(Res.drawable.ic_antenna),
contentDescription = stringResource(Res.string.node_sort_last_heard),
label = if (showLabel) stringResource(Res.string.node_sort_last_heard) else null,
text = formatAgo(lastHeard),

View file

@ -44,10 +44,10 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.GPSFormat
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.last_position_update
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
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

View file

@ -53,8 +53,8 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
@Composable
internal fun SectionCard(

View file

@ -58,26 +58,26 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.copy
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.encryption_error
import org.meshtastic.core.strings.encryption_error_text
import org.meshtastic.core.strings.error
import org.meshtastic.core.strings.hops_away
import org.meshtastic.core.strings.node_id
import org.meshtastic.core.strings.node_number
import org.meshtastic.core.strings.node_sort_last_heard
import org.meshtastic.core.strings.public_key
import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.short_name
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.status_message
import org.meshtastic.core.strings.supported
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_id
import org.meshtastic.core.strings.via_mqtt
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.copy
import org.meshtastic.core.resources.details
import org.meshtastic.core.resources.encryption_error
import org.meshtastic.core.resources.encryption_error_text
import org.meshtastic.core.resources.error
import org.meshtastic.core.resources.hops_away
import org.meshtastic.core.resources.node_id
import org.meshtastic.core.resources.node_number
import org.meshtastic.core.resources.node_sort_last_heard
import org.meshtastic.core.resources.public_key
import org.meshtastic.core.resources.role
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.short_name
import org.meshtastic.core.resources.snr
import org.meshtastic.core.resources.status_message
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

View file

@ -61,18 +61,18 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.desc_node_filter_clear
import org.meshtastic.core.strings.node_filter_exclude_infrastructure
import org.meshtastic.core.strings.node_filter_ignored
import org.meshtastic.core.strings.node_filter_include_unknown
import org.meshtastic.core.strings.node_filter_only_direct
import org.meshtastic.core.strings.node_filter_only_online
import org.meshtastic.core.strings.node_filter_placeholder
import org.meshtastic.core.strings.node_filter_show_ignored
import org.meshtastic.core.strings.node_filter_title
import org.meshtastic.core.strings.node_sort_button
import org.meshtastic.core.strings.node_sort_title
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.desc_node_filter_clear
import org.meshtastic.core.resources.node_filter_exclude_infrastructure
import org.meshtastic.core.resources.node_filter_ignored
import org.meshtastic.core.resources.node_filter_include_unknown
import org.meshtastic.core.resources.node_filter_only_direct
import org.meshtastic.core.resources.node_filter_only_online
import org.meshtastic.core.resources.node_filter_placeholder
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")

View file

@ -55,15 +55,15 @@ import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.elevation_suffix
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.unknown_username
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_utilization
import org.meshtastic.core.strings.channel_utilization
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.unknown_username
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.ui.component.AirQualityInfo
import org.meshtastic.core.ui.component.ChannelInfo
import org.meshtastic.core.ui.component.DistanceInfo

View file

@ -37,16 +37,16 @@ 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
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting
import org.meshtastic.core.resources.device_sleeping
import org.meshtastic.core.resources.disconnected
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.unmessageable
import org.meshtastic.core.resources.unmonitored_or_infrastructure
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.connected
import org.meshtastic.core.strings.connecting
import org.meshtastic.core.strings.device_sleeping
import org.meshtastic.core.strings.disconnected
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.unmessageable
import org.meshtastic.core.strings.unmonitored_or_infrastructure
import org.meshtastic.core.ui.icon.CloudDone
import org.meshtastic.core.ui.icon.CloudOffTwoTone
import org.meshtastic.core.ui.icon.CloudSync

View file

@ -44,10 +44,10 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_a_note
import org.meshtastic.core.strings.notes
import org.meshtastic.core.strings.save
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_a_note
import org.meshtastic.core.resources.notes
import org.meshtastic.core.resources.save
@Composable
fun NotesSection(node: Node, onSaveNotes: (Int, String) -> Unit, modifier: Modifier = Modifier) {

View file

@ -48,10 +48,10 @@ import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.exchange_position
import org.meshtastic.core.strings.open_compass
import org.meshtastic.core.strings.position
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.open_compass
import org.meshtastic.core.resources.position
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction

View file

@ -27,10 +27,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2
import org.meshtastic.core.strings.channel_3
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
import org.meshtastic.core.resources.channel_3
import org.meshtastic.feature.node.model.VectorMetricInfo
/**

View file

@ -24,8 +24,8 @@ 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.strings.Res
import org.meshtastic.core.strings.sats
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.sats
import org.meshtastic.core.ui.theme.AppTheme
@Composable

View file

@ -49,12 +49,12 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.logs
import org.meshtastic.core.strings.request_air_quality_metrics
import org.meshtastic.core.strings.request_telemetry
import org.meshtastic.core.strings.telemetry
import org.meshtastic.core.strings.userinfo
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_telemetry
import org.meshtastic.core.resources.telemetry
import org.meshtastic.core.resources.userinfo
import org.meshtastic.core.ui.icon.AirQuality
import org.meshtastic.core.ui.icon.LineAxis
import org.meshtastic.core.ui.icon.MeshtasticIcons

View file

@ -33,17 +33,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.node_id
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.role
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.node_id
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.role
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
@Composable
fun TemperatureInfo(

View file

@ -61,10 +61,9 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.details
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.loading
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.resources.loading
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
@ -100,20 +99,19 @@ fun NodeDetailScreen(
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
) {
viewModel.start(nodeId)
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val snackbarHostState = remember { SnackbarHostState() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.effects.collect { effect ->
if (effect is NodeRequestEffect.ShowFeedback) {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NodeDetailScaffold(
modifier = modifier,
uiState = uiState,
@ -149,8 +147,8 @@ private fun NodeDetailScaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = getString(Res.string.details),
subtitle = node?.user?.long_name ?: "",
title = stringResource(Res.string.details),
subtitle = uiState.nodeName.asString(),
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,

View file

@ -22,7 +22,6 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@ -31,44 +30,26 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.strings.getString
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
/**
* UI state for the Node Details screen.
*
* @property node The node being viewed, or null if loading.
* @property nodeName The display name for the node, resolved in the UI.
* @property ourNode Information about the locally connected node.
* @property metricsState Aggregated sensor and signal metrics.
* @property environmentState Standardized environmental sensor data.
@ -76,8 +57,10 @@ import javax.inject.Inject
* @property lastTracerouteTime Timestamp of the last successful traceroute request.
* @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request.
*/
@androidx.compose.runtime.Stable
data class NodeDetailUiState(
val node: Node? = null,
val nodeName: UiText = UiText.DynamicString(""),
val ourNode: Node? = null,
val metricsState: MetricsState = MetricsState.Empty,
val environmentState: EnvironmentMetricsState = EnvironmentMetricsState(),
@ -93,17 +76,12 @@ data class NodeDetailUiState(
@HiltViewModel
class NodeDetailViewModel
@Inject
@Suppress("LongParameterList")
constructor(
savedStateHandle: SavedStateHandle,
private val nodeRepository: NodeRepository,
private val nodeManagementActions: NodeManagementActions,
private val nodeRequestActions: NodeRequestActions,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val serviceRepository: ServiceRepository,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : ViewModel() {
private val nodeIdFromRoute: Int? =
@ -120,166 +98,10 @@ constructor(
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState())
buildUiStateFlow(nodeId)
getNodeDetailsUseCase(nodeId)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildUiStateFlow(nodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum
.map { it[nodeId] ?: Node.createFallback(nodeId, getString(Res.string.fallback_node_name)) }
.distinctUntilChanged()
// 1. Logs & Metrics Data (fetches telemetry, packets, paxcount, and response history)
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(nodeId),
meshLogRepository.getMeshPacketsFrom(nodeId),
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value),
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config (local device info and radio profile)
val identityFlow =
combine(nodeRepository.ourNodeInfo, nodeRepository.myNodeInfo, radioConfigRepository.deviceProfileFlow) {
ourNode,
myInfo,
profile,
->
IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile)
}
// 3. Metadata & Request Timestamps (firmware versions and last request times)
val metadataFlow =
combine(
meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes.map { it[nodeId] },
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { args: Array<Any?> ->
MetadataGroup(
edition = args[0] as? FirmwareEdition,
stable = args[1] as? FirmwareRelease,
alpha = args[2] as? FirmwareRelease,
trTime = args[3] as? Long,
niTime = args[4] as? Long,
)
}
// 4. Requests History (tracking traceroute and neighbor info requests sent from this device)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP),
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP),
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
NodeDetailUiState(
node = node,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
fun start(nodeId: Int) {

View file

@ -24,21 +24,21 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.favorite
import org.meshtastic.core.resources.favorite_add
import org.meshtastic.core.resources.favorite_remove
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.ignore_add
import org.meshtastic.core.resources.ignore_remove
import org.meshtastic.core.resources.mute_add
import org.meshtastic.core.resources.mute_notifications
import org.meshtastic.core.resources.mute_remove
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_node_text
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.favorite
import org.meshtastic.core.strings.favorite_add
import org.meshtastic.core.strings.favorite_remove
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.ignore_add
import org.meshtastic.core.strings.ignore_remove
import org.meshtastic.core.strings.mute_add
import org.meshtastic.core.strings.mute_notifications
import org.meshtastic.core.strings.mute_remove
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_node_text
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.util.AlertManager
import javax.inject.Inject
import javax.inject.Singleton

View file

@ -27,29 +27,29 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.request_air_quality_metrics
import org.meshtastic.core.resources.request_device_metrics
import org.meshtastic.core.resources.request_environment_metrics
import org.meshtastic.core.resources.request_host_metrics
import org.meshtastic.core.resources.request_pax_metrics
import org.meshtastic.core.resources.request_power_metrics
import org.meshtastic.core.resources.requesting_from
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.user_info
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.position
import org.meshtastic.core.strings.request_air_quality_metrics
import org.meshtastic.core.strings.request_device_metrics
import org.meshtastic.core.strings.request_environment_metrics
import org.meshtastic.core.strings.request_host_metrics
import org.meshtastic.core.strings.request_pax_metrics
import org.meshtastic.core.strings.request_power_metrics
import org.meshtastic.core.strings.requesting_from
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.user_info
import javax.inject.Inject
import javax.inject.Singleton
sealed class NodeRequestEffect {
data class ShowFeedback(val resource: StringResource, val args: List<Any> = emptyList()) : NodeRequestEffect()
data class ShowFeedback(val text: UiText) : NodeRequestEffect()
}
@Singleton
@ -70,7 +70,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
try {
serviceRepository.meshService?.requestUserInfo(destNum)
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.user_info, longName)),
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request NodeInfo error: ${ex.message}" }
@ -87,8 +89,7 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
_lastRequestNeighborTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(
Res.string.requesting_from,
listOf(Res.string.neighbor_info, longName),
UiText.Resource(Res.string.requesting_from, Res.string.neighbor_info, longName),
),
)
} catch (ex: android.os.RemoteException) {
@ -108,7 +109,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
try {
serviceRepository.meshService?.requestPosition(destNum, position)
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.position, longName)),
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.position, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request position error: ${ex.message}" }
@ -134,7 +137,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
TelemetryType.PAX -> Res.string.request_pax_metrics
}
_effects.emit(NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(typeRes, longName)))
_effects.emit(
NodeRequestEffect.ShowFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName)),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request telemetry error: ${ex.message}" }
}
@ -149,7 +154,9 @@ class NodeRequestActions @Inject constructor(private val serviceRepository: Serv
serviceRepository.meshService?.requestTraceroute(packetId, destNum)
_lastTracerouteTimes.update { it + (destNum to nowMillis) }
_effects.emit(
NodeRequestEffect.ShowFeedback(Res.string.requesting_from, listOf(Res.string.traceroute, longName)),
NodeRequestEffect.ShowFeedback(
UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName),
),
)
} catch (ex: android.os.RemoteException) {
Logger.e { "Request traceroute error: ${ex.message}" }

View file

@ -0,0 +1,60 @@
/*
* 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.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.feature.node.list.NodeFilterState
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.Config
import javax.inject.Inject
class GetFilteredNodesUseCase @Inject constructor(private val nodeRepository: NodeRepository) {
@Suppress("CyclomaticComplexMethod", "LongMethod")
operator fun invoke(filter: NodeFilterState, sort: NodeSortOption): Flow<List<Node>> = nodeRepository
.getNodes(
sort = sort,
filter = filter.filterText,
includeUnknown = filter.includeUnknown,
onlyOnline = filter.onlyOnline,
onlyDirect = filter.onlyDirect,
)
.map { list ->
list
.filter { node -> node.isIgnored == filter.showIgnored }
.filter { node ->
if (filter.excludeInfrastructure) {
val role = node.user.role
@Suppress("DEPRECATION")
val infrastructureRoles =
listOf(
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.CLIENT_BASE,
)
role !in infrastructureRoles && !node.isEffectivelyUnmessageable
} else {
true
}
}
}
}

View file

@ -0,0 +1,237 @@
/*
* 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.domain.usecase
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.detail.NodeDetailUiState
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import javax.inject.Inject
class GetNodeDetailsUseCase
@Inject
constructor(
private val nodeRepository: NodeRepository,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) {
@OptIn(ExperimentalCoroutinesApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
operator fun invoke(nodeId: Int): Flow<NodeDetailUiState> =
nodeRepository.effectiveLogNodeId(nodeId).flatMapLatest { effectiveNodeId ->
buildFlow(nodeId, effectiveNodeId)
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildFlow(nodeId: Int, effectiveNodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum.map { it[nodeId] ?: Node.createFallback(nodeId, "") }.distinctUntilChanged()
// 1. Logs & Metrics Data
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId).onStart { emit(emptyList()) },
meshLogRepository.getMeshPacketsFrom(effectiveNodeId, PortNum.POSITION_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.PAXCOUNTER_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.TRACEROUTE_APP.value).onStart {
emit(emptyList())
},
meshLogRepository.getLogsFrom(effectiveNodeId, PortNum.NEIGHBORINFO_APP.value).onStart {
emit(emptyList())
},
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config
val identityFlow =
combine(
nodeRepository.ourNodeInfo,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow.onStart { emit(DeviceProfile()) },
) { ourNode, myInfo, profile ->
IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile)
}
// 3. Metadata & Request Timestamps
val metadataFlow =
combine(
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.onStart { emit(null) },
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes.map { it[nodeId] },
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { edition, stable, alpha, trTime, niTime ->
MetadataGroup(edition = edition, stable = stable, alpha = alpha, trTime = trTime, niTime = niTime)
}
// 4. Requests History (we still query request logs by the target nodeId)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP).onStart { emit(emptyList()) },
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP).onStart { emit(emptyList()) },
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
@Suppress("MagicNumber")
val nodeName =
node.user.long_name?.takeIf { it.isNotBlank() }?.let { UiText.DynamicString(it) }
?: UiText.Resource(Res.string.fallback_node_name, node.user.id.takeLast(4))
NodeDetailUiState(
node = node,
nodeName = nodeName,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
}

View file

@ -68,18 +68,18 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.add_favorite
import org.meshtastic.core.resources.channel_invalid
import org.meshtastic.core.resources.ignore
import org.meshtastic.core.resources.mute_always
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.resources.remove
import org.meshtastic.core.resources.remove_favorite
import org.meshtastic.core.resources.remove_ignored
import org.meshtastic.core.resources.unmute
import org.meshtastic.core.service.ConnectionState
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.add_favorite
import org.meshtastic.core.strings.channel_invalid
import org.meshtastic.core.strings.ignore
import org.meshtastic.core.strings.mute_always
import org.meshtastic.core.strings.node_count_template
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.strings.remove
import org.meshtastic.core.strings.remove_favorite
import org.meshtastic.core.strings.remove_ignored
import org.meshtastic.core.strings.unmute
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent

View file

@ -29,7 +29,6 @@ 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.launch
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
@ -39,12 +38,13 @@ import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.detail.NodeManagementActions
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.feature.node.domain.usecase.GetFilteredNodesUseCase
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@Suppress("LongParameterList")
@HiltViewModel
class NodeListViewModel
@Inject
@ -54,6 +54,7 @@ constructor(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
val nodeManagementActions: NodeManagementActions,
private val getFilteredNodesUseCase: GetFilteredNodesUseCase,
val nodeFilterPreferences: NodeFilterPreferences,
) : ViewModel() {
@ -116,35 +117,7 @@ constructor(
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 { node -> node.isIgnored == filter.showIgnored }
.filter { node ->
if (filter.excludeInfrastructure) {
val role = node.user.role
val infrastructureRoles =
listOf(
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.CLIENT_BASE,
)
role !in infrastructureRoles && !node.isEffectivelyUnmessageable
} else {
true
}
}
}
}
.flatMapLatest { (filter, sort) -> getFilteredNodesUseCase.invoke(filter, sort) }
.stateInWhileSubscribed(initialValue = emptyList())
val unfilteredNodeList: StateFlow<List<Node>> =

View file

@ -59,9 +59,9 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.info
import org.meshtastic.core.strings.logs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.logs
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh

View file

@ -63,12 +63,12 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.close
import org.meshtastic.core.strings.delete
import org.meshtastic.core.strings.info
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.snr
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.close
import org.meshtastic.core.resources.delete
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
import java.text.DateFormat

View file

@ -66,16 +66,15 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.air_util_definition
import org.meshtastic.core.strings.air_utilization
import org.meshtastic.core.strings.battery
import org.meshtastic.core.strings.ch_util_definition
import org.meshtastic.core.strings.channel_utilization
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.air_util_definition
import org.meshtastic.core.resources.air_utilization
import org.meshtastic.core.resources.battery
import org.meshtastic.core.resources.ch_util_definition
import org.meshtastic.core.resources.channel_utilization
import org.meshtastic.core.resources.device_metrics_log
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.component.MaterialBatteryInfo
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Cyan
@ -141,7 +140,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -33,15 +33,15 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.baro_pressure
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.lux
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
import org.meshtastic.core.resources.uv_lux
import org.meshtastic.proto.Telemetry
@Suppress("MagicNumber")

View file

@ -51,21 +51,20 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.gas_resistance
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.humidity
import org.meshtastic.core.strings.iaq
import org.meshtastic.core.strings.iaq_definition
import org.meshtastic.core.strings.lux
import org.meshtastic.core.strings.radiation
import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.gas_resistance
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.iaq_definition
import org.meshtastic.core.resources.lux
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
import org.meshtastic.core.resources.temperature
import org.meshtastic.core.resources.uv_lux
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.component.IaqDisplayMode
import org.meshtastic.core.ui.component.IndoorAirQuality
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -87,7 +86,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -59,13 +59,12 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.disk_free_indexed
import org.meshtastic.core.strings.free_memory
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.load_indexed
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.user_string
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.disk_free_indexed
import org.meshtastic.core.resources.free_memory
import org.meshtastic.core.resources.load_indexed
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.user_string
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.DataArray
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -88,7 +87,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -27,18 +27,17 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.toRoute
import co.touchlab.kermit.Logger
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -46,11 +45,8 @@ import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.data.repository.DeviceHardwareRepository
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
@ -58,26 +54,21 @@ import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.okay
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.view_on_map
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.view_on_map
import org.meshtastic.core.ui.util.AlertManager
import org.meshtastic.core.ui.util.toMessageRes
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import java.io.BufferedWriter
@ -89,8 +80,6 @@ import java.util.Locale
import javax.inject.Inject
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private fun MeshPacket.hasValidSignal(): Boolean = rx_time > 0 && (rx_snr != 0f || rx_rssi != 0)
/**
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
*/
@ -103,27 +92,111 @@ constructor(
private val app: Application,
private val dispatchers: CoroutineDispatchers,
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
private val alertManager: AlertManager,
private val getNodeDetailsUseCase: GetNodeDetailsUseCase,
) : ViewModel() {
private var destNum: Int? =
private val nodeIdFromRoute: Int? =
runCatching { savedStateHandle.toRoute<NodesRoutes.NodeDetailGraph>().destNum }.getOrNull()
private var jobs: Job? = null
private val manualNodeId = MutableStateFlow<Int?>(null)
private val activeNodeId =
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
val state: StateFlow<MetricsState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(MetricsState.Empty)
getNodeDetailsUseCase(nodeId).map { it.metricsState }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), MetricsState.Empty)
private val environmentState: StateFlow<EnvironmentMetricsState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(EnvironmentMetricsState())
getNodeDetailsUseCase(nodeId).map { it.environmentState }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), EnvironmentMetricsState())
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
/** The active time window for filtering graphed data. */
val timeFrame: StateFlow<TimeFrame> = _timeFrame
/** Returns the list of time frames that are actually available based on the oldest data point. */
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(state, environmentState) { currentState, envState ->
val stateOldest = currentState.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
.stateInWhileSubscribed(TimeFrame.entries)
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Exposes filtered and unit-converted environment metrics for the UI. */
val filteredEnvironmentMetrics: StateFlow<List<Telemetry>> =
combine(environmentState, _timeFrame, state) { envState, timeFrame, currentState ->
val threshold = timeFrame.timeThreshold()
val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold }
if (currentState.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
telemetry.copy(
environment_metrics =
em.copy(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
} else {
data
}
}
.stateInWhileSubscribed(emptyList())
/** Exposes graphing data specifically for the filtered environment metrics. */
val environmentGraphingData: StateFlow<EnvironmentGraphingData> =
filteredEnvironmentMetrics
.map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) }
.stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList()))
/** Exposes filtered and decoded pax metrics for the UI. */
val filteredPaxMetrics: StateFlow<List<Pair<MeshLog, ProtoPaxcount>>> =
combine(state, _timeFrame) { currentState, timeFrame ->
val threshold = timeFrame.timeThreshold()
currentState.paxMetrics
.filter { (it.received_date / 1000) >= threshold }
.mapNotNull { log -> decodePaxFromLog(log)?.let { log to it } }
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> =
combine(nodeRequestActions.lastTracerouteTimes, activeNodeId) { map, id -> id?.let { map[it] } }
.stateInWhileSubscribed(null)
val lastRequestNeighborsTime: StateFlow<Long?> =
combine(nodeRequestActions.lastRequestNeighborTimes, activeNodeId) { map, id -> id?.let { map[it] } }
.stateInWhileSubscribed(null)
fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum)
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
/** Returns the map overlay for a specific traceroute request ID. */
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
val cached = tracerouteOverlayCache.value[requestId]
if (cached != null) return cached
@ -170,103 +243,35 @@ constructor(
}
}
}
Logger.d { "MetricsViewModel created" }
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) }
(manualNodeId.value ?: nodeIdFromRoute)?.let {
meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value)
}
}
private val _state = MutableStateFlow(MetricsState.Empty)
/** Current aggregated metrics state, including signal history and sensor logs. */
val state: StateFlow<MetricsState> = _state
private val environmentState = MutableStateFlow(EnvironmentMetricsState())
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
/** The active time window for filtering graphed data. */
val timeFrame: StateFlow<TimeFrame> = _timeFrame
/** Returns the list of time frames that are actually available based on the oldest data point. */
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(_state, environmentState) { state, envState ->
val stateOldest = state.oldestTimestampSeconds()
val envOldest = envState.environmentMetrics.minOfOrNull { (it.time ?: 0).toLong() }?.takeIf { it > 0 }
val oldest = listOfNotNull(stateOldest, envOldest).minOrNull() ?: nowSeconds
TimeFrame.entries.filter { it.isAvailable(oldest) }
}
.stateInWhileSubscribed(TimeFrame.entries)
fun setTimeFrame(timeFrame: TimeFrame) {
_timeFrame.value = timeFrame
}
/** Exposes filtered and unit-converted environment metrics for the UI. */
val filteredEnvironmentMetrics: StateFlow<List<Telemetry>> =
combine(environmentState, _timeFrame, _state) { envState, timeFrame, state ->
val threshold = timeFrame.timeThreshold()
val data = envState.environmentMetrics.filter { (it.time ?: 0).toLong() >= threshold }
if (state.isFahrenheit) {
data.map { telemetry ->
val em = telemetry.environment_metrics ?: return@map telemetry
telemetry.copy(
environment_metrics =
em.copy(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
} else {
data
}
}
.stateInWhileSubscribed(emptyList())
/** Exposes graphing data specifically for the filtered environment metrics. */
val environmentGraphingData: StateFlow<EnvironmentGraphingData> =
filteredEnvironmentMetrics
.map { filtered -> EnvironmentMetricsState(filtered).environmentMetricsForGraphing(useFahrenheit = false) }
.stateInWhileSubscribed(EnvironmentGraphingData(emptyList(), emptyList()))
/** Exposes filtered and decoded pax metrics for the UI. */
val filteredPaxMetrics: StateFlow<List<Pair<MeshLog, ProtoPaxcount>>> =
combine(_state, _timeFrame) { state, timeFrame ->
val threshold = timeFrame.timeThreshold()
state.paxMetrics
.filter { (it.received_date / 1000) >= threshold }
.mapNotNull { log -> decodePaxFromLog(log)?.let { log to it } }
}
.stateInWhileSubscribed(emptyList())
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
val lastTraceRouteTime: StateFlow<Long?> =
nodeRequestActions.lastTracerouteTimes.map { it[destNum] }.stateInWhileSubscribed(null)
val lastRequestNeighborsTime: StateFlow<Long?> =
nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null)
fun requestPosition() {
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") }
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
fun requestTelemetry(type: TelemetryType) {
destNum?.let {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.long_name ?: "", type)
}
}
fun requestTraceroute() {
destNum?.let {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
fun requestNeighborInfo() {
destNum?.let {
(manualNodeId.value ?: nodeIdFromRoute)?.let {
nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
@ -278,7 +283,6 @@ constructor(
)
}
/** Shows the detail dialog for a traceroute result, with an option to view on the map. */
fun showTracerouteDetail(
annotatedMessage: AnnotatedString,
requestId: Int,
@ -318,188 +322,17 @@ constructor(
}
}
init {
initializeFlows()
}
fun setNodeId(id: Int) {
if (destNum != id) {
destNum = id
initializeFlows()
if (manualNodeId.value != id) {
manualNodeId.value = id
}
}
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun initializeFlows() {
jobs?.cancel()
val currentDestNum = destNum
jobs =
viewModelScope.launch {
if (currentDestNum != null) {
val logNodeIdFlow = nodeRepository.effectiveLogNodeId(currentDestNum)
launch {
combine(nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { nodes, myInfo ->
nodes[currentDestNum] to (nodes.keys.firstOrNull() to myInfo)
}
.distinctUntilChanged()
.collect { (node, localData) ->
val (ourNodeNum, myInfo) = localData
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode =
node
?: Node.createFallback(currentDestNum, getString(Res.string.fallback_node_name))
val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null
val hwModel = actualNode.user.hw_model.value
val deviceHardware =
deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv)
_state.update { state ->
state.copy(
node = actualNode,
isLocal = currentDestNum == ourNodeNum,
deviceHardware = deviceHardware.getOrNull(),
reportedTarget = pioEnv,
)
}
}
}
launch {
radioConfigRepository.deviceProfileFlow.collect { profile ->
val moduleConfig = profile.module_config
val displayUnits = profile.config?.display?.units
_state.update { state ->
state.copy(
isManaged = profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC,
)
}
}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getTelemetryFrom(it) }
.collect { telemetry ->
val device = mutableListOf<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
for (item in telemetry) {
if (item.device_metrics != null) device.add(item)
if (item.power_metrics != null) power.add(item)
if (item.host_metrics != null) host.add(item)
if (item.hasValidEnvironmentMetrics()) env.add(item)
}
_state.update { state ->
state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host)
}
environmentState.update { it.copy(environmentMetrics = env) }
}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getMeshPacketsFrom(it) }
.collect { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}
}
launch {
combine(
meshLogRepository.getRequestLogs(currentDestNum, PortNum.TRACEROUTE_APP),
logNodeIdFlow.flatMapLatest {
meshLogRepository.getLogsFrom(it, PortNum.TRACEROUTE_APP.value)
},
) { request, response ->
_state.update { state ->
state.copy(tracerouteRequests = request, tracerouteResults = response)
}
}
.collect {}
}
launch {
combine(
meshLogRepository.getRequestLogs(currentDestNum, PortNum.NEIGHBORINFO_APP),
logNodeIdFlow.flatMapLatest {
meshLogRepository.getLogsFrom(it, PortNum.NEIGHBORINFO_APP.value)
},
) { request, response ->
_state.update { state ->
state.copy(neighborInfoRequests = request, neighborInfoResults = response)
}
}
.collect {}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getMeshPacketsFrom(it, PortNum.POSITION_APP.value) }
.collect { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
}
launch {
logNodeIdFlow
.flatMapLatest { meshLogRepository.getLogsFrom(it, PortNum.PAXCOUNTER_APP.value) }
.collect { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
}
launch {
firmwareReleaseRepository.stableRelease.filterNotNull().collect { latestStable ->
_state.update { state -> state.copy(latestStableFirmware = latestStable) }
}
}
launch {
firmwareReleaseRepository.alphaRelease.filterNotNull().collect { latestAlpha ->
_state.update { state -> state.copy(latestAlphaFirmware = latestAlpha) }
}
}
launch {
meshLogRepository
.getMyNodeInfo()
.map { it?.firmware_edition }
.distinctUntilChanged()
.collect { firmwareEdition ->
_state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
}
}
Logger.d { "MetricsViewModel created" }
} else {
Logger.d { "MetricsViewModel: destNum is null, skipping metrics flows initialization." }
}
}
}
override fun onCleared() {
super.onCleared()
Logger.d { "MetricsViewModel cleared" }
}
/** Write the persisted Position data out to a CSV file in the specified location. */
fun savePositionCSV(uri: Uri) = viewModelScope.launch(dispatchers.main) {
val positions = state.value.positionLogs
writeToUri(uri) { writer ->
@ -518,7 +351,6 @@ constructor(
val speed = position.ground_speed
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
// date,time,latitude,longitude,altitude,satsInView,speed,heading
writer.appendLine(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"",
)
@ -541,12 +373,10 @@ constructor(
@Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount")
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
// Requests for paxcount (want_response = true) should not be logged as data points.
if (decoded.want_response == true) return null
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax
@ -554,10 +384,9 @@ constructor(
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from binary data" }
}
// Fallback: Attempt to parse Paxcount from raw_message as base64 or hex string.
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
if (base64.matches(Regex("^[A-Za-z0-9+/=\\r\\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
return ProtoPaxcount.ADAPTER.decode(bytes)
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {

View file

@ -45,10 +45,9 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.getNeighborInfoResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Groups
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -77,7 +76,7 @@ fun NeighborInfoLogScreen(
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
@ -137,6 +136,7 @@ fun NeighborInfoLogScreen(
)
val text = if (result != null) "Success" else stringResource(Res.string.routing_error_no_response)
val icon = if (result != null) MeshtasticIcons.Groups else MeshtasticIcons.PersonOff
val header = stringResource(Res.string.neighbor_info)
var expanded by remember { mutableStateOf(false) }
Box {
@ -149,10 +149,7 @@ fun NeighborInfoLogScreen(
result
?.fromRadio
?.packet
?.getNeighborInfoResponse(
::getUsername,
header = getString(Res.string.neighbor_info),
)
?.getNeighborInfoResponse(::getUsername, header = header)
?.let {
val message =
annotateNeighborInfo(

View file

@ -59,14 +59,13 @@ import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.ble_devices
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.no_pax_metrics_logs
import org.meshtastic.core.strings.pax
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.uptime
import org.meshtastic.core.strings.wifi_devices
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.ble_devices
import org.meshtastic.core.resources.no_pax_metrics_logs
import org.meshtastic.core.resources.pax
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.IconInfo
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
@ -189,7 +188,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -65,17 +65,16 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.alt
import org.meshtastic.core.strings.clear
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.heading
import org.meshtastic.core.strings.latitude
import org.meshtastic.core.strings.longitude
import org.meshtastic.core.strings.sats
import org.meshtastic.core.strings.save
import org.meshtastic.core.strings.speed
import org.meshtastic.core.strings.timestamp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.alt
import org.meshtastic.core.resources.clear
import org.meshtastic.core.resources.heading
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.save
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -182,7 +181,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -64,14 +64,13 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLa
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.channel_1
import org.meshtastic.core.strings.channel_2
import org.meshtastic.core.strings.channel_3
import org.meshtastic.core.strings.current
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.voltage
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
import org.meshtastic.core.resources.channel_2
import org.meshtastic.core.resources.channel_3
import org.meshtastic.core.resources.current
import org.meshtastic.core.resources.power_metrics_log
import org.meshtastic.core.resources.voltage
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.detail.NodeRequestEffect
@ -121,7 +120,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -58,13 +58,12 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.rssi
import org.meshtastic.core.strings.rssi_definition
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.snr
import org.meshtastic.core.strings.snr_definition
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.rssi_definition
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.snr
import org.meshtastic.core.resources.snr_definition
import org.meshtastic.core.ui.component.LoraSignalIndicator
import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
@ -98,7 +97,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}

View file

@ -52,18 +52,17 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.getString
import org.meshtastic.core.strings.routing_error_no_response
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_diff
import org.meshtastic.core.strings.traceroute_direct
import org.meshtastic.core.strings.traceroute_duration
import org.meshtastic.core.strings.traceroute_hops
import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.strings.traceroute_route_back_to_us
import org.meshtastic.core.strings.traceroute_route_towards_dest
import org.meshtastic.core.strings.traceroute_time_and_text
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.routing_error_no_response
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.traceroute_diff
import org.meshtastic.core.resources.traceroute_direct
import org.meshtastic.core.resources.traceroute_duration
import org.meshtastic.core.resources.traceroute_hops
import org.meshtastic.core.resources.traceroute_log
import org.meshtastic.core.resources.traceroute_route_back_to_us
import org.meshtastic.core.resources.traceroute_route_towards_dest
import org.meshtastic.core.resources.traceroute_time_and_text
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Group
import org.meshtastic.core.ui.icon.MeshtasticIcons
@ -98,7 +97,7 @@ fun TracerouteLogScreen(
when (effect) {
is NodeRequestEffect.ShowFeedback -> {
@Suppress("SpreadOperator")
snackbarHostState.showSnackbar(getString(effect.resource, *effect.args.toTypedArray()))
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
@ -142,6 +141,8 @@ fun TracerouteLogScreen(
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val headerTowardsStr = stringResource(Res.string.traceroute_route_towards_dest)
val headerBackStr = stringResource(Res.string.traceroute_route_back_to_us)
val result =
remember(state.tracerouteRequests, log.fromRadio.packet?.id) {
state.tracerouteResults.find {
@ -169,7 +170,7 @@ fun TracerouteLogScreen(
res.fromRadio.packet?.getTracerouteResponse(
::getUsername,
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
headerBack = headerBackStr,
),
statusGreen = statusGreen,
statusYellow = statusYellow,
@ -186,7 +187,7 @@ fun TracerouteLogScreen(
?.getTracerouteResponse(
::getUsername,
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
headerBack = headerBackStr,
)
?.let { AnnotatedString(it) }
}
@ -214,8 +215,8 @@ fun TracerouteLogScreen(
?.packet
?.getTracerouteResponse(
::getUsername,
headerTowards = getString(Res.string.traceroute_route_towards_dest),
headerBack = getString(Res.string.traceroute_route_back_to_us),
headerTowards = headerTowardsStr,
headerBack = headerBackStr,
)
?.let {
annotateTraceroute(

View file

@ -43,11 +43,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.flowOf
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.traceroute
import org.meshtastic.core.strings.traceroute_outgoing_route
import org.meshtastic.core.strings.traceroute_return_route
import org.meshtastic.core.strings.traceroute_showing_nodes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.traceroute
import org.meshtastic.core.resources.traceroute_outgoing_route
import org.meshtastic.core.resources.traceroute_return_route
import org.meshtastic.core.resources.traceroute_showing_nodes
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Route

View file

@ -29,17 +29,17 @@ import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.strings.env_metrics_log
import org.meshtastic.core.strings.host_metrics_log
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.node_map
import org.meshtastic.core.strings.pax_metrics_log
import org.meshtastic.core.strings.position_log
import org.meshtastic.core.strings.power_metrics_log
import org.meshtastic.core.strings.signal_quality
import org.meshtastic.core.strings.traceroute_log
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device_metrics_log
import org.meshtastic.core.resources.env_metrics_log
import org.meshtastic.core.resources.host_metrics_log
import org.meshtastic.core.resources.neighbor_info
import org.meshtastic.core.resources.node_map
import org.meshtastic.core.resources.pax_metrics_log
import org.meshtastic.core.resources.position_log
import org.meshtastic.core.resources.power_metrics_log
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.traceroute_log
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Paxcount
import org.meshtastic.core.ui.icon.Route

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* 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
@ -14,11 +14,10 @@
* 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.model
import androidx.annotation.DrawableRes
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
internal data class VectorMetricInfo(
@ -31,6 +30,6 @@ internal data class VectorMetricInfo(
internal data class DrawableMetricInfo(
val label: StringResource,
val value: String,
@DrawableRes val icon: Int,
val icon: DrawableResource,
val rotateIcon: Float = 0f,
)

View file

@ -18,13 +18,13 @@ package org.meshtastic.feature.node.model
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.all_time
import org.meshtastic.core.strings.one_hour_short
import org.meshtastic.core.strings.one_month
import org.meshtastic.core.strings.one_week
import org.meshtastic.core.strings.twenty_four_hours
import org.meshtastic.core.strings.two_weeks
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.all_time
import org.meshtastic.core.resources.one_hour_short
import org.meshtastic.core.resources.one_month
import org.meshtastic.core.resources.one_week
import org.meshtastic.core.resources.twenty_four_hours
import org.meshtastic.core.resources.two_weeks
@Suppress("MagicNumber")
enum class TimeFrame(val strRes: StringResource, val seconds: Long) {

View file

@ -1,121 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="909.88dp"
android:height="546.86dp"
android:viewportWidth="909.88"
android:viewportHeight="546.86">
<path
android:pathData="m898.52,135.44h4.69a5.67,5.67 0,0 1,5.67 5.67v84.65a5.67,5.67 0,0 1,-5.67 5.67h-4.69"
android:fillColor="#9f9f9e"/>
<path
android:pathData="M12.7,104.75L886.82,104.75A11.7,11.7 0,0 1,898.52 116.45L898.52,534.16A11.7,11.7 0,0 1,886.82 545.86L12.7,545.86A11.7,11.7 0,0 1,1 534.16L1,116.45A11.7,11.7 0,0 1,12.7 104.75z"
android:fillColor="#cbcccb"/>
<path
android:pathData="m34.47,104.75v113.48a3.67,3.67 0,0 0,3.67 3.67h41a2.35,2.35 0,0 1,2.35 2.35L81.49,545.86L870.95,545.86L870.95,104.75ZM845.99,520.86L106.53,520.86L106.53,213.96a17.06,17.06 0,0 0,-17.06 -17.06h-27.5a2.5,2.5 0,0 1,-2.5 -2.5v-62.15a2.5,2.5 0,0 1,2.5 -2.5h784z"
android:fillColor="#9f9f9e"/>
<path
android:pathData="M845.99,129.75L845.99,520.86L106.53,520.86L106.53,213.96a17,17 0,0 0,-7.2 -13.92v-70.29z"
android:fillColor="#cbcccb"/>
<path
android:pathData="m99.33,129.75v70.29a17,17 0,0 0,-9.86 -3.14h-27.5a2.5,2.5 0,0 1,-2.5 -2.5v-62.15a2.5,2.5 0,0 1,2.5 -2.5z"
android:fillColor="#b7b7b7"/>
<path
android:pathData="M25.45,253.39h13.53v148.4h-13.53z"
android:fillColor="#9f9f9e"/>
<path
android:pathData="m430.64,95.71h71.71a2.55,2.55 0,0 1,2.55 2.55v6.48h-76.8v-6.48a2.55,2.55 0,0 1,2.54 -2.55z"
android:fillColor="#b1a368"/>
<path
android:pathData="m436.27,3.17h60.88a6.2,4.85 0,0 1,6.2 4.85v77.33h-73.28v-77.33a6.2,4.85 0,0 1,6.2 -4.85z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="M425.88,20.14L506.28,20.14A5.55,5.55 0,0 1,511.83 25.69L511.83,70.55A5.55,5.55 0,0 1,506.28 76.1L425.88,76.1A5.55,5.55 0,0 1,420.33 70.55L420.33,25.69A5.55,5.55 0,0 1,425.88 20.14z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="m511.8,24.48v47.25a5.52,4.31 0,0 1,-5.55 4.34h-80.37a5.55,4.34 0,0 1,-5.59 -4.34v-47.25a5.55,4.34 0,0 1,5.59 -4.34h80.51a5.52,4.31 0,0 1,5.41 4.34z"
android:strokeWidth="3.16706"
android:fillColor="#9f9f9e"
android:strokeColor="#050606"/>
<path
android:pathData="M433.29,85.68h65.99v10.03h-65.99z"
android:fillColor="#b1a368"/>
<path
android:pathData="M845.99,129.75L845.99,520.86L106.53,520.86L106.53,213.96a17.06,17.06 0,0 0,-17.06 -17.06h-27.5a2.5,2.5 0,0 1,-2.5 -2.5v-62.15a2.5,2.5 0,0 1,2.5 -2.5h784m25,-25L34.47,104.75v113.48a3.68,3.68 0,0 0,3.67 3.67h41a2.35,2.35 0,0 1,2.35 2.35L81.49,545.86L870.95,545.86L870.95,104.75Z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="M99.34,200.04L99.34,129.75"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="M25.45,253.39h13.53v148.4h-13.53z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="m898.52,135.44h4.69a5.67,5.67 0,0 1,5.67 5.67v84.65a5.67,5.67 0,0 1,-5.67 5.67h-4.69"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="m430.64,95.71h71.71a2.55,2.55 0,0 1,2.55 2.55v6.48h-76.8v-6.48a2.55,2.55 0,0 1,2.54 -2.55z"
android:strokeWidth="2.04"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="M433.29,85.68h65.99v10.03h-65.99z"
android:strokeWidth="1.99"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="m78.62,152.33a14,14 0,1 0,14 14,13.95 13.95,0 0,0 -14,-14zM78.62,173.83a7.55,7.55 0,1 1,7.54 -7.55,7.55 7.55,0 0,1 -7.54,7.55z"
android:fillColor="#9f9f9e"/>
<path
android:pathData="M78.62,166.28m-7.55,0a7.55,7.55 0,1 1,15.1 0a7.55,7.55 0,1 1,-15.1 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="M78.62,166.28m-13.95,0a13.95,13.95 0,1 1,27.9 0a13.95,13.95 0,1 1,-27.9 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="m445.36,440.05c0,11.52 10.38,20.86 23.19,20.86 12.81,0 23.19,-9.34 23.19,-20.86 0,-11.52 -10.38,-20.86 -23.19,-20.86 -12.81,0 -23.19,9.34 -23.19,20.86z"
android:strokeWidth="0.458227"
android:fillColor="#4d4d4d"/>
<path
android:pathData="m469.4,538.4c-119.83,0 -217.32,-93.41 -217.32,-208.23 0,-114.82 97.48,-208.23 217.32,-208.23 119.83,0 217.32,93.41 217.32,208.23 0,114.82 -97.48,208.23 -217.32,208.23zM469.4,151.82c-102.64,0 -186.13,80.01 -186.13,178.35 0,98.33 83.5,178.35 186.13,178.35 102.62,0 186.13,-80.02 186.13,-178.35 0,-98.34 -83.51,-178.35 -186.13,-178.35z"
android:strokeWidth="0.474832"
android:fillColor="#4d4d4d"/>
<path
android:pathData="m468.56,391.97c-8.54,0 -15.46,-6.23 -15.46,-13.91v-23.51c0,-22.75 19.33,-40.13 36.4,-55.47 12.51,-11.26 25.45,-22.89 25.45,-32.16 0,-23.18 -20.81,-42.04 -46.39,-42.04 -26.01,0 -46.39,18.05 -46.39,41.09 0,7.68 -6.93,13.91 -15.46,13.91 -8.54,0 -15.46,-6.23 -15.46,-13.91 0,-37.99 34.68,-68.9 77.31,-68.9 42.63,0 77.31,31.33 77.31,69.85 0,20.82 -17.55,36.59 -34.51,51.84 -13.45,12.07 -27.34,24.56 -27.34,35.78v23.51c0,7.68 -6.93,13.92 -15.46,13.92z"
android:strokeWidth="0.458227"
android:fillColor="#4d4d4d"
android:strokeColor="#000000"/>
<path
android:pathData="M12.7,104.75L886.82,104.75A11.7,11.7 0,0 1,898.52 116.45L898.52,534.16A11.7,11.7 0,0 1,886.82 545.86L12.7,545.86A11.7,11.7 0,0 1,1 534.16L1,116.45A11.7,11.7 0,0 1,12.7 104.75z"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#050606"/>
<path
android:pathData="m107.42,363.03 l-0.24,-156.31 -2.99,-3.72 -2.99,-3.72v-34.09,-34.09l150.65,0.05 150.65,0.05 -8.28,3.07c-19.32,7.16 -34.46,14.82 -50.22,25.41 -50.58,33.98 -84.36,88.87 -91.06,147.96 -1.43,12.63 -0.64,44.7 1.39,55.76 7.76,42.44 25.98,77.93 55.68,108.42 17.38,17.85 33.99,30.3 55.43,41.55l11.31,5.93 -134.54,0.02 -134.54,0.02z"
android:strokeWidth="0.92"
android:fillColor="#ffffff"
android:fillAlpha="0"/>
<path
android:pathData="m107.42,363.03 l-0.24,-156.31 -2.99,-3.72 -2.99,-3.72v-34.09,-34.09l150.65,0.05 150.65,0.05 -8.28,3.07c-19.32,7.16 -34.46,14.82 -50.22,25.41 -50.58,33.98 -84.36,88.87 -91.06,147.96 -1.43,12.63 -0.64,44.7 1.39,55.76 7.76,42.44 25.98,77.93 55.68,108.42 17.38,17.85 33.99,30.3 55.43,41.55l11.31,5.93 -134.54,0.02 -134.54,0.02z"
android:strokeWidth="0.92"
android:fillColor="#ffffff"
android:fillAlpha="0"/>
<path
android:pathData="m107.42,363.03 l-0.24,-156.31 -2.99,-3.72 -2.99,-3.72v-34.09,-34.09l150.65,0.05 150.65,0.05 -8.28,3.07c-19.32,7.16 -34.46,14.82 -50.22,25.41 -50.58,33.98 -84.36,88.87 -91.06,147.96 -1.43,12.63 -0.64,44.7 1.39,55.76 7.76,42.44 25.98,77.93 55.68,108.42 17.38,17.85 33.99,30.3 55.43,41.55l11.31,5.93 -134.54,0.02 -134.54,0.02z"
android:strokeWidth="0.92"
android:fillColor="#ffffff"
android:fillAlpha="0"/>
</vector>

View file

@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,11a1,1 0,0 1,1 1a10,10 0,0 1,-5 8.656a1,1 0,0 1,-1.302 -0.268l-0.064,-0.098l-3,-5.19a0.995,0.995 0,0 1,-0.133 -0.542l0.01,-0.11l0.023,-0.106l0.034,-0.106l0.046,-0.1l0.056,-0.094l0.067,-0.089a0.994,0.994 0,0 1,0.165 -0.155l0.098,-0.064a2,2 0,0 0,0.993 -1.57l0.007,-0.163a1,1 0,0 1,0.883 -0.994l0.117,-0.007h6z" />
<path
android:fillColor="@android:color/white"
android:pathData="M7,3.344a10,10 0,0 1,10 0a1,1 0,0 1,0.418 1.262l-0.052,0.104l-3,5.19l-0.064,0.098a0.994,0.994 0,0 1,-0.155 0.165l-0.089,0.067a1,1 0,0 1,-0.195 0.102l-0.105,0.034l-0.107,0.022a1.003,1.003 0,0 1,-0.547 -0.07l-0.104,-0.052a2,2 0,0 0,-1.842 -0.082l-0.158,0.082a1,1 0,0 1,-1.302 -0.268l-0.064,-0.098l-3,-5.19a1,1 0,0 1,0.366 -1.366z" />
<path
android:fillColor="@android:color/white"
android:pathData="M9,11a1,1 0,0 1,0.993 0.884l0.007,0.117a2,2 0,0 0,0.861 1.645l0.237,0.152a0.994,0.994 0,0 1,0.165 0.155l0.067,0.089l0.056,0.095l0.045,0.099c0.014,0.036 0.026,0.07 0.035,0.106l0.022,0.107l0.011,0.11a0.994,0.994 0,0 1,-0.08 0.437l-0.053,0.104l-3,5.19a1,1 0,0 1,-1.366 0.366a10,10 0,0 1,-5 -8.656a1,1 0,0 1,0.883 -0.993l0.117,-0.007h6z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M620,440q-25,0 -42.5,-17.5T560,380q0,-17 9.5,-34.5t20.5,-32q11,-14.5 20.5,-24l9.5,-9.5 9.5,9.5q9.5,9.5 20.5,24t20.5,32Q680,363 680,380q0,25 -17.5,42.5T620,440ZM780,320q-25,0 -42.5,-17.5T720,260q0,-17 9.5,-34.5t20.5,-32q11,-14.5 20.5,-24l9.5,-9.5 9.5,9.5q9.5,9.5 20.5,24t20.5,32Q840,243 840,260q0,25 -17.5,42.5T780,320ZM780,560q-25,0 -42.5,-17.5T720,500q0,-17 9.5,-34.5t20.5,-32q11,-14.5 20.5,-24l9.5,-9.5 9.5,9.5q9.5,9.5 20.5,24t20.5,32Q840,483 840,500q0,25 -17.5,42.5T780,560ZM360,840q-83,0 -141.5,-58.5T160,640q0,-48 21,-89.5t59,-70.5v-240q0,-50 35,-85t85,-35q50,0 85,35t35,85v240q38,29 59,70.5t21,89.5q0,83 -58.5,141.5T360,840ZM240,640h240q0,-29 -12.5,-54T432,544l-32,-24v-280q0,-17 -11.5,-28.5T360,200q-17,0 -28.5,11.5T320,240v280l-32,24q-23,17 -35.5,42T240,640Z"
android:fillColor="#e8eaed"/>
</vector>

View file

@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="200dp" android:viewportHeight="32" android:viewportWidth="32" android:width="200dp">
<path android:fillColor="#000000" android:pathData="M24.5,30a5.202,5.202 0,0 1,-4.626 -8.08L23.49,16.538a1.217,1.217 0,0 1,2.02 0L29.06,21.815A5.492,5.492 0,0 1,30 24.751,5.385 5.385,0 0,1 24.5,30ZM24.5,18.62 L21.564,22.987A3.208,3.208 0,0 0,24.5 28,3.385 3.385,0 0,0 28,24.751a3.435,3.435 0,0 0,-0.63 -1.867Z"/>
<path android:fillColor="#000000" android:pathData="M11,16V11h1a4.004,4.004 0,0 0,4 -4V4H13a3.978,3.978 0,0 0,-2.747 1.107A6.003,6.003 0,0 0,5 2H2V5a6.007,6.007 0,0 0,6 6H9v5H2v2H16V16ZM13,6h1V7a2.002,2.002 0,0 1,-2 2H11V8A2.002,2.002 0,0 1,13 6ZM8,9A4.004,4.004 0,0 1,4 5V4H5A4.004,4.004 0,0 1,9 8V9Z"/>
<path android:fillColor="#000000" android:pathData="M2,21h14v2h-14z"/>
<path android:fillColor="#000000" android:pathData="M2,26h14v2h-14z"/>
</vector>

View file

@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="200dp" android:viewportHeight="32" android:viewportWidth="32" android:width="200dp">
<path android:fillColor="#000000" android:pathData="M11,16V11h1a4.004,4.004 0,0 0,4 -4V4H13a3.978,3.978 0,0 0,-2.747 1.107A6.003,6.003 0,0 0,5 2H2V5a6.007,6.007 0,0 0,6 6H9v5H2v2H16V16ZM13,6h1V7a2.002,2.002 0,0 1,-2 2H11V8A2.002,2.002 0,0 1,13 6ZM8,9A4.004,4.004 0,0 1,4 5V4H5A4.004,4.004 0,0 1,9 8V9Z"/>
<path android:fillColor="#000000" android:pathData="M2,21h14v2h-14z"/>
<path android:fillColor="#000000" android:pathData="M2,26h14v2h-14z"/>
<path android:fillColor="#000000" android:pathData="M25,30a4.986,4.986 0,0 1,-3 -8.98L22,15a3,3 0,0 1,6 0v6.02A4.986,4.986 0,0 1,25 30ZM25,14a1.001,1.001 0,0 0,-1 1v7.13l-0.497,0.289A2.968,2.968 0,0 0,22 25a3,3 0,0 0,6 0,2.968 2.968,0 0,0 -1.503,-2.581L26,22.13L26,15A1.001,1.001 0,0 0,25 14Z"/>
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M23,11.99l-2.44,-2.79l0.34,-3.69l-3.61,-0.82L15.4,1.5L12,2.96L8.6,1.5L6.71,4.69L3.1,5.5L3.44,9.2L1,11.99l2.44,2.79l-0.34,3.7l3.61,0.82L8.6,22.5l3.4,-1.47l3.4,1.46l1.89,-3.19l3.61,-0.82l-0.34,-3.69L23,11.99zM19.05,13.47l-0.56,0.65l0.08,0.85l0.18,1.95l-1.9,0.43l-0.84,0.19l-0.44,0.74l-0.99,1.68l-1.78,-0.77L12,18.85l-0.79,0.34l-1.78,0.77l-0.99,-1.67l-0.44,-0.74l-0.84,-0.19l-1.9,-0.43l0.18,-1.96l0.08,-0.85l-0.56,-0.65l-1.29,-1.47l1.29,-1.48l0.56,-0.65L5.43,9.01L5.25,7.07l1.9,-0.43l0.84,-0.19l0.44,-0.74l0.99,-1.68l1.78,0.77L12,5.14l0.79,-0.34l1.78,-0.77l0.99,1.68l0.44,0.74l0.84,0.19l1.9,0.43l-0.18,1.95l-0.08,0.85l0.56,0.65l1.29,1.47L19.05,13.47z"
android:fillColor="#e3e3e3"/>
</vector>

View file

@ -0,0 +1,119 @@
/*
* 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.domain.usecase
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.feature.node.list.NodeFilterState
import org.meshtastic.proto.Config
import org.meshtastic.proto.User
class GetFilteredNodesUseCaseTest {
private lateinit var nodeRepository: NodeRepository
private lateinit var useCase: GetFilteredNodesUseCase
@Before
fun setUp() {
nodeRepository = mockk()
useCase = GetFilteredNodesUseCase(nodeRepository)
}
private fun createNode(
num: Int,
role: Config.DeviceConfig.Role = Config.DeviceConfig.Role.CLIENT,
ignored: Boolean = false,
name: String = "Node$num",
): Node {
val user = User(id = "!$num", long_name = name, short_name = "N$num", role = role)
return Node(num = num, user = user, isIgnored = ignored)
}
@Test
fun `invoke applies repository filters and returns nodes`() = runTest {
// Arrange
val nodes = listOf(createNode(1), createNode(2))
val filter = NodeFilterState(filterText = "Node", includeUnknown = true)
every {
nodeRepository.getNodes(
sort = NodeSortOption.LAST_HEARD,
filter = "Node",
includeUnknown = true,
onlyOnline = false,
onlyDirect = false,
)
} returns flowOf(nodes)
// Act
val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
// Assert
assertEquals(2, result.size)
assertEquals(1, result[0].num)
}
@Test
fun `invoke filters out ignored nodes if showIgnored is false`() = runTest {
// Arrange
val normalNode = createNode(1, ignored = false)
val ignoredNode = createNode(2, ignored = true)
val filter = NodeFilterState(showIgnored = false)
every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns
flowOf(listOf(normalNode, ignoredNode))
// Act
val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
// Assert
assertEquals(1, result.size)
assertEquals(1, result.first().num)
}
@Test
fun `invoke filters out infrastructure nodes if excludeInfrastructure is true`() = runTest {
// Arrange
val clientNode = createNode(1, role = Config.DeviceConfig.Role.CLIENT)
val routerNode = createNode(2, role = Config.DeviceConfig.Role.ROUTER)
@Suppress("DEPRECATION")
val repeaterNode = createNode(3, role = Config.DeviceConfig.Role.REPEATER)
val clientBaseNode = createNode(4, role = Config.DeviceConfig.Role.CLIENT_BASE)
val filter = NodeFilterState(excludeInfrastructure = true)
every { nodeRepository.getNodes(any(), any(), any(), any(), any()) } returns
flowOf(listOf(clientNode, routerNode, repeaterNode, clientBaseNode))
// Act
val result = useCase(filter, NodeSortOption.LAST_HEARD).first()
// Assert
// Should only keep the CLIENT node, others are infrastructure
assertEquals(1, result.size)
assertEquals(1, result.first().num)
}
}

View file

@ -27,8 +27,8 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.device_metrics_log
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device_metrics_log
import org.meshtastic.core.ui.theme.AppTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config