diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index bd456f9ea0..1285a90ffb 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -1720,14 +1720,10 @@ voltage wait_for_bluetooth_duration_seconds wake_on_tap_or_motion warning -### WAYPOINT ### waypoint_delete waypoint_edit waypoint_new waypoint_received -waypoint_recipient_broadcast -waypoint_recipient_channel -waypoint_send_to weight ### WIFI ### wifi_config diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt index 1f39b552c6..155f645727 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt @@ -263,9 +263,6 @@ fun MapView( // tile-download rectangle machinery) that becomes the draft's bounding box on confirm. Kept distinct from the // tile-download box (downloadRegionBoundingBox) so the two rectangle modes never collide. var geofenceBoxDraft by remember { mutableStateOf(null) } - // The recipient selected in EditWaypointDialog before "Set area" tore it down — carried across the round trip - // so re-opening the dialog with the drawn box doesn't silently reset the destination back to Broadcast. - var geofenceBoxDraftContactKey by remember { mutableStateOf(null) } var geofenceBoxBoundingBox: BoundingBox? by remember { mutableStateOf(null) } var showDownloadButton: Boolean by remember { mutableStateOf(false) } @@ -483,10 +480,8 @@ fun MapView( waypoint.toGeofence() != null && !mapViewModel.isMyWaypoint(id) -> showGeofenceInfoDialog = waypoint // edit only when unlocked or lockedTo myNodeNum - waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected -> { - geofenceBoxDraftContactKey = null + waypoint.locked_to in setOf(0, mapViewModel.myNodeNum ?: 0) && isConnected -> showEditWaypointDialog = waypoint - } else -> showDeleteWaypointDialog = waypoint } @@ -588,7 +583,6 @@ fun MapView( val enabled = isConnected && downloadRegionBoundingBox == null && geofenceBoxDraft == null if (enabled) { - geofenceBoxDraftContactKey = null showEditWaypointDialog = Waypoint(latitude_i = (p.latitude * 1e7).toInt(), longitude_i = (p.longitude * 1e7).toInt()) } @@ -900,16 +894,7 @@ fun MapView( EditWaypointDialog( waypoint = showEditWaypointDialog ?: return, // Safe call displayUnits = displayUnits, - nodes = nodes, - ourNode = ourNodeInfo, - channelSet = channelSet, - initialContactKey = - geofenceBoxDraftContactKey - ?: showEditWaypointDialog - ?.let { wp -> waypoints[wp.id] } - ?.let { mapViewModel.waypointContactKey(it) } - ?: "0${NodeAddress.ID_BROADCAST}", - onSend = { waypoint, contactKey -> + onSend = { waypoint -> Logger.d { "User clicked send waypoint ${waypoint.id}" } showEditWaypointDialog = null @@ -927,7 +912,6 @@ fun MapView( locked_to = newLockedTo, icon = newIcon, ), - contactKey, ) }, onDelete = { waypoint -> @@ -939,11 +923,10 @@ fun MapView( Logger.d { "User clicked cancel marker edit dialog" } showEditWaypointDialog = null }, - onBeginBoxAuthoring = { draft, contactKey -> + onBeginBoxAuthoring = { draft -> Logger.d { "User began geofence box authoring for waypoint ${draft.id}" } showEditWaypointDialog = null geofenceBoxDraft = draft - geofenceBoxDraftContactKey = contactKey map.generateGeofenceBoxOverlay() }, ) @@ -963,7 +946,6 @@ fun MapView( if (waypoint.locked_to == 0 && isConnected) { { showGeofenceInfoDialog = null - geofenceBoxDraftContactKey = null showEditWaypointDialog = waypoint } } else { @@ -997,13 +979,7 @@ fun MapView( TextButton( onClick = { Logger.d { "User deleted waypoint ${waypoint.id} for everyone" } - // Route the expiry to wherever the waypoint was originally sent (a DM or a secondary - // channel), not the default broadcast — otherwise a DM'd waypoint's "delete for - // everyone" never reaches its actual recipient. - val originalContactKey = - waypoints[waypoint.id]?.let { mapViewModel.waypointContactKey(it) } - ?: "0${NodeAddress.ID_BROADCAST}" - mapViewModel.sendWaypoint(waypoint.copy(expire = 1), originalContactKey) + mapViewModel.sendWaypoint(waypoint.copy(expire = 1)) mapViewModel.deleteWaypoint(waypoint.id) showDeleteWaypointDialog = null }, diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt index bbfcb37c47..56d247d828 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -31,7 +31,6 @@ import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.repository.UiPrefs import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.feature.map.BaseMapViewModel import java.io.InputStream @@ -45,7 +44,6 @@ class MapViewModel( radioController: RadioController, radioConfigRepository: RadioConfigRepository, notificationPrefs: NotificationPrefs, - uiPrefs: UiPrefs, buildConfigProvider: BuildConfigProvider, private val mapLayersManager: MapLayersManager, savedStateHandle: SavedStateHandle, @@ -56,7 +54,6 @@ class MapViewModel( radioController, radioConfigRepository, notificationPrefs, - uiPrefs, ) { private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt index 4108deeeeb..b1ae7a07a1 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt @@ -114,7 +114,6 @@ import org.meshtastic.app.map.component.WaypointMarkers import org.meshtastic.app.map.model.NodeClusterItem import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.geofence.toGeofence import org.meshtastic.core.model.util.GeoConstants.DEG_D @@ -263,9 +262,6 @@ fun MapView( // --- Geofence box authoring (Main mode) --- // When non-null, the user is defining a bounding box for [boxAuthoringDraft] by tapping two corners. var boxAuthoringDraft by remember { mutableStateOf(null) } - // The recipient selected in EditWaypointDialog before box authoring tore it down — carried across the round trip - // so re-opening the dialog with the drawn box doesn't silently reset the destination back to Broadcast. - var boxAuthoringDraftContactKey by remember { mutableStateOf(null) } var boxAuthoringFirstCorner by remember { mutableStateOf(null) } var boxAuthoringSecondCorner by remember { mutableStateOf(null) } @@ -350,7 +346,6 @@ fun MapView( DisposableEffect(Unit) { onDispose { fusedLocationClient.removeLocationUpdates(locationCallback) } } // --- Node & waypoint data --- - val nodes by mapViewModel.nodes.collectAsStateWithLifecycle() val allNodes by mapViewModel.nodesWithPosition.collectAsStateWithLifecycle(listOf()) val waypoints by mapViewModel.waypoints.collectAsStateWithLifecycle(emptyMap()) val displayableWaypoints = waypoints.values.mapNotNull { it.waypoint } @@ -603,7 +598,6 @@ fun MapView( }, onMapLongClick = { latLng -> if (isMainMode && isConnected && boxAuthoringDraft == null) { - boxAuthoringDraftContactKey = null editingWaypoint = Waypoint( latitude_i = (latLng.latitude / DEG_D).toInt(), @@ -662,10 +656,7 @@ fun MapView( displayableWaypoints = displayableWaypoints, myNodeNum = myNodeNum, isConnected = isConnected, - onEditWaypointRequest = { - boxAuthoringDraftContactKey = null - editingWaypoint = it - }, + onEditWaypointRequest = { editingWaypoint = it }, onShowGeofenceInfo = { geofenceInfoWaypoint = it }, selectedWaypointId = selectedWaypointId, mapLayers = mapLayers, @@ -711,14 +702,7 @@ fun MapView( EditWaypointDialog( waypoint = waypointToEdit, displayUnits = displayUnits, - nodes = nodes, - ourNode = ourNodeInfo, - channelSet = channelSet, - initialContactKey = - boxAuthoringDraftContactKey - ?: waypoints[waypointToEdit.id]?.let { mapViewModel.waypointContactKey(it) } - ?: "0${NodeAddress.ID_BROADCAST}", - onSend = { updatedWp, contactKey -> + onSend = { updatedWp -> var finalWp = updatedWp if (updatedWp.id == 0) { finalWp = finalWp.copy(id = mapViewModel.generatePacketId()) @@ -726,26 +710,19 @@ fun MapView( if (updatedWp.icon == 0) { finalWp = finalWp.copy(icon = 0x1F4CD) } - mapViewModel.sendWaypoint(finalWp, contactKey) + mapViewModel.sendWaypoint(finalWp) editingWaypoint = null }, onDelete = { wpToDelete -> if (wpToDelete.locked_to == 0 && isConnected && wpToDelete.id != 0) { - // Route the expiry to wherever the waypoint was originally sent (a DM or a secondary - // channel), not the default broadcast — otherwise a DM'd waypoint's deletion never - // reaches its actual recipient. - val originalContactKey = - waypoints[wpToDelete.id]?.let { mapViewModel.waypointContactKey(it) } - ?: "0${NodeAddress.ID_BROADCAST}" - mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1), originalContactKey) + mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1)) } mapViewModel.deleteWaypoint(wpToDelete.id) editingWaypoint = null }, onDismissRequest = { editingWaypoint = null }, - onBeginBoxAuthoring = { draft, contactKey -> + onBeginBoxAuthoring = { draft -> boxAuthoringDraft = draft - boxAuthoringDraftContactKey = contactKey boxAuthoringFirstCorner = null boxAuthoringSecondCorner = null editingWaypoint = null @@ -767,7 +744,6 @@ fun MapView( if (waypoint.locked_to == 0 && isConnected) { { geofenceInfoWaypoint = null - boxAuthoringDraftContactKey = null editingWaypoint = waypoint } } else { diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt index c8b8b090f1..5015ef00c0 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt @@ -97,7 +97,6 @@ class MapViewModel( radioController, radioConfigRepository, notificationPrefs, - uiPrefs, ) { private val _selectedWaypointId = MutableStateFlow(savedStateHandle.get("waypointId")) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt index 3e2b654211..656da0cc33 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NodeSortOption.kt @@ -34,10 +34,4 @@ enum class NodeSortOption(val sqlValue: String, val stringRes: StringResource) { CHANNEL("channel", Res.string.node_sort_channel), VIA_MQTT("via_mqtt", Res.string.node_sort_via_mqtt), VIA_FAVORITE("via_favorite", Res.string.node_sort_via_favorite), - ; - - companion object { - /** Maps a persisted preference ordinal (e.g. `UiPrefs.nodeSort`) back to its option. */ - fun fromOrdinal(ordinal: Int): NodeSortOption = entries.getOrElse(ordinal) { VIA_FAVORITE } - } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt index 09201a6676..a84cac27d3 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/AppPreferences.kt @@ -16,11 +16,8 @@ */ package org.meshtastic.core.repository -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map import org.meshtastic.core.model.DeviceType -import org.meshtastic.core.model.NodeSortOption /** Reactive interface for analytics-related preferences. */ interface AnalyticsPrefs { @@ -197,10 +194,6 @@ interface UiPrefs { fun setShouldShowTelemetry(value: Boolean) } -/** Maps the persisted [UiPrefs.nodeSort] ordinal to its [NodeSortOption], the single source every consumer shares. */ -val UiPrefs.nodeSortOption: Flow - get() = nodeSort.map { NodeSortOption.fromOrdinal(it) } - /** Reactive interface for notification preferences. */ interface NotificationPrefs { val messagesEnabled: StateFlow diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index d9f4fc9e08..ae7676d4a6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -1765,14 +1765,10 @@ Wait for Bluetooth duration Wake on tap or motion Warning - Delete waypoint? Edit waypoint New waypoint Received waypoint: %1$s - Broadcast (all nodes) - Channel %1$d - Send to Weight WiFi Options diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts index d6dfea310d..63ce33a721 100644 --- a/feature/map/build.gradle.kts +++ b/feature/map/build.gradle.kts @@ -37,16 +37,5 @@ kotlin { implementation(projects.core.ui) implementation(projects.core.di) } - - getByName("androidHostTest") { - dependencies { - implementation(projects.core.testing) - implementation(libs.kotlinx.coroutines.test) - implementation(libs.compose.multiplatform.ui.test) - // Registers a ComponentActivity in the test manifest so Robolectric's runComposeUiTest has a host - // activity to attach to (feature/map is a library module with no launcher activity of its own). - implementation(libs.androidx.compose.ui.test.manifest) - } - } } } diff --git a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt b/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt deleted file mode 100644 index 2ded8f0371..0000000000 --- a/feature/map/src/androidHostTest/kotlin/org/meshtastic/feature/map/component/EditWaypointDialogTest.kt +++ /dev/null @@ -1,252 +0,0 @@ -/* - * Copyright (c) 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 . - */ -package org.meshtastic.feature.map.component - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.assertCountEquals -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onAllNodesWithText -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.v2.runComposeUiTest -import okio.ByteString.Companion.encodeUtf8 -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.model.NodeAddress -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.getString -import org.meshtastic.core.resources.node_filter_placeholder -import org.meshtastic.core.resources.send -import org.meshtastic.core.resources.waypoint_recipient_broadcast -import org.meshtastic.core.testing.TestDataFactory -import org.meshtastic.proto.ChannelSet -import org.meshtastic.proto.ChannelSettings -import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits -import org.meshtastic.proto.Waypoint -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import kotlin.test.assertEquals - -@OptIn(ExperimentalTestApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class EditWaypointDialogTest { - - private val waypoint = Waypoint(id = 1, name = "Test Waypoint") - private val node = TestDataFactory.createTestNode(num = 42, userId = "!a1b2c3d4", longName = "Alice") - private val channelSet = - ChannelSet(settings = listOf(ChannelSettings(name = "Primary"), ChannelSettings(name = "Secondary"))) - - @Test - fun sendDefaultsToPrimaryChannel() = runComposeUiTest { - var sentContactKey: String? = null - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node), - ourNode = null, - channelSet = channelSet, - onSend = { _, contactKey -> sentContactKey = contactKey }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - // The primary channel (index 0) is labelled with its configured name, not a generic "Broadcast" string. - onNodeWithText("Primary").assertIsDisplayed() - onNodeWithText(getString(Res.string.send)).performClick() - - assertEquals("0${NodeAddress.ID_BROADCAST}", sentContactKey) - } - - @Test - fun sendDefaultsToGenericBroadcastLabelWhenChannelSetUnavailable() = runComposeUiTest { - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node), - ourNode = null, - channelSet = null, - onSend = { _, _ -> }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - // No channel info (e.g. disconnected) falls back to the generic "Broadcast" label. - onNodeWithText(getString(Res.string.waypoint_recipient_broadcast)).assertIsDisplayed() - } - - @Test - fun sendOffersOnlyBroadcastWhenChannelSetSettingsIsEmpty() = runComposeUiTest { - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node), - ourNode = null, - // Non-null but empty settings (e.g. right after a fresh install, before channel sync completes) must - // still offer a Broadcast option rather than rendering zero channel rows. - channelSet = ChannelSet(settings = emptyList()), - onSend = { _, _ -> }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - onNodeWithText(getString(Res.string.waypoint_recipient_broadcast)).assertIsDisplayed() - } - - @Test - fun pickingANonPkcNodeRoutesViaItsOwnChannel() = runComposeUiTest { - var sentContactKey: String? = null - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node), - ourNode = null, - channelSet = channelSet, - onSend = { _, contactKey -> sentContactKey = contactKey }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - // The clickable element is the TextButton showing the *current* recipient value ("Primary" before any - // selection), not the static "Send to" label beside it. - onNodeWithText("Primary").performClick() - onNodeWithText("Alice").assertIsDisplayed() - onNodeWithText("Alice").performClick() - onNodeWithText(getString(Res.string.send)).performClick() - - // Neither this node nor our local node advertises PKC, so the DM routes on the node's own channel (0), - // not the PKC channel index — matching NodeListViewModel.getDirectMessageRoute's convention. - assertEquals("0!a1b2c3d4", sentContactKey) - } - - @Test - fun pickingAPkcCapableNodeRoutesViaPkcChannel() = runComposeUiTest { - var sentContactKey: String? = null - val pkcNode = node.copy(publicKey = "node-key".encodeUtf8()) - val ourPkcNode = - TestDataFactory.createTestNode(num = 1, userId = "!local0001").copy(publicKey = "our-key".encodeUtf8()) - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(pkcNode), - ourNode = ourPkcNode, - channelSet = channelSet, - onSend = { _, contactKey -> sentContactKey = contactKey }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - onNodeWithText("Primary").performClick() - onNodeWithText("Alice").performClick() - onNodeWithText(getString(Res.string.send)).performClick() - - // Both ends support PKC, so the DM routes on the dedicated PKC channel index. - assertEquals("${NodeAddress.PKC_CHANNEL_INDEX}!a1b2c3d4", sentContactKey) - } - - @Test - fun pickingASecondaryChannelRoutesSendToThatChannel() = runComposeUiTest { - var sentContactKey: String? = null - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node), - ourNode = null, - channelSet = channelSet, - onSend = { _, contactKey -> sentContactKey = contactKey }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - onNodeWithText("Primary").performClick() - onNodeWithText("Secondary").assertIsDisplayed() - onNodeWithText("Secondary").performClick() - onNodeWithText(getString(Res.string.send)).performClick() - - assertEquals("1${NodeAddress.ID_BROADCAST}", sentContactKey) - } - - @Test - fun initialContactKeySeedsTheSelectedRecipient() = runComposeUiTest { - var sentContactKey: String? = null - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node), - ourNode = null, - channelSet = channelSet, - // Simulates re-opening the dialog after geofence box authoring with a previously-picked recipient. - initialContactKey = "1${NodeAddress.ID_BROADCAST}", - onSend = { _, contactKey -> sentContactKey = contactKey }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - onNodeWithText("Secondary").assertIsDisplayed() - onNodeWithText(getString(Res.string.send)).performClick() - - assertEquals("1${NodeAddress.ID_BROADCAST}", sentContactKey) - } - - @Test - fun filteringRecipientPickerNarrowsNodesButKeepsChannelsVisible() = runComposeUiTest { - val bob = TestDataFactory.createTestNode(num = 43, userId = "!b1c2d3e4", longName = "Bob", shortName = "B") - setContent { - EditWaypointDialog( - waypoint = waypoint, - displayUnits = DisplayUnits.METRIC, - nodes = listOf(node, bob), - ourNode = null, - channelSet = channelSet, - onSend = { _, _ -> }, - onDelete = {}, - onDismissRequest = {}, - ) - } - - onNodeWithText("Primary").performClick() - onNodeWithText("Alice").assertIsDisplayed() - onNodeWithText("Bob").assertIsDisplayed() - - onNodeWithText(getString(Res.string.node_filter_placeholder)).performTextInput("Bob") - - // Filtering by node name narrows the node list. "Bob" now matches two elements — the filter field's own - // typed-in value, and the surviving node row — so assert the count directly rather than picking an - // arbitrary match by index, which would risk silently checking the wrong element. - onAllNodesWithText("Bob").assertCountEquals(2) - onAllNodesWithText("Alice").assertCountEquals(0) - // ...but channel rows are never filtered out. "Primary" also matches the (now-covered) "Send to" button - // behind the dialog, so likewise assert the count (button + picker row) instead of an arbitrary index. - onAllNodesWithText("Primary").assertCountEquals(2) - onNodeWithText("Secondary").assertIsDisplayed() - } -} diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt index deb3895c7d..49dfeaffc8 100644 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt +++ b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/EditWaypointDialog.kt @@ -55,7 +55,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -75,11 +74,7 @@ import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.systemTimeZone -import org.meshtastic.core.model.ContactKey -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.geofence.GeofenceRadiusPresets -import org.meshtastic.core.model.util.getChannel import org.meshtastic.core.model.util.toDistanceString import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.cancel @@ -102,69 +97,19 @@ import org.meshtastic.core.resources.send import org.meshtastic.core.resources.time import org.meshtastic.core.resources.waypoint_edit import org.meshtastic.core.resources.waypoint_new -import org.meshtastic.core.resources.waypoint_recipient_broadcast -import org.meshtastic.core.resources.waypoint_recipient_channel -import org.meshtastic.core.resources.waypoint_send_to import org.meshtastic.core.ui.emoji.EmojiPickerDialog import org.meshtastic.core.ui.icon.CalendarMonth import org.meshtastic.core.ui.icon.Lock import org.meshtastic.core.ui.icon.MeshtasticIcons -import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.Config.DisplayConfig.DisplayUnits import org.meshtastic.proto.Waypoint import kotlin.time.Duration.Companion.hours -/** Contact key for the broadcast destination on the primary channel — the historical default for waypoints. */ -private val BROADCAST_CONTACT_KEY = ContactKey.broadcast(0).value - -/** - * Number of selectable channel slots in [channelSet]: at least 1, so a channel-less/disconnected state still offers - * Broadcast. - */ -internal fun channelCount(channelSet: ChannelSet?): Int = channelSet?.settings?.size?.takeIf { it > 0 } ?: 1 - -/** - * Display name for channel [index] in [channelSet]: its configured name, or "Broadcast" (index 0) / "Channel N" as a - * fallback. - */ -@Composable -internal fun channelLabel(channelSet: ChannelSet?, index: Int): String { - val configuredName = channelSet?.getChannel(index)?.name - return configuredName - ?: if (index == 0) { - stringResource(Res.string.waypoint_recipient_broadcast) - } else { - stringResource(Res.string.waypoint_recipient_channel, index) - } -} - -/** - * Display name for the recipient identified by [contactKey]: the destination channel's configured name (including the - * primary channel, e.g. "LongFast" — falling back to "Broadcast" only when no channel info is available at all, such as - * while disconnected), a DM node's long/short name, or — if that node is no longer in [nodes] — its raw id, so a stale - * selection never misreports itself as "Broadcast". - */ -@Composable -private fun recipientLabel(contactKey: String, nodes: List, ourNode: Node?, channelSet: ChannelSet?): String { - val parsed = ContactKey(contactKey) - return if (parsed.address is NodeAddress.Broadcast) { - channelLabel(channelSet, parsed.channel) - } else { - val node = nodes.find { dmContactKey(it, ourNode) == contactKey } - node?.user?.long_name?.takeIf { it.isNotBlank() } - ?: node?.user?.short_name?.takeIf { it.isNotBlank() } - ?: (parsed.address as? NodeAddress.ById)?.id - ?: stringResource(Res.string.waypoint_recipient_broadcast) - } -} - /** * Shared waypoint editor used by both the google and fdroid map flavors (DRY — replaces the two drifted per-flavor * copies). Map-engine-specific concerns stay outside: drawing the box overlay and the drag-to-define gesture are - * triggered via [onBeginBoxAuthoring], which hands the current draft *and the selected recipient* back to the flavor's - * map so the user can define the bounding box there; the flavor re-opens this dialog (passing the same recipient back - * as [initialContactKey]) with the drawn box applied — otherwise the recipient choice would silently reset to Broadcast - * every time the user draws a geofence area. + * triggered via [onBeginBoxAuthoring], which hands the current draft back to the flavor's map so the user can define + * the bounding box there; the flavor re-opens this dialog with the drawn box applied. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod", "MagicNumber") @@ -172,23 +117,17 @@ private fun recipientLabel(contactKey: String, nodes: List, ourNode: Node? fun EditWaypointDialog( waypoint: Waypoint, displayUnits: DisplayUnits, - nodes: List, - ourNode: Node?, - channelSet: ChannelSet?, - onSend: (Waypoint, contactKey: String) -> Unit, + onSend: (Waypoint) -> Unit, onDelete: (Waypoint) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - initialContactKey: String = BROADCAST_CONTACT_KEY, - onBeginBoxAuthoring: (Waypoint, contactKey: String) -> Unit = { _, _ -> }, + onBeginBoxAuthoring: (Waypoint) -> Unit = {}, ) { - var waypointInput by remember(waypoint.id) { mutableStateOf(waypoint) } + var waypointInput by remember { mutableStateOf(waypoint) } val title = if (waypoint.id == 0) Res.string.waypoint_new else Res.string.waypoint_edit val defaultEmoji = 0x1F4CD // 📍 Round Pushpin val currentEmojiCodepoint = if (waypointInput.icon == 0) defaultEmoji else waypointInput.icon var showEmojiPickerView by remember { mutableStateOf(false) } - var showRecipientPicker by rememberSaveable { mutableStateOf(false) } - var selectedContactKey by rememberSaveable(waypoint.id) { mutableStateOf(initialContactKey) } val context = LocalContext.current val tz = systemTimeZone @@ -271,17 +210,6 @@ fun EditWaypointDialog( maxLines = 3, ) Spacer(modifier = Modifier.size(8.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringResource(Res.string.waypoint_send_to)) - TextButton(onClick = { showRecipientPicker = true }) { - Text(recipientLabel(selectedContactKey, nodes, ourNode, channelSet)) - } - } - Spacer(modifier = Modifier.size(8.dp)) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -443,7 +371,7 @@ fun EditWaypointDialog( waypoint = waypointInput, displayUnits = displayUnits, onWaypointChange = { waypointInput = it }, - onBeginBoxAuthoring = { onBeginBoxAuthoring(waypointInput, selectedContactKey) }, + onBeginBoxAuthoring = { onBeginBoxAuthoring(waypointInput) }, ) } }, @@ -461,10 +389,7 @@ fun EditWaypointDialog( TextButton(onClick = onDismissRequest, modifier = Modifier.padding(end = 8.dp)) { Text(stringResource(Res.string.cancel)) } - Button( - onClick = { onSend(waypointInput, selectedContactKey) }, - enabled = (waypointInput.name).isNotBlank(), - ) { + Button(onClick = { onSend(waypointInput) }, enabled = (waypointInput.name).isNotBlank()) { Text(stringResource(Res.string.send)) } } @@ -478,20 +403,6 @@ fun EditWaypointDialog( waypointInput = waypointInput.copy(icon = selectedEmoji.codePointAt(0)) } } - - if (showRecipientPicker) { - WaypointRecipientPickerDialog( - nodes = nodes, - ourNode = ourNode, - channelSet = channelSet, - selectedContactKey = selectedContactKey, - onSelect = { - selectedContactKey = it - showRecipientPicker = false - }, - onDismissRequest = { showRecipientPicker = false }, - ) - } } /** diff --git a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt b/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt deleted file mode 100644 index 73a6572aaa..0000000000 --- a/feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/component/WaypointRecipientPicker.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright (c) 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 . - */ -package org.meshtastic.feature.map.component - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.selection.toggleable -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp -import org.jetbrains.compose.resources.stringResource -import org.meshtastic.core.model.ContactKey -import org.meshtastic.core.model.Node -import org.meshtastic.core.model.NodeAddress -import org.meshtastic.core.resources.Res -import org.meshtastic.core.resources.cancel -import org.meshtastic.core.resources.node_filter_placeholder -import org.meshtastic.core.resources.waypoint_send_to -import org.meshtastic.proto.ChannelSet - -/** - * Direct-message contact key for sending to [node]. Uses the PKC channel index only when both ends support it — mirrors - * [org.meshtastic.feature.node.list.NodeListViewModel.getDirectMessageRoute], the app's other DM-key builder — - * otherwise falls back to the node's own channel, matching how a DM would actually route on the mesh. - */ -internal fun dmContactKey(node: Node, ourNode: Node?): String { - val hasPKC = ourNode?.hasPKC == true && node.hasPKC - val channel = if (hasPKC) NodeAddress.PKC_CHANNEL_INDEX else node.channel - return "$channel${node.user.id}" -} - -/** - * Nodes whose long or short name contains [filterText] (case-insensitive); all of [nodes] when [filterText] is blank. - */ -private fun filterNodesByName(nodes: List, filterText: String): List = if (filterText.isBlank()) { - nodes -} else { - nodes.filter { - it.user.long_name.contains(filterText, ignoreCase = true) || - it.user.short_name.contains(filterText, ignoreCase = true) - } -} - -/** Recipient-picker row label for [node]: long name, short name, or a derived id — never blank. */ -private fun nodeRowLabel(node: Node): String = node.user.long_name.takeIf { it.isNotBlank() } - ?: node.user.short_name.takeIf { it.isNotBlank() } - ?: NodeAddress.numToDefaultId(node.num) - -/** - * Lists the primary channel (labelled with its configured name, e.g. "LongFast") plus any secondary channels, then - * every known [nodes] entry — letting the user pick a waypoint destination the same way the Python Meshtastic CLI/API - * can target a channel or a specific node. - */ -@Composable -internal fun WaypointRecipientPickerDialog( - nodes: List, - ourNode: Node?, - channelSet: ChannelSet?, - selectedContactKey: String, - onSelect: (String) -> Unit, - onDismissRequest: () -> Unit, -) { - val distinctNodes = nodes.distinctBy { it.num } - var filterText by rememberSaveable { mutableStateOf("") } - val filteredNodes = remember(distinctNodes, filterText) { filterNodesByName(distinctNodes, filterText) } - - AlertDialog( - onDismissRequest = onDismissRequest, - title = { Text(stringResource(Res.string.waypoint_send_to)) }, - text = { - Column { - OutlinedTextField( - value = filterText, - onValueChange = { filterText = it }, - label = { Text(stringResource(Res.string.node_filter_placeholder)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.size(8.dp)) - LazyColumn(modifier = Modifier.heightIn(max = 400.dp)) { - items(count = channelCount(channelSet), key = { "channel_$it" }) { channelIndex -> - val contactKey = ContactKey.broadcast(channelIndex).value - RecipientRow( - label = channelLabel(channelSet, channelIndex), - selected = selectedContactKey == contactKey, - onClick = { onSelect(contactKey) }, - ) - } - if (filteredNodes.isNotEmpty()) { - item { - Spacer(modifier = Modifier.size(8.dp)) - HorizontalDivider() - Spacer(modifier = Modifier.size(8.dp)) - } - } - items(filteredNodes, key = { it.num }) { node -> - val contactKey = dmContactKey(node, ourNode) - RecipientRow( - label = nodeRowLabel(node), - selected = selectedContactKey == contactKey, - onClick = { onSelect(contactKey) }, - ) - } - } - } - }, - confirmButton = { TextButton(onClick = onDismissRequest) { Text(stringResource(Res.string.cancel)) } }, - dismissButton = null, - ) -} - -@Composable -private fun RecipientRow(label: String, selected: Boolean, onClick: () -> Unit) { - Row( - modifier = - Modifier.fillMaxWidth() - .toggleable(value = selected, role = Role.RadioButton, onValueChange = { onClick() }), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = selected, onClick = null) - Spacer(modifier = Modifier.width(8.dp)) - Text(label) - } -} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt index 85a5761fef..aabba7f836 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt @@ -17,12 +17,10 @@ package org.meshtastic.feature.map import androidx.lifecycle.ViewModel -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import org.jetbrains.compose.resources.StringResource @@ -34,7 +32,6 @@ import org.meshtastic.core.model.Node import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.geofence.activeWaypointPackets -import org.meshtastic.core.model.isBroadcast import org.meshtastic.core.model.isFromLocal import org.meshtastic.core.model.util.DistanceUnit import org.meshtastic.core.repository.MapPrefs @@ -43,8 +40,6 @@ import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.repository.nodeSortOption import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.any import org.meshtastic.core.resources.eight_hours @@ -72,7 +67,6 @@ open class BaseMapViewModel( private val radioController: RadioController, private val radioConfigRepository: RadioConfigRepository, private val notificationPrefs: NotificationPrefs, - private val uiPrefs: UiPrefs, ) : ViewModel() { val myNodeInfo = nodeRepository.myNodeInfo @@ -102,13 +96,9 @@ open class BaseMapViewModel( .map { it is org.meshtastic.core.model.ConnectionState.Connected } .stateInWhileSubscribed(initialValue = false) - /** - * Nodes sorted per the user's current Nodes-tab sort preference (re-queried live whenever that preference changes, - * e.g. via the waypoint recipient picker staying in sync with the Nodes tab). - */ val nodes: StateFlow> = - uiPrefs.nodeSortOption - .flatMapLatest { sort -> nodeRepository.getNodes(sort = sort) } + nodeRepository + .getNodes() .map { nodes -> nodes.filterNot { node -> node.isIgnored } } .stateInWhileSubscribed(initialValue = emptyList()) @@ -186,25 +176,16 @@ open class BaseMapViewModel( fun deleteWaypoint(id: Int) = safeLaunch(context = ioDispatcher, tag = "deleteWaypoint") { packetRepository.deleteWaypoint(id) } - /** - * Contact key a waypoint packet was exchanged on: its destination for packets we sent (or broadcasts), otherwise - * its sender — the same derivation as MeshDataHandlerImpl.rememberDataPacket, so edits and expiries route back to - * the conversation the waypoint actually lives in (a raw `to` would self-address a waypoint someone DM'd to us). - */ - fun waypointContactKey(packet: DataPacket): String { - val contact = if (packet.isFromLocal(myNodeNum) || packet.isBroadcast) packet.to else packet.from - return "${packet.channel}$contact" - } - - fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${NodeAddress.ID_BROADCAST}"): Job? { + fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${NodeAddress.ID_BROADCAST}") { // contactKey: unique contact key filter (channel)+(nodeId) val parsedKey = ContactKey(contactKey) val p = DataPacket(parsedKey.addressString, parsedKey.channel, wpt) - return if (wpt.id != 0) sendDataPacket(p) else null + if (wpt.id != 0) sendDataPacket(p) } - private fun sendDataPacket(p: DataPacket): Job = + private fun sendDataPacket(p: DataPacket) { safeLaunch(context = ioDispatcher, tag = "sendDataPacket") { radioController.sendMessage(p) } + } fun generatePacketId(): Int = radioController.generatePacketId() diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt index 5e5f533e6d..482e7702f6 100644 --- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/SharedMapViewModel.kt @@ -23,7 +23,6 @@ import org.meshtastic.core.repository.NotificationPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.RadioController -import org.meshtastic.core.repository.UiPrefs @KoinViewModel class SharedMapViewModel( @@ -33,7 +32,6 @@ class SharedMapViewModel( radioController: RadioController, radioConfigRepository: RadioConfigRepository, notificationPrefs: NotificationPrefs, - uiPrefs: UiPrefs, ) : BaseMapViewModel( mapPrefs, nodeRepository, @@ -41,5 +39,4 @@ class SharedMapViewModel( radioController, radioConfigRepository, notificationPrefs, - uiPrefs, ) diff --git a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt index b26092dcd4..909c3bcb28 100644 --- a/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt +++ b/feature/map/src/commonTest/kotlin/org/meshtastic/feature/map/BaseMapViewModelTest.kt @@ -31,14 +31,12 @@ import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.model.DataPacket import org.meshtastic.core.model.NodeAddress -import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.MapPrefs import org.meshtastic.core.repository.PacketRepository import org.meshtastic.core.testing.FakeNodeRepository import org.meshtastic.core.testing.FakeNotificationPrefs import org.meshtastic.core.testing.FakeRadioConfigRepository import org.meshtastic.core.testing.FakeRadioController -import org.meshtastic.core.testing.FakeUiPrefs import org.meshtastic.core.testing.TestDataFactory import org.meshtastic.proto.Waypoint import kotlin.test.AfterTest @@ -46,7 +44,6 @@ import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -import kotlin.test.assertNull @OptIn(ExperimentalCoroutinesApi::class) class BaseMapViewModelTest { @@ -85,7 +82,6 @@ class BaseMapViewModelTest { radioController = radioController, radioConfigRepository = radioConfigRepository, notificationPrefs = FakeNotificationPrefs(), - uiPrefs = FakeUiPrefs(), ) } @@ -138,66 +134,6 @@ class BaseMapViewModelTest { assertEquals(3, nodeRepository.nodeDBbyNum.value.size) } - @Test - fun testNodesResortWhenSortPreferenceChanges() = runTest(testDispatcher) { - val uiPrefs = FakeUiPrefs() - val vm = - BaseMapViewModel( - mapPrefs = mapPrefs, - nodeRepository = nodeRepository, - packetRepository = packetRepository, - radioController = radioController, - radioConfigRepository = radioConfigRepository, - notificationPrefs = FakeNotificationPrefs(), - uiPrefs = uiPrefs, - ) - // Alice sorts first alphabetically, but Zoe was heard more recently — the two sort modes disagree, so a - // resort is observable. - val alice = TestDataFactory.createTestNode(num = 1, userId = "!a", longName = "Alice", lastHeard = 100) - val zoe = TestDataFactory.createTestNode(num = 2, userId = "!z", longName = "Zoe", lastHeard = 200) - nodeRepository.setNodes(listOf(alice, zoe)) - - vm.nodes.test { - // Default sort (LAST_HEARD): most-recently-heard first. - assertEquals(listOf("Zoe", "Alice"), awaitItem().map { it.user.long_name }) - - // Switching the Nodes-tab sort preference re-sorts the map's node list live, without a new - // subscription. - uiPrefs.setNodeSort(NodeSortOption.ALPHABETICAL.ordinal) - assertEquals(listOf("Alice", "Zoe"), awaitItem().map { it.user.long_name }) - - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun testSendWaypointDefaultsToBroadcast() = runTest(testDispatcher) { - viewModel.sendWaypoint(Waypoint(id = 1, name = "Broadcast Waypoint"))?.join() - - val sent = radioController.sentPackets.single() - assertEquals(NodeAddress.ID_BROADCAST, sent.to) - assertEquals(0, sent.channel) - } - - @Test - fun testSendWaypointWithContactKeyRoutesToNode() = runTest(testDispatcher) { - val contactKey = "8!a1b2c3d4" - - viewModel.sendWaypoint(Waypoint(id = 2, name = "DM Waypoint"), contactKey)?.join() - - val sent = radioController.sentPackets.single() - assertEquals("!a1b2c3d4", sent.to) - assertEquals(8, sent.channel) - } - - @Test - fun testSendWaypointWithZeroIdDoesNotSend() = runTest(testDispatcher) { - val job = viewModel.sendWaypoint(Waypoint(id = 0, name = "Unsaved Draft")) - - assertNull(job) - assertEquals(emptyList(), radioController.sentPackets) - } - @Test fun testWaypointsIncludeFutureExpirations() = runTest(testDispatcher) { val now = nowSeconds.toInt() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt index cf4af287cd..b3568e2869 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeFilterPreferences.kt @@ -16,10 +16,10 @@ */ package org.meshtastic.feature.node.list +import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single import org.meshtastic.core.model.NodeSortOption import org.meshtastic.core.repository.UiPrefs -import org.meshtastic.core.repository.nodeSortOption @Single @Suppress("TooManyFunctions") @@ -43,7 +43,8 @@ open class NodeFilterPreferences constructor(private val uiPrefs: UiPrefs) { open val shouldShowRole = uiPrefs.shouldShowRole open val shouldShowTelemetry = uiPrefs.shouldShowTelemetry - open val nodeSortOption = uiPrefs.nodeSortOption + open val nodeSortOption = + uiPrefs.nodeSort.map { NodeSortOption.entries.getOrElse(it) { NodeSortOption.VIA_FAVORITE } } open fun setNodeSort(option: NodeSortOption) { uiPrefs.setNodeSort(option.ordinal)