mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)
This commit is contained in:
parent
f04924ded5
commit
d136b162a4
170 changed files with 2208 additions and 2432 deletions
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) }
|
||||
}
|
||||
|
|
@ -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].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue