feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)

This commit is contained in:
James Rich 2026-03-21 18:19:13 -05:00 committed by GitHub
parent f04924ded5
commit d136b162a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
170 changed files with 2208 additions and 2432 deletions

View file

@ -48,6 +48,7 @@ import kotlinx.coroutines.flow.first
import org.koin.core.context.startKoin
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.navigation.MeshtasticNavSavedStateConfig
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.service.MeshServiceOrchestrator
@ -57,7 +58,6 @@ import org.meshtastic.desktop.data.DesktopPreferencesDataSource
import org.meshtastic.desktop.di.desktopModule
import org.meshtastic.desktop.di.desktopPlatformModule
import org.meshtastic.desktop.ui.DesktopMainScreen
import org.meshtastic.desktop.ui.navSavedStateConfig
import java.awt.Desktop
import java.util.Locale
@ -199,7 +199,7 @@ fun main(args: Array<String>) = application(exitProcessOnExit = false) {
state = windowState,
) {
val backStack =
rememberNavBackStack(navSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
rememberNavBackStack(MeshtasticNavSavedStateConfig, TopLevelDestination.Connections.route as NavKey)
MenuBar {
Menu("File") {

View file

@ -1,83 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.navigation
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.desktop.ui.messaging.DesktopAdaptiveContactsScreen
import org.meshtastic.desktop.ui.messaging.DesktopMessageContent
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.QuickChatScreen
import org.meshtastic.feature.messaging.QuickChatViewModel
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
/**
* Registers real messaging/contacts feature composables into the desktop navigation graph.
*
* The contacts screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding,
* backed by shared `ContactsViewModel` from commonMain. The list pane shows contacts and the detail pane shows
* `DesktopMessageContent` using shared `MessageViewModel` with a non-paged message list.
*/
fun EntryProviderScope<NavKey>.desktopMessagingGraph(backStack: NavBackStack<NavKey>) {
entry<ContactsRoutes.ContactsGraph> {
val viewModel: ContactsViewModel = koinViewModel()
DesktopAdaptiveContactsScreen(
viewModel = viewModel,
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
)
}
entry<ContactsRoutes.Contacts> {
val viewModel: ContactsViewModel = koinViewModel()
DesktopAdaptiveContactsScreen(
viewModel = viewModel,
onNavigateToShareChannels = { backStack.add(ChannelsRoutes.ChannelsGraph) },
)
}
entry<ContactsRoutes.Messages> { route ->
val viewModel: MessageViewModel = koinViewModel(key = "messages-${route.contactKey}")
DesktopMessageContent(
contactKey = route.contactKey,
viewModel = viewModel,
initialMessage = route.message,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
entry<ContactsRoutes.Share> { route ->
val viewModel: ContactsViewModel = koinViewModel()
ShareScreen(
viewModel = viewModel,
onConfirm = { contactKey ->
backStack.removeLastOrNull()
backStack.add(ContactsRoutes.Messages(contactKey, route.message))
},
onNavigateUp = { backStack.removeLastOrNull() },
)
}
entry<ContactsRoutes.QuickChat> {
val viewModel: QuickChatViewModel = koinViewModel()
QuickChatScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
}

View file

@ -26,57 +26,47 @@ import androidx.compose.ui.Modifier
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.desktop.ui.firmware.DesktopFirmwareScreen
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
import org.meshtastic.feature.connections.ui.ConnectionsScreen
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
import org.meshtastic.feature.node.navigation.nodesGraph
import org.meshtastic.feature.settings.navigation.settingsGraph
import org.meshtastic.feature.settings.radio.channel.channelsGraph
/**
* Registers entry providers for all top-level desktop destinations.
*
* Nodes uses real composables from `feature:node` via [desktopNodeGraph]. Conversations uses real composables from
* Nodes uses real composables from `feature:node` via [nodesGraph]. Conversations uses real composables from
* `feature:messaging` via [desktopMessagingGraph]. Settings uses real composables from `feature:settings` via
* [desktopSettingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until
* their shared composables are wired.
* [settingsGraph]. Connections uses the shared [ConnectionsScreen]. Other features use placeholder screens until their
* shared composables are wired.
*/
fun EntryProviderScope<NavKey>.desktopNavGraph(backStack: NavBackStack<NavKey>) {
// Nodes — real composables from feature:node
desktopNodeGraph(backStack)
nodesGraph(
backStack = backStack,
nodeMapScreen = { destNum, _ -> KmpMapPlaceholder(title = "Node Map ($destNum)") },
)
// Conversations — real composables from feature:messaging
desktopMessagingGraph(backStack)
contactsGraph(backStack)
// Map — placeholder for now, will be replaced with feature:map real implementation
entry<MapRoutes.Map> { KmpMapPlaceholder() }
mapGraph(backStack)
// Firmware — in-flow destination (for example from Settings), not a top-level rail tab
entry<FirmwareRoutes.FirmwareGraph> { DesktopFirmwareScreen() }
entry<FirmwareRoutes.FirmwareUpdate> { DesktopFirmwareScreen() }
firmwareGraph(backStack)
// Settings — real composables from feature:settings
desktopSettingsGraph(backStack)
settingsGraph(backStack)
// Channels
channelsGraph(backStack)
// Connections — shared screen
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
}
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
onClickNodeChip = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
}
connectionsGraph(backStack)
}
@Composable

View file

@ -1,129 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.desktop.ui.map.KmpMapPlaceholder
import org.meshtastic.desktop.ui.nodes.DesktopAdaptiveNodeListScreen
import org.meshtastic.feature.node.list.NodeListViewModel
import org.meshtastic.feature.node.metrics.DeviceMetricsScreen
import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen
import org.meshtastic.feature.node.metrics.HostMetricsLogScreen
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.NeighborInfoLogScreen
import org.meshtastic.feature.node.metrics.PaxMetricsScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
/**
* Registers real node feature composables into the desktop navigation graph.
*
* The node list screen uses a desktop-specific adaptive composable with Material 3 Adaptive list-detail scaffolding,
* backed by shared `NodeListViewModel` and commonMain components. The detail pane shows real shared node detail content
* from commonMain.
*
* Metrics screens (logs + chart-based detail metrics) use shared composables from commonMain with `MetricsViewModel`
* scoped to the destination node number.
*/
fun EntryProviderScope<NavKey>.desktopNodeGraph(backStack: NavBackStack<NavKey>) {
entry<NodesRoutes.NodesGraph> {
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
}
entry<NodesRoutes.Nodes> {
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(viewModel = viewModel, onNavigate = { backStack.add(it) })
}
// Node detail graph routes open the real shared list-detail screen focused on the requested node.
entry<NodesRoutes.NodeDetailGraph> { route ->
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(
viewModel = viewModel,
initialNodeId = route.destNum,
onNavigate = { backStack.add(it) },
)
}
entry<NodesRoutes.NodeDetail> { route ->
val viewModel: NodeListViewModel = koinViewModel()
DesktopAdaptiveNodeListScreen(
viewModel = viewModel,
initialNodeId = route.destNum,
onNavigate = { backStack.add(it) },
)
}
// Traceroute log — real shared screen from commonMain
desktopMetricsEntry<NodeDetailRoutes.TracerouteLog>(getDestNum = { it.destNum }) { viewModel ->
TracerouteLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Neighbor info log — real shared screen from commonMain
desktopMetricsEntry<NodeDetailRoutes.NeighborInfoLog>(getDestNum = { it.destNum }) { viewModel ->
NeighborInfoLogScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Host metrics log — real shared screen from commonMain
desktopMetricsEntry<NodeDetailRoutes.HostMetricsLog>(getDestNum = { it.destNum }) { viewModel ->
HostMetricsLogScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Chart-based metrics — real shared screens from commonMain
desktopMetricsEntry<NodeDetailRoutes.DeviceMetrics>(getDestNum = { it.destNum }) { viewModel ->
DeviceMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.EnvironmentMetrics>(getDestNum = { it.destNum }) { viewModel ->
EnvironmentMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.SignalMetrics>(getDestNum = { it.destNum }) { viewModel ->
SignalMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.PowerMetrics>(getDestNum = { it.destNum }) { viewModel ->
PowerMetricsScreen(viewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
desktopMetricsEntry<NodeDetailRoutes.PaxMetrics>(getDestNum = { it.destNum }) { viewModel ->
PaxMetricsScreen(metricsViewModel = viewModel, onNavigateUp = { backStack.removeLastOrNull() })
}
// Map-based screens — placeholders (map integration needed)
entry<NodeDetailRoutes.NodeMap> { route -> KmpMapPlaceholder(title = "Node Map (${route.destNum})") }
entry<NodeDetailRoutes.TracerouteMap> { KmpMapPlaceholder(title = "Traceroute Map") }
entry<NodeDetailRoutes.PositionLog> { route -> KmpMapPlaceholder(title = "Position Log (${route.destNum})") }
}
private inline fun <reified R : NavKey> EntryProviderScope<NavKey>.desktopMetricsEntry(
crossinline getDestNum: (R) -> Int,
crossinline content: @Composable (MetricsViewModel) -> Unit,
) {
entry<R> { route ->
val destNum = getDestNum(route)
val viewModel: MetricsViewModel = koinViewModel(key = "metrics-$destNum") { parametersOf(destNum) }
LaunchedEffect(destNum) { viewModel.setNodeId(destNum) }
content(viewModel)
}
}

View file

@ -1,228 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.desktop.ui.settings.DesktopDeviceConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopExternalNotificationConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopNetworkConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopPositionConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopSecurityConfigScreen
import org.meshtastic.desktop.ui.settings.DesktopSettingsScreen
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.filter.FilterSettingsScreen
import org.meshtastic.feature.settings.filter.FilterSettingsViewModel
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseScreen
import org.meshtastic.feature.settings.radio.CleanNodeDatabaseViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.channel.ChannelConfigScreen
import org.meshtastic.feature.settings.radio.component.AmbientLightingConfigScreen
import org.meshtastic.feature.settings.radio.component.AudioConfigScreen
import org.meshtastic.feature.settings.radio.component.BluetoothConfigScreen
import org.meshtastic.feature.settings.radio.component.CannedMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.DetectionSensorConfigScreen
import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.LoRaConfigScreen
import org.meshtastic.feature.settings.radio.component.MQTTConfigScreen
import org.meshtastic.feature.settings.radio.component.NeighborInfoConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
import org.meshtastic.feature.settings.radio.component.PowerConfigScreen
import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen
import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
import org.meshtastic.feature.settings.radio.component.TAKConfigScreen
import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen
import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigScreen
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@Composable
private fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<RadioConfigViewModel>()
LaunchedEffect(backStack) {
val destNum =
backStack.lastOrNull { it is SettingsRoutes.Settings }?.let { (it as SettingsRoutes.Settings).destNum }
?: backStack
.lastOrNull { it is SettingsRoutes.SettingsGraph }
?.let { (it as SettingsRoutes.SettingsGraph).destNum }
viewModel.initDestNum(destNum)
}
return viewModel
}
/**
* Registers real settings feature composables into the desktop navigation graph.
*
* Top-level settings screen is a desktop-specific composable since Android's [SettingsScreen] uses Android-only APIs.
* All sub-screens (device config, module config, radio config, channels, etc.) use the shared commonMain composables
* from `feature:settings`.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun EntryProviderScope<NavKey>.desktopSettingsGraph(backStack: NavBackStack<NavKey>) {
// Top-level settings — desktop-specific screen (Android version uses Activity, permissions, etc.)
entry<SettingsRoutes.SettingsGraph> {
DesktopSettingsScreen(
radioConfigViewModel = getRadioConfigViewModel(backStack),
settingsViewModel = koinViewModel<SettingsViewModel>(),
onNavigate = { route -> backStack.add(route) },
)
}
entry<SettingsRoutes.Settings> {
DesktopSettingsScreen(
radioConfigViewModel = getRadioConfigViewModel(backStack),
settingsViewModel = koinViewModel<SettingsViewModel>(),
onNavigate = { route -> backStack.add(route) },
)
}
// Device configuration — shared commonMain composable
entry<SettingsRoutes.DeviceConfiguration> {
DeviceConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack),
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
}
// Module configuration — shared commonMain composable
entry<SettingsRoutes.ModuleConfiguration> {
val settingsViewModel: SettingsViewModel = koinViewModel()
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
ModuleConfigurationScreen(
viewModel = getRadioConfigViewModel(backStack),
excludedModulesUnlocked = excludedModulesUnlocked,
onBack = { backStack.removeLastOrNull() },
onNavigate = { route -> backStack.add(route) },
)
}
// Administration — shared commonMain composable
entry<SettingsRoutes.Administration> {
AdministrationScreen(viewModel = getRadioConfigViewModel(backStack), onBack = { backStack.removeLastOrNull() })
}
// Clean node database — shared commonMain composable
entry<SettingsRoutes.CleanNodeDb> {
val viewModel: CleanNodeDatabaseViewModel = koinViewModel()
CleanNodeDatabaseScreen(viewModel = viewModel)
}
// Debug Panel — shared commonMain composable
entry<SettingsRoutes.DebugPanel> {
val viewModel: org.meshtastic.feature.settings.debugging.DebugViewModel = koinViewModel()
org.meshtastic.feature.settings.debugging.DebugScreen(
viewModel = viewModel,
onNavigateUp = { backStack.removeLastOrNull() },
)
}
// Config routes — all from commonMain composables
ConfigRoute.entries.forEach { routeInfo ->
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ConfigRoute.USER -> UserConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.CHANNELS -> ChannelConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.DEVICE -> DesktopDeviceConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.POSITION ->
DesktopPositionConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.POWER -> PowerConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.NETWORK -> DesktopNetworkConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.DISPLAY -> DisplayConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.LORA -> LoRaConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.BLUETOOTH -> BluetoothConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ConfigRoute.SECURITY ->
DesktopSecurityConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
}
}
}
// Module routes — all from commonMain composables
ModuleRoute.entries.forEach { routeInfo ->
desktopConfigComposable(routeInfo.route::class, backStack) { viewModel ->
LaunchedEffect(Unit) { viewModel.setResponseStateLoading(routeInfo) }
when (routeInfo) {
ModuleRoute.MQTT -> MQTTConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.SERIAL -> SerialConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.EXT_NOTIFICATION ->
DesktopExternalNotificationConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.STORE_FORWARD ->
StoreForwardConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.RANGE_TEST -> RangeTestConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TELEMETRY -> TelemetryConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.CANNED_MESSAGE ->
CannedMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.AUDIO -> AudioConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.REMOTE_HARDWARE ->
RemoteHardwareConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.NEIGHBOR_INFO ->
NeighborInfoConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.AMBIENT_LIGHTING ->
AmbientLightingConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.DETECTION_SENSOR ->
DetectionSensorConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.STATUS_MESSAGE ->
StatusMessageConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TRAFFIC_MANAGEMENT ->
TrafficManagementConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
ModuleRoute.TAK -> TAKConfigScreen(viewModel, onBack = { backStack.removeLastOrNull() })
}
}
}
// About — shared commonMain screen, per-platform library definitions loaded from JVM classpath
entry<SettingsRoutes.About> {
AboutScreen(
onNavigateUp = { backStack.removeLastOrNull() },
jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" },
)
}
// Filter settings — shared commonMain composable
entry<SettingsRoutes.FilterSettings> {
val viewModel: FilterSettingsViewModel = koinViewModel()
FilterSettingsScreen(viewModel = viewModel, onBack = { backStack.removeLastOrNull() })
}
}
/** Helper to register a config/module route entry with a [RadioConfigViewModel] scoped to that entry. */
fun <R : Route> EntryProviderScope<NavKey>.desktopConfigComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
content: @Composable (RadioConfigViewModel) -> Unit,
) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}

View file

@ -32,22 +32,11 @@ import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceType
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.navigation.FirmwareRoutes
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.navigation.NodeDetailRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.ui.navigation.icon
@ -56,90 +45,6 @@ import org.meshtastic.core.ui.share.SharedContactDialog
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.desktop.navigation.desktopNavGraph
/**
* Polymorphic serialization configuration for Navigation 3 saved-state support. Registers all route types used in the
* desktop navigation graph.
*/
internal val navSavedStateConfig = SavedStateConfiguration {
serializersModule = SerializersModule {
polymorphic(NavKey::class) {
// Nodes
subclass(NodesRoutes.NodesGraph::class, NodesRoutes.NodesGraph.serializer())
subclass(NodesRoutes.Nodes::class, NodesRoutes.Nodes.serializer())
subclass(NodesRoutes.NodeDetailGraph::class, NodesRoutes.NodeDetailGraph.serializer())
subclass(NodesRoutes.NodeDetail::class, NodesRoutes.NodeDetail.serializer())
// Node detail sub-screens
subclass(NodeDetailRoutes.DeviceMetrics::class, NodeDetailRoutes.DeviceMetrics.serializer())
subclass(NodeDetailRoutes.NodeMap::class, NodeDetailRoutes.NodeMap.serializer())
subclass(NodeDetailRoutes.PositionLog::class, NodeDetailRoutes.PositionLog.serializer())
subclass(NodeDetailRoutes.EnvironmentMetrics::class, NodeDetailRoutes.EnvironmentMetrics.serializer())
subclass(NodeDetailRoutes.SignalMetrics::class, NodeDetailRoutes.SignalMetrics.serializer())
subclass(NodeDetailRoutes.PowerMetrics::class, NodeDetailRoutes.PowerMetrics.serializer())
subclass(NodeDetailRoutes.TracerouteLog::class, NodeDetailRoutes.TracerouteLog.serializer())
subclass(NodeDetailRoutes.TracerouteMap::class, NodeDetailRoutes.TracerouteMap.serializer())
subclass(NodeDetailRoutes.HostMetricsLog::class, NodeDetailRoutes.HostMetricsLog.serializer())
subclass(NodeDetailRoutes.PaxMetrics::class, NodeDetailRoutes.PaxMetrics.serializer())
subclass(NodeDetailRoutes.NeighborInfoLog::class, NodeDetailRoutes.NeighborInfoLog.serializer())
// Conversations
subclass(ContactsRoutes.ContactsGraph::class, ContactsRoutes.ContactsGraph.serializer())
subclass(ContactsRoutes.Contacts::class, ContactsRoutes.Contacts.serializer())
subclass(ContactsRoutes.Messages::class, ContactsRoutes.Messages.serializer())
subclass(ContactsRoutes.Share::class, ContactsRoutes.Share.serializer())
subclass(ContactsRoutes.QuickChat::class, ContactsRoutes.QuickChat.serializer())
// Map
subclass(MapRoutes.Map::class, MapRoutes.Map.serializer())
// Firmware
subclass(FirmwareRoutes.FirmwareGraph::class, FirmwareRoutes.FirmwareGraph.serializer())
subclass(FirmwareRoutes.FirmwareUpdate::class, FirmwareRoutes.FirmwareUpdate.serializer())
// Settings
subclass(SettingsRoutes.SettingsGraph::class, SettingsRoutes.SettingsGraph.serializer())
subclass(SettingsRoutes.Settings::class, SettingsRoutes.Settings.serializer())
subclass(SettingsRoutes.DeviceConfiguration::class, SettingsRoutes.DeviceConfiguration.serializer())
subclass(SettingsRoutes.ModuleConfiguration::class, SettingsRoutes.ModuleConfiguration.serializer())
subclass(SettingsRoutes.Administration::class, SettingsRoutes.Administration.serializer())
// Settings - Config routes
subclass(SettingsRoutes.User::class, SettingsRoutes.User.serializer())
subclass(SettingsRoutes.ChannelConfig::class, SettingsRoutes.ChannelConfig.serializer())
subclass(SettingsRoutes.Device::class, SettingsRoutes.Device.serializer())
subclass(SettingsRoutes.Position::class, SettingsRoutes.Position.serializer())
subclass(SettingsRoutes.Power::class, SettingsRoutes.Power.serializer())
subclass(SettingsRoutes.Network::class, SettingsRoutes.Network.serializer())
subclass(SettingsRoutes.Display::class, SettingsRoutes.Display.serializer())
subclass(SettingsRoutes.LoRa::class, SettingsRoutes.LoRa.serializer())
subclass(SettingsRoutes.Bluetooth::class, SettingsRoutes.Bluetooth.serializer())
subclass(SettingsRoutes.Security::class, SettingsRoutes.Security.serializer())
// Settings - Module routes
subclass(SettingsRoutes.MQTT::class, SettingsRoutes.MQTT.serializer())
subclass(SettingsRoutes.Serial::class, SettingsRoutes.Serial.serializer())
subclass(SettingsRoutes.ExtNotification::class, SettingsRoutes.ExtNotification.serializer())
subclass(SettingsRoutes.StoreForward::class, SettingsRoutes.StoreForward.serializer())
subclass(SettingsRoutes.RangeTest::class, SettingsRoutes.RangeTest.serializer())
subclass(SettingsRoutes.Telemetry::class, SettingsRoutes.Telemetry.serializer())
subclass(SettingsRoutes.CannedMessage::class, SettingsRoutes.CannedMessage.serializer())
subclass(SettingsRoutes.Audio::class, SettingsRoutes.Audio.serializer())
subclass(SettingsRoutes.RemoteHardware::class, SettingsRoutes.RemoteHardware.serializer())
subclass(SettingsRoutes.NeighborInfo::class, SettingsRoutes.NeighborInfo.serializer())
subclass(SettingsRoutes.AmbientLighting::class, SettingsRoutes.AmbientLighting.serializer())
subclass(SettingsRoutes.DetectionSensor::class, SettingsRoutes.DetectionSensor.serializer())
subclass(SettingsRoutes.Paxcounter::class, SettingsRoutes.Paxcounter.serializer())
subclass(SettingsRoutes.StatusMessage::class, SettingsRoutes.StatusMessage.serializer())
subclass(SettingsRoutes.TrafficManagement::class, SettingsRoutes.TrafficManagement.serializer())
subclass(SettingsRoutes.TAK::class, SettingsRoutes.TAK.serializer())
// Settings - Advanced routes
subclass(SettingsRoutes.CleanNodeDb::class, SettingsRoutes.CleanNodeDb.serializer())
subclass(SettingsRoutes.DebugPanel::class, SettingsRoutes.DebugPanel.serializer())
subclass(SettingsRoutes.About::class, SettingsRoutes.About.serializer())
subclass(SettingsRoutes.FilterSettings::class, SettingsRoutes.FilterSettings.serializer())
// Channels
subclass(ChannelsRoutes.ChannelsGraph::class, ChannelsRoutes.ChannelsGraph.serializer())
subclass(ChannelsRoutes.Channels::class, ChannelsRoutes.Channels.serializer())
// Connections
subclass(ConnectionsRoutes.ConnectionsGraph::class, ConnectionsRoutes.ConnectionsGraph.serializer())
subclass(ConnectionsRoutes.Connections::class, ConnectionsRoutes.Connections.serializer())
}
}
}
/**
* Desktop main screen Navigation 3 shell with a persistent [NavigationRail] and [NavDisplay].
*

View file

@ -1,161 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.firmware
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.actions
import org.meshtastic.core.resources.check_for_updates
import org.meshtastic.core.resources.connected_device
import org.meshtastic.core.resources.download_firmware
import org.meshtastic.core.resources.firmware_charge_warning
import org.meshtastic.core.resources.firmware_update_title
import org.meshtastic.core.resources.no_device_connected
import org.meshtastic.core.resources.note
import org.meshtastic.core.resources.ready_for_firmware_update
import org.meshtastic.core.resources.update_device
import org.meshtastic.core.resources.update_status
/**
* Desktop Firmware Update Screen Shows firmware update status and controls.
*
* Simplified desktop UI for firmware updates. Demonstrates the firmware feature in a desktop context without full
* native DFU integration.
*/
@Suppress("LongMethod") // Placeholder screen — will be replaced with shared KMP implementation
@Composable
fun DesktopFirmwareScreen() {
Column(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp)) {
// Header
Text(
stringResource(Res.string.firmware_update_title),
style = MaterialTheme.typography.headlineLarge,
modifier = Modifier.padding(bottom = 16.dp),
)
// Device info
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(Res.string.connected_device),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(Res.string.no_device_connected),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
}
// Update status
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(stringResource(Res.string.update_status), style = MaterialTheme.typography.labelMedium)
Text(
stringResource(Res.string.ready_for_firmware_update),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
// Progress indicator (placeholder)
LinearProgressIndicator(progress = { 0f }, modifier = Modifier.fillMaxWidth().padding(top = 12.dp))
Text("0%", style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(top = 4.dp))
}
}
// Controls
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(Res.string.actions),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
Button(onClick = { /* Check for updates */ }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(Res.string.check_for_updates))
}
Button(
onClick = { /* Download firmware */ },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
enabled = false,
) {
Text(stringResource(Res.string.download_firmware))
}
Button(
onClick = { /* Start update */ },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
enabled = false,
) {
Text(stringResource(Res.string.update_device))
}
}
}
// Info
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
stringResource(Res.string.note),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
stringResource(Res.string.firmware_charge_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
}

View file

@ -1,165 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.messaging
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.conversations
import org.meshtastic.core.resources.mark_as_read
import org.meshtastic.core.resources.unread_count
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.icon.MarkChatRead
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.component.EmptyConversationsPlaceholder
import org.meshtastic.feature.messaging.ui.contact.ContactItem
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
/**
* Desktop adaptive contacts screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive.
*
* On wide screens, the contacts list is shown on the left and the selected conversation detail on the right. On narrow
* screens, the scaffold automatically switches to a single-pane layout.
*
* Uses the shared [ContactsViewModel] and [ContactItem] from commonMain. The detail pane shows [DesktopMessageContent]
* with a non-paged message list and send input, backed by the shared [MessageViewModel].
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod")
@Composable
fun DesktopAdaptiveContactsScreen(
viewModel: ContactsViewModel,
onNavigateToShareChannels: () -> Unit = {},
uiViewModel: UIViewModel = koinViewModel(),
) {
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val unreadTotal by viewModel.unreadCountTotal.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.conversations),
subtitle =
if (unreadTotal > 0) {
stringResource(Res.string.unread_count, unreadTotal)
} else {
null
},
ourNode = ourNode,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {
if (unreadTotal > 0) {
IconButton(onClick = { viewModel.markAllAsRead() }) {
Icon(
MeshtasticIcons.MarkChatRead,
contentDescription = stringResource(Res.string.mark_as_read),
)
}
}
},
onClickChip = {},
)
},
floatingActionButton = {
if (connectionState == ConnectionState.Connected) {
MeshtasticImportFAB(
onImport = { uriString ->
uiViewModel.handleScannedUri(
org.meshtastic.core.common.util.MeshtasticUri(uriString),
) {
// OnInvalid
}
},
onShareChannels = onNavigateToShareChannels,
sharedContact = sharedContactRequested,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { contentPadding ->
if (contacts.isEmpty()) {
EmptyConversationsPlaceholder(modifier = Modifier.padding(contentPadding))
} else {
LazyColumn(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
items(contacts, key = { it.contactKey }) { contact ->
val isActive = navigator.currentDestination?.contentKey == contact.contactKey
ContactItem(
contact = contact,
selected = false,
isActive = isActive,
onClick = {
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, contact.contactKey)
}
},
)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey?.let { contactKey ->
val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey")
DesktopMessageContent(contactKey = contactKey, viewModel = messageViewModel)
} ?: EmptyConversationsPlaceholder(modifier = Modifier)
}
},
)
}

View file

@ -1,507 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.messaging
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.no_messages_yet
import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
import org.meshtastic.feature.messaging.component.MessageInput
import org.meshtastic.feature.messaging.component.MessageItem
import org.meshtastic.feature.messaging.component.MessageMenuAction
import org.meshtastic.feature.messaging.component.MessageStatusDialog
import org.meshtastic.feature.messaging.component.MessageTopBar
import org.meshtastic.feature.messaging.component.QuickChatRow
import org.meshtastic.feature.messaging.component.ReplySnippet
import org.meshtastic.feature.messaging.component.ScrollToBottomFab
import org.meshtastic.feature.messaging.component.UnreadMessagesDivider
import org.meshtastic.feature.messaging.component.handleQuickChatAction
/**
* Desktop message content view for the contacts detail pane.
*
* Uses a non-paged [LazyColumn] to display messages for a selected conversation. Now shares the full message screen
* component set with Android, including: proper reply-to-message with replyId, message selection mode, quick chat row,
* message filtering, delivery info dialog, overflow menu, byte counter input, and unread dividers.
*
* The only difference from Android is the non-paged data source (Flow<List<Message>> vs LazyPagingItems) and the
* absence of PredictiveBackHandler.
*/
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DesktopMessageContent(
contactKey: String,
viewModel: MessageViewModel,
modifier: Modifier = Modifier,
initialMessage: String = "",
onNavigateUp: (() -> Unit)? = null,
) {
val coroutineScope = rememberCoroutineScope()
val clipboardManager = LocalClipboard.current
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val channels by viewModel.channels.collectAsStateWithLifecycle()
val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList())
val contactSettings by viewModel.contactSettings.collectAsStateWithLifecycle(initialValue = emptyMap())
val homoglyphEncodingEnabled by viewModel.homoglyphEncodingEnabled.collectAsStateWithLifecycle(initialValue = false)
val messages by viewModel.getMessagesFlow(contactKey).collectAsStateWithLifecycle(initialValue = emptyList())
// UI State
var replyingToPacketId by rememberSaveable { mutableStateOf<Int?>(null) }
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
val selectedMessageIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
var messageText by rememberSaveable(contactKey) { mutableStateOf(initialMessage) }
val showQuickChat by viewModel.showQuickChat.collectAsStateWithLifecycle()
val filteredCount by viewModel.filteredCount.collectAsStateWithLifecycle()
val showFiltered by viewModel.showFiltered.collectAsStateWithLifecycle()
val filteringDisabled = contactSettings[contactKey]?.filteringDisabled ?: false
var showStatusDialog by remember { mutableStateOf<org.meshtastic.core.model.Message?>(null) }
val inSelectionMode by remember { derivedStateOf { selectedMessageIds.value.isNotEmpty() } }
val listState = rememberLazyListState()
val unreadCount by viewModel.unreadCount.collectAsStateWithLifecycle()
// Derive title
val channelInfo =
remember(contactKey, channels) {
val index = contactKey.firstOrNull()?.digitToIntOrNull()
val id = contactKey.substring(1)
val name = index?.let { channels.getChannel(it)?.name }
Triple(index, id, name)
}
val (channelIndex, nodeId, rawChannelName) = channelInfo
val unknownChannelText = stringResource(Res.string.unknown_channel)
val channelName = rawChannelName ?: unknownChannelText
val title =
remember(nodeId, channelName, viewModel) {
when (nodeId) {
DataPacket.ID_BROADCAST -> channelName
else -> viewModel.getUser(nodeId).long_name
}
}
val isMismatchKey =
remember(channelIndex, nodeId, viewModel) {
channelIndex == DataPacket.PKC_CHANNEL_INDEX && viewModel.getNode(nodeId).mismatchKey
}
// Find the original message for reply snippet
val originalMessage by
remember(replyingToPacketId, messages.size) {
derivedStateOf { replyingToPacketId?.let { id -> messages.firstOrNull { it.packetId == id } } }
}
// Scroll to bottom when new messages arrive and we're already at the bottom
LaunchedEffect(messages.size) {
if (messages.isNotEmpty() && !listState.canScrollBackward) {
listState.animateScrollToItem(0)
}
}
// Seed route-provided draft text
LaunchedEffect(contactKey, initialMessage) {
if (initialMessage.isNotBlank() && messageText.isBlank()) {
messageText = initialMessage
}
}
// Mark messages as read when they become visible
@OptIn(kotlinx.coroutines.FlowPreview::class)
LaunchedEffect(messages.size) {
snapshotFlow { if (listState.isScrollInProgress) null else listState.layoutInfo }
.debounce(SCROLL_SETTLE_MILLIS)
.collectLatest { layoutInfo ->
if (layoutInfo == null || messages.isEmpty()) return@collectLatest
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return@collectLatest
val topVisibleIndex = visibleItems.first().index
val bottomVisibleIndex = visibleItems.last().index
val firstVisibleUnread =
(bottomVisibleIndex..topVisibleIndex)
.mapNotNull { if (it in messages.indices) messages[it] else null }
.firstOrNull { !it.fromLocal && !it.read }
firstVisibleUnread?.let { message ->
viewModel.clearUnreadCount(contactKey, message.uuid, message.receivedTime)
}
}
}
// Dialogs
if (showDeleteDialog) {
DeleteMessageDialog(
count = selectedMessageIds.value.size,
onConfirm = {
viewModel.deleteMessages(selectedMessageIds.value.toList())
selectedMessageIds.value = emptySet()
showDeleteDialog = false
},
onDismiss = { showDeleteDialog = false },
)
}
showStatusDialog?.let { message ->
MessageStatusDialog(
message = message,
nodes = nodes,
ourNode = ourNode,
resendOption = message.status?.equals(MessageStatus.ERROR) ?: false,
onResend = {
viewModel.deleteMessages(listOf(message.uuid))
viewModel.sendMessage(message.text, contactKey)
showStatusDialog = null
},
onDismiss = { showStatusDialog = null },
)
}
Scaffold(
modifier = modifier,
topBar = {
if (inSelectionMode) {
ActionModeTopBar(
selectedCount = selectedMessageIds.value.size,
onAction = { action ->
when (action) {
MessageMenuAction.ClipboardCopy -> {
val copiedText =
messages
.filter { it.uuid in selectedMessageIds.value }
.joinToString("\n") { it.text }
coroutineScope.launch {
clipboardManager.setClipEntry(createClipEntry(copiedText, "messages"))
}
selectedMessageIds.value = emptySet()
}
MessageMenuAction.Delete -> showDeleteDialog = true
MessageMenuAction.Dismiss -> selectedMessageIds.value = emptySet()
MessageMenuAction.SelectAll -> {
selectedMessageIds.value =
if (selectedMessageIds.value.size == messages.size) {
emptySet()
} else {
messages.map { it.uuid }.toSet()
}
}
}
},
)
} else {
MessageTopBar(
title = title,
channelIndex = channelIndex,
mismatchKey = isMismatchKey,
onNavigateBack = { onNavigateUp?.invoke() },
channels = channels,
channelIndexParam = channelIndex,
showQuickChat = showQuickChat,
onToggleQuickChat = viewModel::toggleShowQuickChat,
filteringDisabled = filteringDisabled,
onToggleFilteringDisabled = {
viewModel.setContactFilteringDisabled(contactKey, !filteringDisabled)
},
filteredCount = filteredCount,
showFiltered = showFiltered,
onToggleShowFiltered = viewModel::toggleShowFiltered,
)
}
},
bottomBar = {
Column {
AnimatedVisibility(visible = showQuickChat) {
QuickChatRow(
enabled = connectionState.isConnected(),
actions = quickChatActions,
onClick = { action ->
handleQuickChatAction(
action = action,
currentText = messageText,
onUpdateText = { messageText = it },
onSendMessage = { text -> viewModel.sendMessage(text, contactKey) },
)
},
)
}
ReplySnippet(
originalMessage = originalMessage,
onClearReply = { replyingToPacketId = null },
ourNode = ourNode,
)
MessageInput(
messageText = messageText,
onMessageChange = { messageText = it },
onSendMessage = {
val trimmed = messageText.trim()
if (trimmed.isNotEmpty()) {
viewModel.sendMessage(trimmed, contactKey, replyingToPacketId)
if (replyingToPacketId != null) replyingToPacketId = null
messageText = ""
}
},
isEnabled = connectionState.isConnected(),
isHomoglyphEncodingEnabled = homoglyphEncodingEnabled,
modifier =
Modifier.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && !event.isShiftPressed) {
val currentByteLength = messageText.encodeToByteArray().size
val isOverLimit = currentByteLength > MESSAGE_CHARACTER_LIMIT_BYTES
val trimmed = messageText.trim()
if (trimmed.isNotEmpty() && connectionState.isConnected() && !isOverLimit) {
viewModel.sendMessage(trimmed, contactKey, replyingToPacketId)
if (replyingToPacketId != null) replyingToPacketId = null
messageText = ""
return@onPreviewKeyEvent true
}
// If over limit or empty, we still consume Enter to prevent newlines if the user
// intended to send, but only if they are not holding shift.
if (!event.isShiftPressed) return@onPreviewKeyEvent true
}
false
},
)
}
},
) { contentPadding ->
Box(Modifier.fillMaxSize().padding(contentPadding).focusable()) {
if (messages.isEmpty()) {
EmptyDetailPlaceholder(
icon = MeshtasticIcons.Conversations,
title = stringResource(Res.string.no_messages_yet),
)
} else {
// Pre-calculate node map for O(1) lookup
val nodeMap = remember(nodes) { nodes.associateBy { it.num } }
// Find first unread index
val firstUnreadIndex by
remember(messages.size) {
derivedStateOf { messages.indexOfFirst { !it.fromLocal && !it.read }.takeIf { it != -1 } }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(bottom = 24.dp, top = 24.dp),
) {
items(messages.size, key = { messages[it].uuid }) { index ->
val message = messages[index]
val isSender = message.fromLocal
// Because reverseLayout = true, visually previous (above) is index + 1
val visuallyPrevMessage = if (index < messages.size - 1) messages[index + 1] else null
val visuallyNextMessage = if (index > 0) messages[index - 1] else null
val hasSamePrev =
if (visuallyPrevMessage != null) {
visuallyPrevMessage.fromLocal == message.fromLocal &&
(message.fromLocal || visuallyPrevMessage.node.num == message.node.num)
} else {
false
}
val hasSameNext =
if (visuallyNextMessage != null) {
visuallyNextMessage.fromLocal == message.fromLocal &&
(message.fromLocal || visuallyNextMessage.node.num == message.node.num)
} else {
false
}
val isFirstUnread = firstUnreadIndex == index
val selected by
remember(message.uuid, selectedMessageIds.value) {
derivedStateOf { selectedMessageIds.value.contains(message.uuid) }
}
val node = nodeMap[message.node.num] ?: message.node
if (isFirstUnread) {
Column {
UnreadMessagesDivider()
DesktopMessageItemRow(
message = message,
node = node,
ourNode = ourNode ?: Node(num = 0),
selected = selected,
inSelectionMode = inSelectionMode,
selectedMessageIds = selectedMessageIds,
contactKey = contactKey,
viewModel = viewModel,
listState = listState,
messages = messages,
onShowStatusDialog = { showStatusDialog = it },
onReply = { replyingToPacketId = it?.packetId },
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
showUserName = !isSender && !hasSamePrev,
quickEmojis = viewModel.frequentEmojis,
)
}
} else {
DesktopMessageItemRow(
message = message,
node = node,
ourNode = ourNode ?: Node(num = 0),
selected = selected,
inSelectionMode = inSelectionMode,
selectedMessageIds = selectedMessageIds,
contactKey = contactKey,
viewModel = viewModel,
listState = listState,
messages = messages,
onShowStatusDialog = { showStatusDialog = it },
onReply = { replyingToPacketId = it?.packetId },
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
showUserName = !isSender && !hasSamePrev,
quickEmojis = viewModel.frequentEmojis,
)
}
}
}
}
// Show FAB if we can scroll towards the newest messages (index 0).
if (listState.canScrollBackward) {
ScrollToBottomFab(coroutineScope = coroutineScope, listState = listState, unreadCount = unreadCount)
}
}
}
}
@Suppress("LongParameterList")
@Composable
private fun DesktopMessageItemRow(
message: org.meshtastic.core.model.Message,
node: Node,
ourNode: Node,
selected: Boolean,
inSelectionMode: Boolean,
selectedMessageIds: androidx.compose.runtime.MutableState<Set<Long>>,
contactKey: String,
viewModel: MessageViewModel,
listState: androidx.compose.foundation.lazy.LazyListState,
messages: List<org.meshtastic.core.model.Message>,
onShowStatusDialog: (org.meshtastic.core.model.Message) -> Unit,
onReply: (org.meshtastic.core.model.Message?) -> Unit,
hasSamePrev: Boolean,
hasSameNext: Boolean,
showUserName: Boolean,
quickEmojis: List<String>,
) {
val coroutineScope = rememberCoroutineScope()
MessageItem(
message = message,
node = node,
ourNode = ourNode,
selected = selected,
inSelectionMode = inSelectionMode,
onClick = { if (inSelectionMode) selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) },
onLongClick = {
if (inSelectionMode) {
selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid)
}
},
onSelect = { selectedMessageIds.value = selectedMessageIds.value.toggle(message.uuid) },
onDelete = { viewModel.deleteMessages(listOf(message.uuid)) },
onReply = { onReply(message) },
sendReaction = { emoji ->
val hasReacted =
message.emojis.any { reaction ->
(reaction.user.id == ourNode.user.id || reaction.user.id == DataPacket.ID_LOCAL) &&
reaction.emoji == emoji
}
if (!hasReacted) {
viewModel.sendReaction(emoji, message.packetId, contactKey)
}
},
onStatusClick = { onShowStatusDialog(message) },
onNavigateToOriginalMessage = { replyId ->
coroutineScope.launch {
val targetIndex = messages.indexOfFirst { it.packetId == replyId }.takeIf { it != -1 }
if (targetIndex != null) {
listState.animateScrollToItem(targetIndex)
}
}
},
emojis = message.emojis,
showUserName = showUserName,
hasSamePrev = hasSamePrev,
hasSameNext = hasSameNext,
quickEmojis = quickEmojis,
)
}
private fun Set<Long>.toggle(uuid: Long): Set<Long> = if (contains(uuid)) this - uuid else this + uuid
/** Debounce delay before marking messages as read after scroll settles. */
private const val SCROLL_SETTLE_MILLIS = 300L

View file

@ -1,290 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.nodes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.node_count_template
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Nodes
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.feature.node.detail.NodeDetailContent
import org.meshtastic.feature.node.detail.NodeDetailViewModel
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.list.NodeListViewModel
import org.meshtastic.feature.node.model.NodeDetailAction
/**
* Desktop adaptive node list screen using [ListDetailPaneScaffold] from JetBrains Material 3 Adaptive.
*
* On wide screens, the node list is shown on the left and the selected node detail on the right. On narrow screens, the
* scaffold automatically switches to a single-pane layout.
*
* Uses the shared [NodeListViewModel] and commonMain composables ([NodeItem], [NodeFilterTextField], [MainAppBar]). The
* detail pane renders the shared [NodeDetailContent] from commonMain with the full node detail sections (identity,
* device actions, position, hardware details, notes, administration). Android-only overlays (compass permissions,
* bottom sheets) are no-ops on desktop.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun DesktopAdaptiveNodeListScreen(
viewModel: NodeListViewModel,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
uiViewModel: UIViewModel = koinViewModel(),
) {
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
val nodes by viewModel.nodeList.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val unfilteredNodes by viewModel.unfilteredNodeList.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val navigator = rememberListDetailPaneScaffoldNavigator<Int>()
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
var shareNode by remember { mutableStateOf<org.meshtastic.core.model.Node?>(null) }
if (shareNode != null) {
SharedContactDialog(contact = shareNode, onDismiss = { shareNode = null })
}
LaunchedEffect(initialNodeId) {
initialNodeId?.let { nodeId -> navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, nodeId) }
}
ListDetailPaneScaffold(
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.nodes),
subtitle =
stringResource(
Res.string.node_count_template,
onlineNodeCount,
nodes.size,
totalNodeCount,
),
ourNode = ourNode,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = {},
)
},
floatingActionButton = {
if (connectionState == ConnectionState.Connected) {
MeshtasticImportFAB(
onImport = { uriString ->
uiViewModel.handleScannedUri(
org.meshtastic.core.common.util.MeshtasticUri(uriString),
) {
// OnInvalid
}
},
sharedContact = sharedContactRequested,
onDismissSharedContact = { uiViewModel.clearSharedContactRequested() },
isContactContext = true,
)
}
},
) { contentPadding ->
Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
item {
NodeFilterTextField(
modifier =
Modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceDim)
.padding(8.dp),
filterText = state.filter.filterText,
onTextChange = { viewModel.nodeFilterText = it },
currentSortOption = state.sort,
onSortSelect = viewModel::setSortOption,
includeUnknown = state.filter.includeUnknown,
onToggleIncludeUnknown = { viewModel.nodeFilterPreferences.toggleIncludeUnknown() },
excludeInfrastructure = state.filter.excludeInfrastructure,
onToggleExcludeInfrastructure = {
viewModel.nodeFilterPreferences.toggleExcludeInfrastructure()
},
onlyOnline = state.filter.onlyOnline,
onToggleOnlyOnline = { viewModel.nodeFilterPreferences.toggleOnlyOnline() },
onlyDirect = state.filter.onlyDirect,
onToggleOnlyDirect = { viewModel.nodeFilterPreferences.toggleOnlyDirect() },
showIgnored = state.filter.showIgnored,
onToggleShowIgnored = { viewModel.nodeFilterPreferences.toggleShowIgnored() },
ignoredNodeCount = ignoredNodeCount,
excludeMqtt = state.filter.excludeMqtt,
onToggleExcludeMqtt = { viewModel.nodeFilterPreferences.toggleExcludeMqtt() },
)
}
items(nodes, key = { it.num }) { node ->
var expanded by remember { mutableStateOf(false) }
val isActive = navigator.currentDestination?.contentKey == node.num
Box(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
val longClick =
if (node.num != ourNode?.num) {
{ expanded = true }
} else {
null
}
NodeItem(
thisNode = ourNode,
thatNode = node,
distanceUnits = state.distanceUnits,
tempInFahrenheit = state.tempInFahrenheit,
onClick = {
scope.launch {
navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, node.num)
}
},
onLongClick = longClick,
connectionState = connectionState,
isActive = isActive,
)
val isThisNode = remember(node) { ourNode?.num == node.num }
if (!isThisNode) {
NodeContextMenu(
expanded = expanded,
node = node,
onFavorite = { viewModel.favoriteNode(node) },
onIgnore = { viewModel.ignoreNode(node) },
onMute = { viewModel.muteNode(node) },
onRemove = { viewModel.removeNode(node) },
onDismiss = { expanded = false },
)
}
}
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
}
},
detailPane = {
AnimatedPane {
navigator.currentDestination?.contentKey?.let { nodeNum ->
val detailViewModel: NodeDetailViewModel = koinViewModel(key = "node-detail-$nodeNum")
LaunchedEffect(nodeNum) { detailViewModel.start(nodeNum) }
val detailUiState by detailViewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
detailViewModel.effects.collect { effect ->
if (effect is NodeRequestEffect.ShowFeedback) {
snackbarHostState.showSnackbar(effect.text.resolve())
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues ->
NodeDetailContent(
modifier = Modifier.padding(paddingValues),
uiState = detailUiState,
onAction = { action ->
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction ->
detailViewModel.onServiceAction(action.action)
is NodeDetailAction.ShareContact -> shareNode = detailUiState.node
is NodeDetailAction.HandleNodeMenuAction -> {
val menuAction = action.action
if (
menuAction
is org.meshtastic.feature.node.component.NodeMenuAction.DirectMessage
) {
val routeStr =
detailViewModel.getDirectMessageRoute(
menuAction.node,
detailUiState.ourNode,
)
onNavigate(
org.meshtastic.core.navigation.ContactsRoutes.Messages(
contactKey = routeStr,
),
)
} else {
detailViewModel.handleNodeMenuAction(menuAction)
}
}
else -> {} // Actions requiring Android APIs are no-ops on desktop
}
},
onFirmwareSelect = { /* Firmware update not available on desktop */ },
onSaveNotes = { num, notes -> detailViewModel.setNodeNotes(num, notes) },
)
}
} ?: EmptyDetailPlaceholder(icon = MeshtasticIcons.Nodes, title = stringResource(Res.string.nodes))
}
},
)
}

View file

@ -1,461 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.settings
import androidx.compose.foundation.clickable
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.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.PhoneAndroid
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.graphics.RectangleShape
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.accept
import org.meshtastic.core.resources.are_you_sure
import org.meshtastic.core.resources.button_gpio
import org.meshtastic.core.resources.buzzer_gpio
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.config_device_doubleTapAsButtonPress_summary
import org.meshtastic.core.resources.config_device_ledHeartbeatEnabled_summary
import org.meshtastic.core.resources.config_device_tripleClickAsAdHocPing_summary
import org.meshtastic.core.resources.config_device_tzdef_summary
import org.meshtastic.core.resources.config_device_use_phone_tz
import org.meshtastic.core.resources.device
import org.meshtastic.core.resources.double_tap_as_button_press
import org.meshtastic.core.resources.gpio
import org.meshtastic.core.resources.hardware
import org.meshtastic.core.resources.i_know_what_i_m_doing
import org.meshtastic.core.resources.led_heartbeat
import org.meshtastic.core.resources.nodeinfo_broadcast_interval
import org.meshtastic.core.resources.options
import org.meshtastic.core.resources.rebroadcast_mode
import org.meshtastic.core.resources.rebroadcast_mode_all_desc
import org.meshtastic.core.resources.rebroadcast_mode_all_skip_decoding_desc
import org.meshtastic.core.resources.rebroadcast_mode_core_portnums_only_desc
import org.meshtastic.core.resources.rebroadcast_mode_known_only_desc
import org.meshtastic.core.resources.rebroadcast_mode_local_only_desc
import org.meshtastic.core.resources.rebroadcast_mode_none_desc
import org.meshtastic.core.resources.role
import org.meshtastic.core.resources.role_client_base_desc
import org.meshtastic.core.resources.role_client_desc
import org.meshtastic.core.resources.role_client_hidden_desc
import org.meshtastic.core.resources.role_client_mute_desc
import org.meshtastic.core.resources.role_lost_and_found_desc
import org.meshtastic.core.resources.role_repeater_desc
import org.meshtastic.core.resources.role_router_client_desc
import org.meshtastic.core.resources.role_router_desc
import org.meshtastic.core.resources.role_router_late_desc
import org.meshtastic.core.resources.role_sensor_desc
import org.meshtastic.core.resources.role_tak_desc
import org.meshtastic.core.resources.role_tak_tracker_desc
import org.meshtastic.core.resources.role_tracker_desc
import org.meshtastic.core.resources.router_role_confirmation_text
import org.meshtastic.core.resources.time_zone
import org.meshtastic.core.resources.triple_click_adhoc_ping
import org.meshtastic.core.resources.unrecognized
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.InsetDivider
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.role
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.zone.ZoneOffsetTransitionRule
import java.util.Locale
import kotlin.math.abs
private val Config.DeviceConfig.Role.description: StringResource
get() =
when (this) {
Config.DeviceConfig.Role.CLIENT -> Res.string.role_client_desc
Config.DeviceConfig.Role.CLIENT_BASE -> Res.string.role_client_base_desc
Config.DeviceConfig.Role.CLIENT_MUTE -> Res.string.role_client_mute_desc
Config.DeviceConfig.Role.ROUTER -> Res.string.role_router_desc
Config.DeviceConfig.Role.ROUTER_CLIENT -> Res.string.role_router_client_desc
Config.DeviceConfig.Role.REPEATER -> Res.string.role_repeater_desc
Config.DeviceConfig.Role.TRACKER -> Res.string.role_tracker_desc
Config.DeviceConfig.Role.SENSOR -> Res.string.role_sensor_desc
Config.DeviceConfig.Role.TAK -> Res.string.role_tak_desc
Config.DeviceConfig.Role.CLIENT_HIDDEN -> Res.string.role_client_hidden_desc
Config.DeviceConfig.Role.LOST_AND_FOUND -> Res.string.role_lost_and_found_desc
Config.DeviceConfig.Role.TAK_TRACKER -> Res.string.role_tak_tracker_desc
Config.DeviceConfig.Role.ROUTER_LATE -> Res.string.role_router_late_desc
else -> Res.string.unrecognized
}
private val Config.DeviceConfig.RebroadcastMode.description: StringResource
get() =
when (this) {
Config.DeviceConfig.RebroadcastMode.ALL -> Res.string.rebroadcast_mode_all_desc
Config.DeviceConfig.RebroadcastMode.ALL_SKIP_DECODING -> Res.string.rebroadcast_mode_all_skip_decoding_desc
Config.DeviceConfig.RebroadcastMode.LOCAL_ONLY -> Res.string.rebroadcast_mode_local_only_desc
Config.DeviceConfig.RebroadcastMode.KNOWN_ONLY -> Res.string.rebroadcast_mode_known_only_desc
Config.DeviceConfig.RebroadcastMode.NONE -> Res.string.rebroadcast_mode_none_desc
Config.DeviceConfig.RebroadcastMode.CORE_PORTNUMS_ONLY ->
Res.string.rebroadcast_mode_core_portnums_only_desc
else -> Res.string.unrecognized
}
@Composable
@Suppress("LongMethod")
fun DesktopDeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val deviceConfig = state.radioConfig.device ?: Config.DeviceConfig()
val formState = rememberConfigState(initialValue = deviceConfig)
var selectedRole by rememberSaveable { mutableStateOf(formState.value.role ?: Config.DeviceConfig.Role.CLIENT) }
val infrastructureRoles =
listOf(Config.DeviceConfig.Role.ROUTER, Config.DeviceConfig.Role.ROUTER_LATE, Config.DeviceConfig.Role.REPEATER)
if (selectedRole != formState.value.role) {
if (selectedRole in infrastructureRoles) {
DesktopRouterRoleConfirmationDialog(
onDismiss = { selectedRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT },
onConfirm = { formState.value = formState.value.copy(role = selectedRole) },
)
} else {
formState.value = formState.value.copy(role = selectedRole)
}
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.device),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = Config(device = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.options)) {
val currentRole = formState.value.role ?: Config.DeviceConfig.Role.CLIENT
DropDownPreference(
title = stringResource(Res.string.role),
enabled = state.connected,
selectedItem = currentRole,
onItemSelected = { selectedRole = it },
summary = stringResource(currentRole.description),
itemIcon = { MeshtasticIcons.role(it) },
itemLabel = { it.name },
)
HorizontalDivider()
val currentRebroadcastMode = formState.value.rebroadcast_mode ?: Config.DeviceConfig.RebroadcastMode.ALL
DropDownPreference(
title = stringResource(Res.string.rebroadcast_mode),
enabled = state.connected,
selectedItem = currentRebroadcastMode,
onItemSelected = { formState.value = formState.value.copy(rebroadcast_mode = it) },
summary = stringResource(currentRebroadcastMode.description),
)
HorizontalDivider()
val nodeInfoBroadcastIntervals = remember { IntervalConfiguration.NODE_INFO_BROADCAST.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.nodeinfo_broadcast_interval),
selectedItem = (formState.value.node_info_broadcast_secs ?: 0).toLong(),
enabled = state.connected,
items = nodeInfoBroadcastIntervals.map { it.value to it.toDisplayString() },
onItemSelected = { formState.value = formState.value.copy(node_info_broadcast_secs = it.toInt()) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.hardware)) {
SwitchPreference(
title = stringResource(Res.string.double_tap_as_button_press),
summary = stringResource(Res.string.config_device_doubleTapAsButtonPress_summary),
checked = formState.value.double_tap_as_button_press,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(double_tap_as_button_press = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
InsetDivider()
SwitchPreference(
title = stringResource(Res.string.triple_click_adhoc_ping),
summary = stringResource(Res.string.config_device_tripleClickAsAdHocPing_summary),
checked = !formState.value.disable_triple_click,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(disable_triple_click = !it) },
containerColor = CardDefaults.cardColors().containerColor,
)
InsetDivider()
SwitchPreference(
title = stringResource(Res.string.led_heartbeat),
summary = stringResource(Res.string.config_device_ledHeartbeatEnabled_summary),
checked = !formState.value.led_heartbeat_disabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(led_heartbeat_disabled = !it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.time_zone)) {
val systemTzPosixString = remember { ZoneId.systemDefault().toPosixString() }
EditTextPreference(
title = "",
value = formState.value.tzdef ?: "",
summary = stringResource(Res.string.config_device_tzdef_summary),
maxSize = 64, // tzdef max_size:65
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(tzdef = it) },
trailingIcon = {
IconButton(onClick = { formState.value = formState.value.copy(tzdef = "") }) {
Icon(imageVector = Icons.Rounded.Clear, contentDescription = null)
}
},
)
HorizontalDivider()
TextButton(
modifier = Modifier.height(40.dp).fillMaxWidth(),
enabled = state.connected,
shape = RectangleShape,
onClick = { formState.value = formState.value.copy(tzdef = systemTzPosixString) },
) {
Icon(imageVector = Icons.Rounded.PhoneAndroid, contentDescription = null)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(Res.string.config_device_use_phone_tz))
}
}
}
item {
TitledCard(title = stringResource(Res.string.gpio)) {
EditTextPreference(
title = stringResource(Res.string.button_gpio),
value = formState.value.button_gpio ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(button_gpio = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.buzzer_gpio),
value = formState.value.buzzer_gpio ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(buzzer_gpio = it) },
)
}
}
}
}
@Composable
private fun DesktopRouterRoleConfirmationDialog(onDismiss: () -> Unit, onConfirm: () -> Unit) {
val dialogTitle = stringResource(Res.string.are_you_sure)
val dialogText = stringResource(Res.string.router_role_confirmation_text)
var confirmed by rememberSaveable { mutableStateOf(false) }
AlertDialog(
title = { Text(text = dialogTitle) },
text = {
Column {
Text(text = dialogText)
Row(
modifier = Modifier.fillMaxWidth().clickable(true) { confirmed = !confirmed },
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = confirmed, onCheckedChange = { confirmed = it })
Text(stringResource(Res.string.i_know_what_i_m_doing))
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm, enabled = confirmed) { Text(stringResource(Res.string.accept)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(Res.string.cancel)) } },
)
}
/** Generates a POSIX time zone string from a [ZoneId]. JVM/Desktop version of the Android-only `core:model` utility. */
@Suppress("MagicNumber", "ReturnCount")
private fun ZoneId.toPosixString(): String {
val rules = this.rules
if (rules.isFixedOffset || rules.transitionRules.isEmpty()) {
val now = java.time.Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
val springRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds > it.offsetBefore.totalSeconds }
val fallRule = rules.transitionRules.lastOrNull { it.offsetAfter.totalSeconds < it.offsetBefore.totalSeconds }
if (springRule == null || fallRule == null) {
val now = java.time.Instant.now()
val zdt = ZonedDateTime.ofInstant(now, this)
return "${formatAbbreviation(zdt.timeZoneShortName())}${formatPosixOffset(zdt.offset)}"
}
return buildString {
val stdAbbrev = getTransitionAbbreviation(this@toPosixString, fallRule)
val dstAbbrev = getTransitionAbbreviation(this@toPosixString, springRule)
append(formatAbbreviation(stdAbbrev))
append(formatPosixOffset(springRule.offsetBefore))
append(formatAbbreviation(dstAbbrev))
if (springRule.offsetAfter.totalSeconds - springRule.offsetBefore.totalSeconds != 3600) {
append(formatPosixOffset(springRule.offsetAfter))
}
append(formatTransitionRule(springRule))
append(formatTransitionRule(fallRule))
}
}
private fun ZonedDateTime.timeZoneShortName(): String {
val formatter = DateTimeFormatter.ofPattern("zzz", Locale.ENGLISH)
val shortName = format(formatter)
return if (shortName.startsWith("GMT")) "GMT" else shortName
}
private fun formatAbbreviation(abbrev: String): String = if (abbrev.all { it.isLetter() }) abbrev else "<$abbrev>"
private fun getTransitionAbbreviation(zone: ZoneId, rule: ZoneOffsetTransitionRule): String {
val year = java.time.LocalDate.now().year
val transition = rule.createTransition(year)
return ZonedDateTime.ofInstant(transition.instant, zone).timeZoneShortName()
}
@Suppress("MagicNumber")
private fun formatPosixOffset(offset: ZoneOffset): String {
val offsetSeconds = -offset.totalSeconds
val hours = offsetSeconds / 3600
val remainingSeconds = abs(offsetSeconds) % 3600
val minutes = remainingSeconds / 60
val seconds = remainingSeconds % 60
return buildString {
if (offsetSeconds < 0 && hours == 0) append("-")
append(hours)
if (minutes != 0 || seconds != 0) {
append(":%02d".format(Locale.ENGLISH, minutes))
if (seconds != 0) {
append(":%02d".format(Locale.ENGLISH, seconds))
}
}
}
}
@Suppress("MagicNumber")
private fun formatTransitionRule(rule: ZoneOffsetTransitionRule): String {
val month = rule.month.value
val dayOfWeek = rule.dayOfWeek.value % 7
val dayIndicator = rule.dayOfMonthIndicator
val occurrence =
when {
dayIndicator < 0 -> 5
dayIndicator > rule.month.length(false) - 7 -> 5
else -> ((dayIndicator - 1) / 7) + 1
}
val wallTime =
when (rule.timeDefinition) {
ZoneOffsetTransitionRule.TimeDefinition.UTC ->
rule.localTime.plusSeconds(rule.offsetBefore.totalSeconds.toLong())
ZoneOffsetTransitionRule.TimeDefinition.STANDARD -> {
if (rule.offsetAfter.totalSeconds > rule.offsetBefore.totalSeconds) {
rule.localTime
} else {
rule.localTime.plusSeconds(
(rule.offsetBefore.totalSeconds - rule.offsetAfter.totalSeconds).toLong(),
)
}
}
else -> rule.localTime
}
return buildString {
append(",M$month.$occurrence.$dayOfWeek")
if (wallTime.hour != 2 || wallTime.minute != 0 || wallTime.second != 0) {
append("/${wallTime.hour}")
if (wallTime.minute != 0 || wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.minute))
if (wallTime.second != 0) {
append(":%02d".format(Locale.ENGLISH, wallTime.second))
}
}
}
}
}

View file

@ -1,254 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.settings
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
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.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.alert_bell_buzzer
import org.meshtastic.core.resources.alert_bell_led
import org.meshtastic.core.resources.alert_bell_vibra
import org.meshtastic.core.resources.alert_message_buzzer
import org.meshtastic.core.resources.alert_message_led
import org.meshtastic.core.resources.alert_message_vibra
import org.meshtastic.core.resources.external_notification
import org.meshtastic.core.resources.external_notification_config
import org.meshtastic.core.resources.external_notification_enabled
import org.meshtastic.core.resources.nag_timeout_seconds
import org.meshtastic.core.resources.notifications_on_alert_bell_receipt
import org.meshtastic.core.resources.notifications_on_message_receipt
import org.meshtastic.core.resources.output_buzzer_gpio
import org.meshtastic.core.resources.output_duration_milliseconds
import org.meshtastic.core.resources.output_led_active_high
import org.meshtastic.core.resources.output_led_gpio
import org.meshtastic.core.resources.output_vibra_gpio
import org.meshtastic.core.resources.ringtone
import org.meshtastic.core.resources.use_i2s_as_buzzer
import org.meshtastic.core.resources.use_pwm_buzzer
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.ModuleConfig
private const val MAX_RINGTONE_SIZE = 230
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun DesktopExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val extNotificationConfig = state.moduleConfig.external_notification ?: ModuleConfig.ExternalNotificationConfig()
val ringtone = state.ringtone
val formState = rememberConfigState(initialValue = extNotificationConfig)
var ringtoneInput by rememberSaveable(ringtone) { mutableStateOf(ringtone) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.external_notification),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
additionalDirtyCheck = { ringtoneInput != ringtone },
onDiscard = { ringtoneInput = ringtone },
onSave = {
if (ringtoneInput != ringtone) {
viewModel.setRingtone(ringtoneInput)
}
if (formState.value != extNotificationConfig) {
val config = ModuleConfig(external_notification = formState.value)
viewModel.setModuleConfig(config)
}
},
) {
item {
TitledCard(title = stringResource(Res.string.external_notification_config)) {
SwitchPreference(
title = stringResource(Res.string.external_notification_enabled),
checked = formState.value.enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.notifications_on_message_receipt)) {
SwitchPreference(
title = stringResource(Res.string.alert_message_led),
checked = formState.value.alert_message ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_message = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_message_buzzer),
checked = formState.value.alert_message_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_message_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_message_vibra),
checked = formState.value.alert_message_vibra ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_message_vibra = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.notifications_on_alert_bell_receipt)) {
SwitchPreference(
title = stringResource(Res.string.alert_bell_led),
checked = formState.value.alert_bell ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_bell = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_bell_buzzer),
checked = formState.value.alert_bell_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_bell_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.alert_bell_vibra),
checked = formState.value.alert_bell_vibra ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(alert_bell_vibra = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.advanced)) {
val gpio = remember { gpioPins }
DropDownPreference(
title = stringResource(Res.string.output_led_gpio),
items = gpio,
selectedItem = (formState.value.output ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output = it.toInt()) },
)
if (formState.value.output ?: 0 != 0) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.output_led_active_high),
checked = formState.value.active ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(active = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.output_buzzer_gpio),
items = gpio,
selectedItem = (formState.value.output_buzzer ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output_buzzer = it.toInt()) },
)
if (formState.value.output_buzzer ?: 0 != 0) {
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_pwm_buzzer),
checked = formState.value.use_pwm ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(use_pwm = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.output_vibra_gpio),
items = gpio,
selectedItem = (formState.value.output_vibra ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output_vibra = it.toInt()) },
)
HorizontalDivider()
val outputItems = remember { IntervalConfiguration.OUTPUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.output_duration_milliseconds),
items = outputItems.map { it.value to it.toDisplayString() },
selectedItem = (formState.value.output_ms ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(output_ms = it.toInt()) },
)
HorizontalDivider()
val nagItems = remember { IntervalConfiguration.NAG_TIMEOUT.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.nag_timeout_seconds),
items = nagItems.map { it.value to it.toDisplayString() },
selectedItem = (formState.value.nag_timeout ?: 0).toLong(),
enabled = state.connected,
onItemSelected = { formState.value = formState.value.copy(nag_timeout = it.toInt()) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ringtone),
value = ringtoneInput,
maxSize = MAX_RINGTONE_SIZE,
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { ringtoneInput = it },
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.use_i2s_as_buzzer),
checked = formState.value.use_i2s_as_buzzer ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(use_i2s_as_buzzer = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
}

View file

@ -1,260 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.settings
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.config_network_eth_enabled_summary
import org.meshtastic.core.resources.config_network_udp_enabled_summary
import org.meshtastic.core.resources.config_network_wifi_enabled_summary
import org.meshtastic.core.resources.connection_status
import org.meshtastic.core.resources.ethernet_config
import org.meshtastic.core.resources.ethernet_enabled
import org.meshtastic.core.resources.ethernet_ip
import org.meshtastic.core.resources.gateway
import org.meshtastic.core.resources.ip
import org.meshtastic.core.resources.ipv4_mode
import org.meshtastic.core.resources.network
import org.meshtastic.core.resources.ntp_server
import org.meshtastic.core.resources.password
import org.meshtastic.core.resources.rsyslog_server
import org.meshtastic.core.resources.ssid
import org.meshtastic.core.resources.subnet
import org.meshtastic.core.resources.udp_enabled
import org.meshtastic.core.resources.wifi_config
import org.meshtastic.core.resources.wifi_enabled
import org.meshtastic.core.resources.wifi_ip
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditIPv4Preference
import org.meshtastic.core.ui.component.EditPasswordPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.proto.Config
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun DesktopNetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
val formState = rememberConfigState(initialValue = networkConfig)
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.network),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = Config(network = it)
viewModel.setConfig(config)
},
) {
// Display device connection status
state.deviceConnectionStatus?.let { connectionStatus ->
val ws = connectionStatus.wifi?.status
val es = connectionStatus.ethernet?.status
if (ws?.is_connected == true || es?.is_connected == true) {
item {
TitledCard(title = stringResource(Res.string.connection_status)) {
ws?.let { wifiStatus ->
if (wifiStatus.is_connected) {
ListItem(
text = stringResource(Res.string.wifi_ip),
supportingText = formatIpAddress(wifiStatus.ip_address ?: 0),
trailingIcon = null,
)
}
}
es?.let { ethernetStatus ->
if (ethernetStatus.is_connected) {
ListItem(
text = stringResource(Res.string.ethernet_ip),
supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0),
trailingIcon = null,
)
}
}
}
}
}
}
if (state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.wifi_config)) {
SwitchPreference(
title = stringResource(Res.string.wifi_enabled),
summary = stringResource(Res.string.config_network_wifi_enabled_summary),
checked = formState.value.wifi_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ssid),
value = formState.value.wifi_ssid ?: "",
maxSize = 32, // wifi_ssid max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(wifi_ssid = it) },
)
HorizontalDivider()
EditPasswordPreference(
title = stringResource(Res.string.password),
value = formState.value.wifi_psk ?: "",
maxSize = 64, // wifi_psk max_size:65
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(wifi_psk = it) },
)
}
}
}
if (state.metadata?.hasEthernet == true) {
item {
TitledCard(title = stringResource(Res.string.ethernet_config)) {
SwitchPreference(
title = stringResource(Res.string.ethernet_enabled),
summary = stringResource(Res.string.config_network_eth_enabled_summary),
checked = formState.value.eth_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(eth_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
if (state.metadata?.hasEthernet == true || state.metadata?.hasWifi == true) {
item {
TitledCard(title = stringResource(Res.string.network)) {
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked = (formState.value.enabled_protocols ?: 0) == 1,
enabled = state.connected,
onCheckedChange = {
formState.value = formState.value.copy(enabled_protocols = if (it) 1 else 0)
},
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.advanced)) {
EditTextPreference(
title = stringResource(Res.string.ntp_server),
value = formState.value.ntp_server ?: "",
maxSize = 32, // ntp_server max_size:33
enabled = state.connected,
isError = formState.value.ntp_server?.isEmpty() ?: true,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ntp_server = it) },
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.rsyslog_server),
value = formState.value.rsyslog_server ?: "",
maxSize = 32, // rsyslog_server max_size:33
enabled = state.connected,
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
enabled = state.connected,
items = Config.NetworkConfig.AddressMode.entries.map { it to it.name },
selectedItem = formState.value.address_mode ?: Config.NetworkConfig.AddressMode.DHCP,
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
)
HorizontalDivider()
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
EditIPv4Preference(
title = stringResource(Res.string.ip),
value = ipv4.ip,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.gateway),
value = ipv4.gateway,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.subnet),
value = ipv4.subnet,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it)) },
)
HorizontalDivider()
EditIPv4Preference(
title = "DNS",
value = ipv4.dns,
enabled =
state.connected && formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(dns = it)) },
)
}
}
}
}
@Suppress("detekt:MagicNumber")
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
"${(ipAddress shr 8) and 0xFF}." +
"${(ipAddress shr 16) and 0xFF}." +
"${(ipAddress shr 24) and 0xFF}"

View file

@ -1,295 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.settings
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
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.platform.LocalFocusManager
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.Position
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced_device_gps
import org.meshtastic.core.resources.altitude
import org.meshtastic.core.resources.broadcast_interval
import org.meshtastic.core.resources.config_position_broadcast_secs_summary
import org.meshtastic.core.resources.config_position_broadcast_smart_minimum_distance_summary
import org.meshtastic.core.resources.config_position_broadcast_smart_minimum_interval_secs_summary
import org.meshtastic.core.resources.config_position_flags_summary
import org.meshtastic.core.resources.config_position_gps_update_interval_summary
import org.meshtastic.core.resources.device_gps
import org.meshtastic.core.resources.fixed_position
import org.meshtastic.core.resources.gps_en_gpio
import org.meshtastic.core.resources.gps_mode
import org.meshtastic.core.resources.gps_receive_gpio
import org.meshtastic.core.resources.gps_transmit_gpio
import org.meshtastic.core.resources.latitude
import org.meshtastic.core.resources.longitude
import org.meshtastic.core.resources.minimum_distance
import org.meshtastic.core.resources.minimum_interval
import org.meshtastic.core.resources.position
import org.meshtastic.core.resources.position_flags
import org.meshtastic.core.resources.position_packet
import org.meshtastic.core.resources.smart_position
import org.meshtastic.core.resources.update_interval
import org.meshtastic.core.ui.component.BitwisePreference
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.EditTextPreference
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.feature.settings.util.FixedUpdateIntervals
import org.meshtastic.feature.settings.util.IntervalConfiguration
import org.meshtastic.feature.settings.util.gpioPins
import org.meshtastic.feature.settings.util.toDisplayString
import org.meshtastic.proto.Config
@Composable
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun DesktopPositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val node by viewModel.destNode.collectAsStateWithLifecycle()
val currentPosition =
Position(
latitude = node?.latitude ?: 0.0,
longitude = node?.longitude ?: 0.0,
altitude = node?.position?.altitude ?: 0,
time = 1, // ignore time for fixed_position
)
val positionConfig = state.radioConfig.position ?: Config.PositionConfig()
val sanitizedPositionConfig =
remember(positionConfig) {
val positionItems = IntervalConfiguration.POSITION.allowedIntervals
val smartBroadcastItems = IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals
var updated = positionConfig
if (FixedUpdateIntervals.fromValue(updated.position_broadcast_secs.toLong()) == null) {
updated = updated.copy(position_broadcast_secs = positionItems.first().value.toInt())
}
if (FixedUpdateIntervals.fromValue(updated.broadcast_smart_minimum_interval_secs.toLong()) == null) {
updated =
updated.copy(broadcast_smart_minimum_interval_secs = smartBroadcastItems.first().value.toInt())
}
if (FixedUpdateIntervals.fromValue(updated.gps_update_interval.toLong()) == null) {
updated = updated.copy(gps_update_interval = positionItems.first().value.toInt())
}
updated
}
val formState = rememberConfigState(initialValue = sanitizedPositionConfig)
var locationInput by rememberSaveable { mutableStateOf(currentPosition) }
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.position),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
additionalDirtyCheck = { locationInput != currentPosition },
onDiscard = { locationInput = currentPosition },
onSave = {
if (formState.value.fixed_position) {
if (locationInput != currentPosition) {
viewModel.setFixedPosition(locationInput)
}
} else {
if (positionConfig.fixed_position) {
// fixed position changed from enabled to disabled
viewModel.removeFixedPosition()
}
}
val config = Config(position = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.position_packet)) {
val items = remember { IntervalConfiguration.POSITION_BROADCAST.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.broadcast_interval),
summary = stringResource(Res.string.config_position_broadcast_secs_summary),
enabled = state.connected,
items = items.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue((formState.value.position_broadcast_secs ?: 0).toLong())
?: items.first(),
onItemSelected = {
formState.value = formState.value.copy(position_broadcast_secs = it.value.toInt())
},
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.smart_position),
checked = formState.value.position_broadcast_smart_enabled ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(position_broadcast_smart_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.position_broadcast_smart_enabled ?: false) {
HorizontalDivider()
val smartItems = remember { IntervalConfiguration.SMART_BROADCAST_MINIMUM.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.minimum_interval),
summary =
stringResource(Res.string.config_position_broadcast_smart_minimum_interval_secs_summary),
enabled = state.connected,
items = smartItems.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue(
(formState.value.broadcast_smart_minimum_interval_secs ?: 0).toLong(),
) ?: smartItems.first(),
onItemSelected = {
formState.value =
formState.value.copy(broadcast_smart_minimum_interval_secs = it.value.toInt())
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.minimum_distance),
summary = stringResource(Res.string.config_position_broadcast_smart_minimum_distance_summary),
value = formState.value.broadcast_smart_minimum_distance ?: 0,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
formState.value = formState.value.copy(broadcast_smart_minimum_distance = it)
},
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.device_gps)) {
SwitchPreference(
title = stringResource(Res.string.fixed_position),
checked = formState.value.fixed_position ?: false,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(fixed_position = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
if (formState.value.fixed_position ?: false) {
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.latitude),
value = locationInput.latitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { lat: Double ->
if (lat >= -90 && lat <= 90.0) {
locationInput = locationInput.copy(latitude = lat)
}
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.longitude),
value = locationInput.longitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { lon: Double ->
if (lon >= -180 && lon <= 180.0) {
locationInput = locationInput.copy(longitude = lon)
}
},
)
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.altitude),
value = locationInput.altitude,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = { alt: Int -> locationInput = locationInput.copy(altitude = alt) },
)
} else {
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_mode),
enabled = state.connected,
items = Config.PositionConfig.GpsMode.entries.map { it to it.name },
selectedItem = formState.value.gps_mode ?: Config.PositionConfig.GpsMode.DISABLED,
onItemSelected = { formState.value = formState.value.copy(gps_mode = it) },
)
HorizontalDivider()
val items = remember { IntervalConfiguration.GPS_UPDATE.allowedIntervals }
DropDownPreference(
title = stringResource(Res.string.update_interval),
summary = stringResource(Res.string.config_position_gps_update_interval_summary),
enabled = state.connected,
items = items.map { it to it.toDisplayString() },
selectedItem =
FixedUpdateIntervals.fromValue((formState.value.gps_update_interval ?: 0).toLong())
?: items.first(),
onItemSelected = {
formState.value = formState.value.copy(gps_update_interval = it.value.toInt())
},
)
}
}
}
item {
TitledCard(title = stringResource(Res.string.position_flags)) {
BitwisePreference(
title = stringResource(Res.string.position_flags),
summary = stringResource(Res.string.config_position_flags_summary),
value = formState.value.position_flags ?: 0,
enabled = state.connected,
items =
Config.PositionConfig.PositionFlags.entries
.filter { it != Config.PositionConfig.PositionFlags.UNSET }
.map { it.value to it.name },
onItemSelected = { formState.value = formState.value.copy(position_flags = it) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.advanced_device_gps)) {
val pins = remember { gpioPins }
DropDownPreference(
title = stringResource(Res.string.gps_receive_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.rx_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(rx_gpio = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_transmit_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.tx_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(tx_gpio = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.gps_en_gpio),
enabled = state.connected,
items = pins,
selectedItem = formState.value.gps_en_gpio ?: 0,
onItemSelected = { formState.value = formState.value.copy(gps_en_gpio = it) },
)
}
}
}
}

View file

@ -1,232 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.util.encodeToString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.admin_key
import org.meshtastic.core.resources.admin_keys
import org.meshtastic.core.resources.administration
import org.meshtastic.core.resources.config_security_admin_key
import org.meshtastic.core.resources.config_security_debug_log_api_enabled
import org.meshtastic.core.resources.config_security_is_managed
import org.meshtastic.core.resources.config_security_private_key
import org.meshtastic.core.resources.config_security_public_key
import org.meshtastic.core.resources.config_security_serial_enabled
import org.meshtastic.core.resources.debug_log_api_enabled
import org.meshtastic.core.resources.direct_message_key
import org.meshtastic.core.resources.legacy_admin_channel
import org.meshtastic.core.resources.logs
import org.meshtastic.core.resources.managed_mode
import org.meshtastic.core.resources.private_key
import org.meshtastic.core.resources.public_key
import org.meshtastic.core.resources.regenerate_keys_confirmation
import org.meshtastic.core.resources.regenerate_private_key
import org.meshtastic.core.resources.security
import org.meshtastic.core.resources.serial_console
import org.meshtastic.core.ui.component.CopyIconButton
import org.meshtastic.core.ui.component.EditBase64Preference
import org.meshtastic.core.ui.component.EditListPreference
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.NodeActionButton
import org.meshtastic.feature.settings.radio.component.RadioConfigScreenList
import org.meshtastic.feature.settings.radio.component.rememberConfigState
import org.meshtastic.proto.Config
import java.security.SecureRandom
@Composable
@Suppress("LongMethod")
fun DesktopSecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
val formState = rememberConfigState(initialValue = securityConfig)
var publicKey by rememberSaveable { mutableStateOf(formState.value.public_key) }
LaunchedEffect(formState.value.private_key) {
if (formState.value.private_key != securityConfig.private_key) {
publicKey = ByteString.EMPTY
} else if (formState.value.private_key == securityConfig.private_key) {
publicKey = securityConfig.public_key
}
}
var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) }
if (showKeyGenerationDialog) {
DesktopPrivateKeyRegenerateDialog(
onConfirm = {
formState.value = it
showKeyGenerationDialog = false
val config = Config(security = formState.value)
viewModel.setConfig(config)
},
onDismiss = { showKeyGenerationDialog = false },
)
}
val focusManager = LocalFocusManager.current
RadioConfigScreenList(
title = stringResource(Res.string.security),
onBack = onBack,
configState = formState,
enabled = state.connected,
responseState = state.responseState,
onDismissPacketResponse = viewModel::clearPacketResponse,
onSave = {
val config = Config(security = it)
viewModel.setConfig(config)
},
) {
item {
TitledCard(title = stringResource(Res.string.direct_message_key)) {
EditBase64Preference(
title = stringResource(Res.string.public_key),
summary = stringResource(Res.string.config_security_public_key),
value = publicKey,
enabled = state.connected,
readOnly = true,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size == 32) {
formState.value = formState.value.copy(public_key = it)
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.public_key.encodeToString()) },
)
HorizontalDivider()
EditBase64Preference(
title = stringResource(Res.string.private_key),
summary = stringResource(Res.string.config_security_private_key),
value = formState.value.private_key,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChange = {
if (it.size == 32) {
formState.value = formState.value.copy(private_key = it)
}
},
trailingIcon = { CopyIconButton(valueToCopy = formState.value.private_key.encodeToString()) },
)
HorizontalDivider()
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(Res.string.regenerate_private_key),
enabled = state.connected,
icon = Icons.TwoTone.Warning,
onClick = { showKeyGenerationDialog = true },
)
}
}
item {
TitledCard(title = stringResource(Res.string.admin_keys)) {
EditListPreference(
title = stringResource(Res.string.admin_key),
summary = stringResource(Res.string.config_security_admin_key),
list = formState.value.admin_key,
maxCount = 3,
enabled = state.connected,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValuesChanged = { formState.value = formState.value.copy(admin_key = it) },
)
}
}
item {
TitledCard(title = stringResource(Res.string.logs)) {
SwitchPreference(
title = stringResource(Res.string.serial_console),
summary = stringResource(Res.string.config_security_serial_enabled),
checked = formState.value.serial_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(serial_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.debug_log_api_enabled),
summary = stringResource(Res.string.config_security_debug_log_api_enabled),
checked = formState.value.debug_log_api_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(debug_log_api_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
item {
TitledCard(title = stringResource(Res.string.administration)) {
SwitchPreference(
title = stringResource(Res.string.managed_mode),
summary = stringResource(Res.string.config_security_is_managed),
checked = formState.value.is_managed,
enabled = state.connected && formState.value.admin_key.isNotEmpty(),
onCheckedChange = { formState.value = formState.value.copy(is_managed = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
HorizontalDivider()
SwitchPreference(
title = stringResource(Res.string.legacy_admin_channel),
checked = formState.value.admin_channel_enabled,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(admin_channel_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
}
}
}
}
@Suppress("MagicNumber")
@Composable
private fun DesktopPrivateKeyRegenerateDialog(onConfirm: (Config.SecurityConfig) -> Unit, onDismiss: () -> Unit = {}) {
MeshtasticResourceDialog(
onDismiss = onDismiss,
titleRes = Res.string.regenerate_private_key,
messageRes = Res.string.regenerate_keys_confirmation,
onConfirm = {
// Generate a random "f" value
val f = ByteArray(32).apply { SecureRandom().nextBytes(this) }
// Adjust the value to make it valid as an "s" value for eval().
// According to the specification we need to mask off the 3
// right-most bits of f[0], mask off the left-most bit of f[31],
// and set the second to left-most bit of f[31].
f[0] = (f[0].toInt() and 0xF8).toByte()
f[31] = ((f[31].toInt() and 0x7F) or 0x40).toByte()
val securityInput = Config.SecurityConfig(private_key = f.toByteString(), public_key = ByteString.EMPTY)
onConfirm(securityInput)
},
)
}

View file

@ -1,384 +0,0 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.desktop.ui.settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
import androidx.compose.material.icons.rounded.FormatPaint
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Memory
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoutes
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.acknowledgements
import org.meshtastic.core.resources.app_settings
import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.choose_theme
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.dynamic
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.modules_already_unlocked
import org.meshtastic.core.resources.modules_unlocked
import org.meshtastic.core.resources.preferences_language
import org.meshtastic.core.resources.remotely_administrating
import org.meshtastic.core.resources.theme
import org.meshtastic.core.resources.theme_dark
import org.meshtastic.core.resources.theme_light
import org.meshtastic.core.resources.theme_system
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.component.ExpressiveSection
import org.meshtastic.feature.settings.component.HomoglyphSetting
import org.meshtastic.feature.settings.component.NotificationSection
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.ModuleRoute
import org.meshtastic.feature.settings.radio.RadioConfigItemList
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import kotlin.time.Duration.Companion.seconds
/**
* Desktop-specific top-level settings screen. Replaces the Android `SettingsScreen` which uses Android-specific APIs
* (Activity, permissions, etc.).
*
* Shows radio configuration entry points that are fully shared in commonMain, plus app-level settings (theme,
* homoglyph, DB cache limit) and an App Info section (About link, version easter egg).
*/
@Suppress("LongMethod")
@Composable
fun DesktopSettingsScreen(
radioConfigViewModel: RadioConfigViewModel,
settingsViewModel: SettingsViewModel,
onNavigate: (Route) -> Unit,
) {
val state by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val destNode by radioConfigViewModel.destNode.collectAsStateWithLifecycle()
val localConfig by settingsViewModel.localConfig.collectAsStateWithLifecycle()
val homoglyphEnabled by radioConfigViewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false)
val excludedModulesUnlocked by settingsViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
val cacheLimit by settingsViewModel.dbCacheLimit.collectAsStateWithLifecycle()
var showThemePickerDialog by remember { mutableStateOf(false) }
var showLanguagePickerDialog by remember { mutableStateOf(false) }
if (showThemePickerDialog) {
ThemePickerDialog(
onClickTheme = { settingsViewModel.setTheme(it) },
onDismiss = { showThemePickerDialog = false },
)
}
if (showLanguagePickerDialog) {
LanguagePickerDialog(
onSelectLanguage = { tag -> settingsViewModel.setLocale(tag) },
onDismiss = { showLanguagePickerDialog = false },
)
}
Scaffold(
topBar = {
MainAppBar(
title = stringResource(Res.string.bottom_nav_settings),
subtitle =
if (state.isLocal) {
null
} else {
val remoteName = destNode?.user?.long_name ?: ""
stringResource(Res.string.remotely_administrating, remoteName)
},
ourNode = null,
showNodeChip = false,
canNavigateUp = false,
onNavigateUp = {},
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
Column(
modifier = Modifier.padding(paddingValues).verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
RadioConfigItemList(
state = state,
isManaged = localConfig.security?.is_managed ?: false,
isOtaCapable = false, // OTA not supported on Desktop yet
onRouteClick = { route ->
val navRoute =
when (route) {
is ConfigRoute -> route.route
is ModuleRoute -> route.route
else -> null
}
navRoute?.let { onNavigate(it) }
},
onNavigate = onNavigate,
onImport = {
// Profile import not yet supported on Desktop
},
onExport = {
// Profile export not yet supported on Desktop
},
)
// App-local settings are only relevant when configuring the local node
if (state.isLocal) {
ExpressiveSection(title = stringResource(Res.string.app_settings)) {
ListItem(
text = stringResource(Res.string.theme),
leadingIcon = Icons.Rounded.FormatPaint,
trailingIcon = null,
) {
showThemePickerDialog = true
}
ListItem(
text = stringResource(Res.string.preferences_language),
leadingIcon = Icons.Rounded.Language,
trailingIcon = null,
) {
showLanguagePickerDialog = true
}
HomoglyphSetting(
homoglyphEncodingEnabled = homoglyphEnabled,
onToggle = { radioConfigViewModel.toggleHomoglyphCharactersEncodingEnabled() },
)
val cacheItems = remember {
(DatabaseConstants.MIN_CACHE_LIMIT..DatabaseConstants.MAX_CACHE_LIMIT).map {
it.toLong() to it.toString()
}
}
DropDownPreference(
title = stringResource(Res.string.device_db_cache_limit),
enabled = true,
items = cacheItems,
selectedItem = cacheLimit.toLong(),
onItemSelected = { selected -> settingsViewModel.setDbCacheLimit(selected.toInt()) },
summary = stringResource(Res.string.device_db_cache_limit_summary),
)
}
NotificationSection(
messagesEnabled = settingsViewModel.messagesEnabled.collectAsStateWithLifecycle().value,
onToggleMessages = { settingsViewModel.setMessagesEnabled(it) },
nodeEventsEnabled = settingsViewModel.nodeEventsEnabled.collectAsStateWithLifecycle().value,
onToggleNodeEvents = { settingsViewModel.setNodeEventsEnabled(it) },
lowBatteryEnabled = settingsViewModel.lowBatteryEnabled.collectAsStateWithLifecycle().value,
onToggleLowBattery = { settingsViewModel.setLowBatteryEnabled(it) },
)
DesktopAppInfoSection(
appVersionName = settingsViewModel.appVersionName,
excludedModulesUnlocked = excludedModulesUnlocked,
onUnlockExcludedModules = { settingsViewModel.unlockExcludedModules() },
onNavigateToAbout = { onNavigate(SettingsRoutes.About) },
)
}
}
}
}
/** Desktop App Info section: About link and version with excluded-modules unlock easter egg. */
@Composable
private fun DesktopAppInfoSection(
appVersionName: String,
excludedModulesUnlocked: Boolean,
onUnlockExcludedModules: () -> Unit,
onNavigateToAbout: () -> Unit,
) {
ExpressiveSection(title = stringResource(Res.string.info)) {
ListItem(
text = stringResource(Res.string.acknowledgements),
leadingIcon = Icons.Rounded.Info,
trailingIcon = Icons.AutoMirrored.Rounded.KeyboardArrowRight,
) {
onNavigateToAbout()
}
DesktopAppVersionButton(
excludedModulesUnlocked = excludedModulesUnlocked,
appVersionName = appVersionName,
onUnlockExcludedModules = onUnlockExcludedModules,
)
}
}
private const val UNLOCK_CLICK_COUNT = 5
private const val UNLOCKED_CLICK_COUNT = 3
private const val UNLOCK_TIMEOUT_SECONDS = 1
@Composable
private fun DesktopAppVersionButton(
excludedModulesUnlocked: Boolean,
appVersionName: String,
onUnlockExcludedModules: () -> Unit,
) {
val scope = rememberCoroutineScope()
val showToast = rememberShowToastResource()
var clickCount by remember { mutableStateOf(0) }
LaunchedEffect(clickCount) {
if (clickCount in 1..<UNLOCK_CLICK_COUNT) {
delay(UNLOCK_TIMEOUT_SECONDS.seconds)
clickCount = 0
}
}
ListItem(
text = stringResource(Res.string.app_version),
leadingIcon = Icons.Rounded.Memory,
supportingText = appVersionName,
trailingIcon = null,
) {
clickCount = clickCount.inc().coerceIn(0, UNLOCK_CLICK_COUNT)
when {
clickCount == UNLOCKED_CLICK_COUNT && excludedModulesUnlocked -> {
clickCount = 0
scope.launch { showToast(Res.string.modules_already_unlocked) }
}
clickCount == UNLOCK_CLICK_COUNT -> {
clickCount = 0
onUnlockExcludedModules()
scope.launch { showToast(Res.string.modules_unlocked) }
}
}
}
}
private enum class ThemeOption(val label: StringResource, val mode: Int) {
DYNAMIC(label = Res.string.dynamic, mode = MODE_DYNAMIC),
LIGHT(label = Res.string.theme_light, mode = 1), // MODE_NIGHT_NO
DARK(label = Res.string.theme_dark, mode = 2), // MODE_NIGHT_YES
SYSTEM(label = Res.string.theme_system, mode = -1), // MODE_NIGHT_FOLLOW_SYSTEM
}
@Composable
private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.choose_theme),
onDismiss = onDismiss,
text = {
Column {
ThemeOption.entries.forEach { option ->
ListItem(text = stringResource(option.label), trailingIcon = null) {
onClickTheme(option.mode)
onDismiss()
}
}
}
},
)
}
/**
* Supported languages tag must match the CMP `values-<qualifier>` directory names. Empty tag means system default.
* Display names are written in the native language for clarity.
*/
private val SUPPORTED_LANGUAGES =
listOf(
"" to "System default",
"ar" to "العربية",
"be" to "Беларуская",
"bg" to "Български",
"ca" to "Català",
"cs" to "Čeština",
"de" to "Deutsch",
"el" to "Ελληνικά",
"en" to "English",
"es" to "Español",
"et" to "Eesti",
"fi" to "Suomi",
"fr" to "Français",
"ga" to "Gaeilge",
"gl" to "Galego",
"he" to "עברית",
"hr" to "Hrvatski",
"ht" to "Kreyòl Ayisyen",
"hu" to "Magyar",
"is" to "Íslenska",
"it" to "Italiano",
"ja" to "日本語",
"ko" to "한국어",
"lt" to "Lietuvių",
"nl" to "Nederlands",
"no" to "Norsk",
"pl" to "Polski",
"pt" to "Português",
"pt-BR" to "Português (Brasil)",
"ro" to "Română",
"ru" to "Русский",
"sk" to "Slovenčina",
"sl" to "Slovenščina",
"sq" to "Shqip",
"sr" to "Српски",
"sv" to "Svenska",
"tr" to "Türkçe",
"uk" to "Українська",
"zh-CN" to "中文 (简体)",
"zh-TW" to "中文 (繁體)",
)
@Composable
private fun LanguagePickerDialog(onSelectLanguage: (String) -> Unit, onDismiss: () -> Unit) {
MeshtasticDialog(
title = stringResource(Res.string.preferences_language),
onDismiss = onDismiss,
text = {
LazyColumn {
items(SUPPORTED_LANGUAGES) { (tag, displayName) ->
ListItem(text = displayName, trailingIcon = null) {
onSelectLanguage(tag)
onDismiss()
}
}
}
},
)
}