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

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

View file

@ -18,8 +18,6 @@
plugins { alias(libs.plugins.meshtastic.kmp.feature) }
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.connections"

View file

@ -1,13 +1,5 @@
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$21972</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$32809</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$6790</ID>
<ID>MagicNumber:ProbeTableProvider.kt$ProbeTableProvider$9114</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$115200</ID>
<ID>MagicNumber:SerialConnectionImpl.kt$SerialConnectionImpl$200</ID>
<ID>SwallowedException:NsdManager.kt$ex: IllegalArgumentException</ID>
</CurrentIssues>
<CurrentIssues/>
</SmellBaseline>

View file

@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.RadioController
@ -84,7 +85,7 @@ open class ScannerViewModel(
timeout = kotlin.time.Duration.INFINITE,
serviceUuid = org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID,
)
.flowOn(kotlinx.coroutines.Dispatchers.IO)
.flowOn(ioDispatcher)
.collect { device ->
if (!scannedBleDevices.value.containsKey(device.address)) {
scannedBleDevices.update { current -> current + (device.address to device) }

View file

@ -22,7 +22,7 @@ import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ConnectionsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.connections.AndroidScannerViewModel
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@ -30,12 +30,9 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoutes.ConnectionsGraph> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
scanModel = koinViewModel<ScannerViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onClickNodeChip = {
// Navigation 3 ignores back stack behavior options; we handle this by popping if necessary.
backStack.add(NodesRoutes.NodeDetailGraph(it))
},
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onConfigNavigate = { route -> backStack.add(route) },
)
@ -43,7 +40,7 @@ fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>)
entry<ConnectionsRoutes.Connections> {
ConnectionsScreen(
scanModel = koinViewModel<AndroidScannerViewModel>(),
scanModel = koinViewModel<ScannerViewModel>(),
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
onNavigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.navigation
import androidx.compose.runtime.Composable
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
@Composable
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = onNavigateUp, viewModel = viewModel)
}

View file

@ -16,17 +16,15 @@
*/
package org.meshtastic.feature.firmware.navigation
import androidx.compose.runtime.Composable
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.FirmwareRoutes
import org.meshtastic.feature.firmware.FirmwareUpdateScreen
import org.meshtastic.feature.firmware.FirmwareUpdateViewModel
fun EntryProviderScope<NavKey>.firmwareGraph(backStack: NavBackStack<NavKey>) {
entry<FirmwareRoutes.FirmwareUpdate> {
val viewModel = koinViewModel<FirmwareUpdateViewModel>()
FirmwareUpdateScreen(onNavigateUp = { backStack.removeLastOrNull() }, viewModel = viewModel)
}
entry<FirmwareRoutes.FirmwareGraph> { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
entry<FirmwareRoutes.FirmwareUpdate> { FirmwareScreen(onNavigateUp = { backStack.removeLastOrNull() }) }
}
@Composable expect fun FirmwareScreen(onNavigateUp: () -> Unit)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.navigation
import androidx.compose.runtime.Composable
@Composable
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
// TODO: Implement iOS firmware screen
}

View file

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

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.firmware.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.feature.firmware.DesktopFirmwareScreen
@Composable
actual fun FirmwareScreen(onNavigateUp: () -> Unit) {
DesktopFirmwareScreen()
}

View file

@ -21,8 +21,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.intro"

View file

@ -20,8 +20,6 @@ plugins {
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.map"

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.navigation
import androidx.compose.runtime.Composable
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
@Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
onClickNodeChip = onClickNodeChip,
navigateToNodeDetails = navigateToNodeDetails,
waypointId = waypointId,
)
}

View file

@ -18,7 +18,6 @@ package org.meshtastic.feature.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
@ -139,7 +139,7 @@ open class BaseMapViewModel(
fun getNodeOrFallback(nodeNum: Int): Node = nodeRepository.nodeDBbyNum.value[nodeNum] ?: Node(num = nodeNum)
fun deleteWaypoint(id: Int) = viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteWaypoint(id) }
fun deleteWaypoint(id: Int) = viewModelScope.launch(ioDispatcher) { packetRepository.deleteWaypoint(id) }
fun sendWaypoint(wpt: Waypoint, contactKey: String = "0${DataPacket.ID_BROADCAST}") {
// contactKey: unique contact key filter (channel)+(nodeId)
@ -151,7 +151,7 @@ open class BaseMapViewModel(
}
private fun sendDataPacket(p: DataPacket) {
viewModelScope.launch(Dispatchers.IO) { radioController.sendMessage(p) }
viewModelScope.launch(ioDispatcher) { radioController.sendMessage(p) }
}
fun generatePacketId(): Int = radioController.getPacketId()

View file

@ -16,23 +16,22 @@
*/
package org.meshtastic.feature.map.navigation
import androidx.compose.runtime.Composable
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.MapRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.feature.map.MapScreen
import org.meshtastic.feature.map.SharedMapViewModel
fun EntryProviderScope<NavKey>.mapGraph(backStack: NavBackStack<NavKey>) {
entry<MapRoutes.Map> { args ->
val viewModel = koinViewModel<SharedMapViewModel>()
MapScreen(
viewModel = viewModel,
MapMainScreen(
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
waypointId = args.waypointId,
)
}
}
@Composable
expect fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.navigation
import androidx.compose.runtime.Composable
@Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
// TODO: Implement iOS map main screen
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.map.navigation
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
@Composable
actual fun MapMainScreen(onClickNodeChip: (Int) -> Unit, navigateToNodeDetails: (Int) -> Unit, waypointId: Int?) {
// Desktop placeholder for now
org.meshtastic.feature.map.navigation.PlaceholderScreen(name = "Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
androidx.compose.foundation.layout.Box(
modifier = androidx.compose.ui.Modifier.fillMaxSize(),
contentAlignment = androidx.compose.ui.Alignment.Center,
) {
androidx.compose.material3.Text(
text = name,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -43,7 +43,9 @@ kotlin {
implementation(projects.core.ui)
implementation(libs.jetbrains.navigation3.runtime)
implementation(libs.jetbrains.navigationevent.compose)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)
// JetBrains Material 3 Adaptive (multiplatform ListDetailPaneScaffold)
implementation(libs.jetbrains.compose.material3.adaptive)
@ -51,10 +53,7 @@ kotlin {
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
}
androidMain.dependencies {
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.work.runtime.ktx)
}
androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
androidUnitTest.dependencies {
implementation(libs.androidx.work.testing)

View file

@ -0,0 +1,59 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@Composable
actual fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String?,
initialMessage: String,
) {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<ContactsViewModel>()
val messageViewModel = koinViewModel<MessageViewModel>()
initialContactKey?.let { messageViewModel.setContactKey(it) }
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,
initialMessage = initialMessage,
)
}

View file

@ -19,6 +19,7 @@ package org.meshtastic.feature.messaging.component
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
@ -26,7 +27,6 @@ import org.junit.runner.RunWith
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
@RunWith(AndroidJUnit4::class)
class MessageItemTest {

View file

@ -18,7 +18,6 @@
package org.meshtastic.feature.messaging
import android.content.ClipData
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
@ -78,6 +77,7 @@ import org.meshtastic.core.resources.unknown_channel
import org.meshtastic.core.ui.component.SharedContactDialog
import org.meshtastic.core.ui.component.smartScrollToIndex
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.feature.messaging.component.ActionModeTopBar
import org.meshtastic.feature.messaging.component.DeleteMessageDialog
import org.meshtastic.feature.messaging.component.MESSAGE_CHARACTER_LIMIT_BYTES
@ -86,7 +86,6 @@ 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 java.nio.charset.StandardCharsets
private const val ROUNDED_CORNER_PERCENT = 100
private const val MAX_LINES = 3
@ -243,11 +242,7 @@ fun MessageScreen(
is MessageScreenEvent.NavigateToNodeDetails -> navigateToNodeDetails(event.nodeNum)
MessageScreenEvent.NavigateBack -> onNavigateBack()
is MessageScreenEvent.CopyToClipboard -> {
coroutineScope.launch {
clipboardManager.setClipEntry(
androidx.compose.ui.platform.ClipEntry(ClipData.newPlainText(event.text, event.text)),
)
}
coroutineScope.launch { clipboardManager.setClipEntry(createClipEntry(event.text, event.text)) }
selectedMessageIds.value = emptySet()
}
}
@ -450,7 +445,7 @@ private fun MessageInput(
val currentByteLength =
remember(currentText) {
// Recalculate only when text changes
currentText.toByteArray(StandardCharsets.UTF_8).size
currentText.encodeToByteArray().size
}
val isOverLimit = currentByteLength > maxByteSize

View file

@ -21,7 +21,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -34,6 +33,7 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Message
@ -190,7 +190,7 @@ class MessageViewModel(
}
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
@ -218,10 +218,10 @@ class MessageViewModel(
viewModelScope.launch { serviceRepository.onServiceAction(ServiceAction.Reaction(emoji, replyId, contactKey)) }
fun deleteMessages(uuidList: List<Long>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteMessages(uuidList) }
viewModelScope.launch(ioDispatcher) { packetRepository.deleteMessages(uuidList) }
fun clearUnreadCount(contact: String, messageUuid: Long, lastReadTimestamp: Long) =
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(ioDispatcher) {
val existingTimestamp = contactSettings.value[contact]?.lastReadMessageTimestamp ?: Long.MIN_VALUE
if (lastReadTimestamp <= existingTimestamp) {
return@launch

View file

@ -202,7 +202,7 @@ internal fun EditQuickChatDialog(
label = stringResource(Res.string.message),
value = actionInput.message,
maxSize = 200,
getSize = { it.toByteArray().size + 1 },
getSize = { it.encodeToByteArray().size + 1 },
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
) {
actionInput = actionInput.copy(message = it)

View file

@ -18,9 +18,9 @@ package org.meshtastic.feature.messaging
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.repository.QuickChatActionRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@ -31,7 +31,7 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
get() = quickChatActionRepository.getAllActions().stateInWhileSubscribed(initialValue = emptyList())
fun updateActionPositions(actions: List<QuickChatAction>) {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(ioDispatcher) {
for (position in actions.indices) {
quickChatActionRepository.setItemPosition(actions[position].uuid, position)
}
@ -39,8 +39,8 @@ class QuickChatViewModel(private val quickChatActionRepository: QuickChatActionR
}
fun addQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.upsert(action) }
viewModelScope.launch(ioDispatcher) { quickChatActionRepository.upsert(action) }
fun deleteQuickChatAction(action: QuickChatAction) =
viewModelScope.launch(Dispatchers.IO) { quickChatActionRepository.delete(action) }
viewModelScope.launch(ioDispatcher) { quickChatActionRepository.delete(action) }
}

View file

@ -17,27 +17,23 @@
package org.meshtastic.feature.messaging.navigation
import androidx.compose.runtime.Composable
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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ContactsRoutes
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.viewmodel.UIViewModel
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.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
import org.meshtastic.feature.messaging.ui.sharing.ShareScreen
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.contactsGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
) {
entry<ContactsRoutes.ContactsGraph> {
ContactsEntryContent(backStack = backStack, scrollToTopEvents = scrollToTopEvents)
@ -77,30 +73,9 @@ fun EntryProviderScope<NavKey>.contactsGraph(
}
@Composable
private fun ContactsEntryContent(
expect fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String? = null,
initialMessage: String = "",
) {
val uiViewModel: UIViewModel = koinViewModel()
val sharedContactRequested by uiViewModel.sharedContactRequested.collectAsStateWithLifecycle()
val requestChannelSet by uiViewModel.requestChannelSet.collectAsStateWithLifecycle()
val contactsViewModel = koinViewModel<ContactsViewModel>()
val messageViewModel = koinViewModel<MessageViewModel>()
initialContactKey?.let { messageViewModel.setContactKey(it) }
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = contactsViewModel,
messageViewModel = messageViewModel,
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = sharedContactRequested,
requestChannelSet = requestChannelSet,
onHandleScannedUri = uiViewModel::handleScannedUri,
onClearSharedContactRequested = uiViewModel::clearSharedContactRequested,
onClearRequestChannelUrl = uiViewModel::clearRequestChannelUrl,
initialContactKey = initialContactKey,
initialMessage = initialMessage,
)
}
)

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.messaging.ui.contact
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
@ -29,7 +28,10 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.CancellationException
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.NavigationEventTransitionState
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
@ -44,6 +46,7 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.icon.Conversations
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
@ -52,8 +55,8 @@ import org.meshtastic.proto.SharedContact
@Composable
fun AdaptiveContactsScreen(
backStack: NavBackStack<NavKey>,
contactsViewModel: org.meshtastic.feature.messaging.ui.contact.ContactsViewModel,
messageViewModel: org.meshtastic.feature.messaging.MessageViewModel,
contactsViewModel: ContactsViewModel,
messageViewModel: MessageViewModel,
scrollToTopEvents: Flow<ScrollToTopEvent>,
sharedContactRequested: SharedContact?,
requestChannelSet: ChannelSet?,
@ -62,6 +65,7 @@ fun AdaptiveContactsScreen(
onClearRequestChannelUrl: () -> Unit,
initialContactKey: String? = null,
initialMessage: String = "",
detailPaneCustom: @Composable ((contactKey: String) -> Unit)? = null,
) {
val navigator = rememberListDetailPaneScaffoldNavigator<String>()
val scope = rememberCoroutineScope()
@ -95,14 +99,18 @@ fun AdaptiveContactsScreen(
}
}
PredictiveBackHandler(
enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
) { progress ->
try {
progress.collect { /* Predictive back progress could be used here to drive UI if scaffold supported it */ }
handleBack()
} catch (_: CancellationException) {
// Gesture cancelled
val navState = rememberNavigationEventState(NavigationEventInfo.None)
NavigationBackHandler(
state = navState,
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
onBackCancelled = { /* Gesture cancelled */ },
onBackCompleted = { handleBack() },
)
LaunchedEffect(navState.transitionState) {
val transitionState = navState.transitionState
if (transitionState is NavigationEventTransitionState.InProgress) {
val progress = transitionState.latestEvent.progress
// Animate the back gesture progress could be used here to drive UI if scaffold supported it
}
}
@ -154,14 +162,18 @@ fun AdaptiveContactsScreen(
AnimatedPane {
navigator.currentDestination?.contentKey?.let { contactKey ->
key(contactKey) {
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,
)
if (detailPaneCustom != null) {
detailPaneCustom(contactKey)
} else {
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
navigateToQuickChatOptions = { backStack.add(ContactsRoutes.QuickChat) },
onNavigateBack = handleBack,
)
}
}
}
?: EmptyDetailPlaceholder(

View file

@ -49,16 +49,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -66,7 +63,6 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toMeshtasticUri
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.util.TimeConstants
@ -108,12 +104,11 @@ import org.meshtastic.core.ui.icon.SelectAll
import org.meshtastic.core.ui.icon.VolumeMuteTwoTone
import org.meshtastic.core.ui.icon.VolumeUpTwoTone
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
import kotlin.time.Duration.Companion.days
@OptIn(ExperimentalPermissionsApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod", "LongParameterList")
@Composable
fun ContactsScreen(
@ -124,13 +119,13 @@ fun ContactsScreen(
onClearSharedContactRequested: () -> Unit,
onClearRequestChannelUrl: () -> Unit,
viewModel: ContactsViewModel,
onClickNodeChip: (Int) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
onNavigateToNodeDetails: (Int) -> Unit = {},
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeContactKey: String? = null,
onClickNodeChip: (Int) -> Unit,
onNavigateToMessages: (String) -> Unit,
onNavigateToNodeDetails: (Int) -> Unit,
scrollToTopEvents: Flow<ScrollToTopEvent>?,
activeContactKey: String?,
) {
val context = LocalContext.current
val showToast = rememberShowToastResource()
val scope = rememberCoroutineScope()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
@ -258,8 +253,8 @@ fun ContactsScreen(
MeshtasticImportFAB(
sharedContact = sharedContactRequested,
onImport = { uriString ->
onHandleScannedUri(uriString.toUri().toMeshtasticUri()) {
scope.launch { context.showToast(Res.string.channel_invalid) }
onHandleScannedUri(MeshtasticUri(uriString)) {
scope.launch { showToast(Res.string.channel_invalid) }
}
},
onShareChannels = onNavigateToShare,

View file

@ -21,13 +21,13 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.Contact
import org.meshtastic.core.model.ContactSettings
import org.meshtastic.core.model.DataPacket
@ -189,17 +189,17 @@ class ContactsViewModel(
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun deleteContacts(contacts: List<String>) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.deleteContacts(contacts) }
viewModelScope.launch(ioDispatcher) { packetRepository.deleteContacts(contacts) }
fun markAllAsRead() = viewModelScope.launch(Dispatchers.IO) { packetRepository.clearAllUnreadCounts() }
fun markAllAsRead() = viewModelScope.launch(ioDispatcher) { packetRepository.clearAllUnreadCounts() }
fun setMuteUntil(contacts: List<String>, until: Long) =
viewModelScope.launch(Dispatchers.IO) { packetRepository.setMuteUntil(contacts, until) }
viewModelScope.launch(ioDispatcher) { packetRepository.setMuteUntil(contacts, until) }
fun getContactSettings() = packetRepository.getContactSettings()
fun setContactFilteringDisabled(contactKey: String, disabled: Boolean) {
viewModelScope.launch(Dispatchers.IO) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
viewModelScope.launch(ioDispatcher) { packetRepository.setContactFilteringDisabled(contactKey, disabled) }
}
/**

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.navigation
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.ui.component.ScrollToTopEvent
@Composable
actual fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String?,
initialMessage: String,
) {
// TODO: Implement iOS contacts screen
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.messaging.navigation
import androidx.compose.runtime.Composable
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.feature.messaging.MessageScreen
import org.meshtastic.feature.messaging.MessageViewModel
import org.meshtastic.feature.messaging.ui.contact.AdaptiveContactsScreen
import org.meshtastic.feature.messaging.ui.contact.ContactsViewModel
@Composable
actual fun ContactsEntryContent(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialContactKey: String?,
initialMessage: String,
) {
val viewModel: ContactsViewModel = koinViewModel()
AdaptiveContactsScreen(
backStack = backStack,
contactsViewModel = viewModel,
messageViewModel = koinViewModel(), // Used for desktop detail pane
scrollToTopEvents = scrollToTopEvents,
sharedContactRequested = null,
requestChannelSet = null,
onHandleScannedUri = { _, _ -> },
onClearSharedContactRequested = {},
onClearRequestChannelUrl = {},
initialContactKey = initialContactKey,
initialMessage = initialMessage,
detailPaneCustom = { contactKey ->
val messageViewModel: MessageViewModel = koinViewModel(key = "messages-$contactKey")
MessageScreen(
contactKey = contactKey,
message = if (contactKey == initialContactKey) initialMessage else "",
viewModel = messageViewModel,
navigateToNodeDetails = {
backStack.add(org.meshtastic.core.navigation.NodesRoutes.NodeDetailGraph(it))
},
navigateToQuickChatOptions = { backStack.add(org.meshtastic.core.navigation.ContactsRoutes.QuickChat) },
onNavigateBack = { backStack.removeLastOrNull() },
)
},
)
}

View file

@ -70,14 +70,14 @@ private sealed interface NodeDetailOverlay {
}
@Composable
fun NodeDetailScreen(
actual fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
modifier: Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel?,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }

View file

@ -102,7 +102,7 @@ private fun ActionButtons(
@Suppress("LongMethod")
@Composable
fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.navigation
import androidx.compose.runtime.Composable
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.feature.node.metrics.MetricsViewModel
import org.meshtastic.feature.node.metrics.TracerouteMapScreen as AndroidTracerouteMapScreen
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
val metricsViewModel = koinViewModel<MetricsViewModel>(key = "metrics-$destNum") { parametersOf(destNum) }
metricsViewModel.setNodeId(destNum)
AndroidTracerouteMapScreen(
metricsViewModel = metricsViewModel,
requestId = requestId,
logUuid = logUuid,
onNavigateUp = onNavigateUp,
)
}

View file

@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.bearing
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@ -119,7 +120,7 @@ class CompassViewModel(
val bearingDegrees = calculateBearing(locationState, target)
val trueHeading = applyTrueNorthCorrection(headingState.heading, locationState)
val distanceText = distanceMeters?.toDistanceString(current.displayUnits)
val bearingText = bearingDegrees?.let { BEARING_FORMAT.format(it) }
val bearingText = bearingDegrees?.let { formatString(BEARING_FORMAT, it) }
val isAligned = isAligned(trueHeading, bearingDegrees)
val lastUpdateText = targetPositionTimeSec?.let { formatElapsed(it) }
val angularErrorDeg = calculateAngularError(positionalAccuracyMeters, distanceMeters)

View file

@ -55,6 +55,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.compass_bearing
import org.meshtastic.core.resources.compass_bearing_na
@ -71,6 +72,7 @@ import org.meshtastic.core.resources.exchange_position
import org.meshtastic.core.resources.last_position_update
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassWarning
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
@ -126,7 +128,7 @@ fun CompassSheetContent(
Text(
text =
uiState.errorRadiusText?.let { radius ->
val angle = uiState.angularErrorDeg?.let { "%.0f°".format(it) } ?: "?"
val angle = uiState.angularErrorDeg?.let { formatString("%.0f°", it) } ?: "?"
stringResource(Res.string.compass_uncertainty, radius, angle)
} ?: stringResource(Res.string.compass_uncertainty_unknown),
style = MaterialTheme.typography.bodyMedium,
@ -279,7 +281,7 @@ private fun CompassDial(
else -> 1.dp.toPx()
}
val angle = Math.toRadians(deg.toDouble())
val angle = (deg * PI / 180.0)
val outer = Offset(center.x + radius * sin(angle).toFloat(), center.y - radius * cos(angle).toFloat())
val inner =
Offset(
@ -310,7 +312,7 @@ private fun CompassDial(
)
for ((label, deg, color) in cardinals) {
val angle = Math.toRadians(deg.toDouble())
val angle = (deg * PI / 180.0)
val x = center.x + cardinalRadius * sin(angle).toFloat()
val y = center.y - cardinalRadius * cos(angle).toFloat()
@ -327,7 +329,7 @@ private fun CompassDial(
// Degree labels
val degRadius = radius * 0.72f
for (d in 0 until 360 step 30) {
val angle = Math.toRadians(d.toDouble())
val angle = (d * PI / 180.0)
val x = center.x + degRadius * sin(angle).toFloat()
val y = center.y - degRadius * cos(angle).toFloat()
@ -363,8 +365,8 @@ private fun CompassDial(
// Cone edge lines for clarity
val edgeRadius = arcRadius
val startRad = Math.toRadians(startAngleNorth.toDouble())
val endRad = Math.toRadians((startAngleNorth + sweep).toDouble())
val startRad = (startAngleNorth * PI / 180.0)
val endRad = ((startAngleNorth + sweep) * PI / 180.0)
val startEnd =
Offset(
center.x + edgeRadius * sin(startRad).toFloat(),
@ -376,7 +378,7 @@ private fun CompassDial(
drawLine(color = faint, start = center, end = endEnd, strokeWidth = 6f, cap = StrokeCap.Round)
}
if (bearingForDraw != null) {
val angle = Math.toRadians(bearingForDraw.toDouble())
val angle = (bearingForDraw * PI / 180.0)
val dot =
Offset(
center.x + (radius * 0.95f) * sin(angle).toFloat(),

View file

@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.Base64Factory
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.formatUptime
@ -261,7 +262,7 @@ private fun SignalRow(node: Node) {
if (node.snr != Float.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.snr),
value = "%.1f dB".format(node.snr),
value = formatString("%.1f dB", node.snr),
icon = MeshtasticIcons.ChannelUtilization,
modifier = Modifier.weight(1f),
)
@ -271,7 +272,7 @@ private fun SignalRow(node: Node) {
if (node.rssi != Int.MAX_VALUE) {
InfoItem(
label = stringResource(Res.string.rssi),
value = "%d dBm".format(node.rssi),
value = formatString("%d dBm", node.rssi),
icon = MeshtasticIcons.ChannelUtilization,
modifier = Modifier.weight(1f),
)

View file

@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.isUnmessageableRole
@ -256,14 +257,14 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
icon = MeshtasticIcons.ChannelUtilization,
contentDescription = stringResource(Res.string.channel_utilization),
label = stringResource(Res.string.channel_utilization),
text = "%.1f%%".format(thatNode.deviceMetrics.channel_utilization),
text = formatString("%.1f%%", thatNode.deviceMetrics.channel_utilization),
contentColor = contentColor,
)
IconInfo(
icon = MeshtasticIcons.AirUtilization,
contentDescription = stringResource(Res.string.air_utilization),
label = stringResource(Res.string.air_utilization),
text = "%.1f%%".format(thatNode.deviceMetrics.air_util_tx),
text = formatString("%.1f%%", thatNode.deviceMetrics.air_util_tx),
contentColor = contentColor,
)
}
@ -318,26 +319,28 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(env.temperature ?: 0f))
} else {
"%.1f°C".format(env.temperature ?: 0f)
formatString("%.1f°C", env.temperature ?: 0f)
}
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}
if ((env.relative_humidity ?: 0f) != 0f) {
items.add { HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor) }
items.add {
HumidityInfo(humidity = formatString("%.0f%%", env.relative_humidity ?: 0f), contentColor = contentColor)
}
}
if ((env.barometric_pressure ?: 0f) != 0f) {
items.add {
PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
PressureInfo(pressure = formatString("%.1fhPa", env.barometric_pressure ?: 0f), contentColor = contentColor)
}
}
if ((env.soil_temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
formatString("%.1f°F", celsiusToFahrenheit(env.soil_temperature ?: 0f))
} else {
"%.1f°C".format(env.soil_temperature ?: 0f)
formatString("%.1f°C", env.soil_temperature ?: 0f)
}
items.add { SoilTemperatureInfo(temp = temp, contentColor = contentColor) }
}
@ -347,7 +350,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.voltage ?: 0f) != 0f) {
items.add {
PowerInfo(
value = "%.2fV".format(env.voltage ?: 0f),
value = formatString("%.2fV", env.voltage ?: 0f),
label = stringResource(Res.string.voltage),
contentColor = contentColor,
)
@ -356,7 +359,7 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
if ((env.current ?: 0f) != 0f) {
items.add {
PowerInfo(
value = "%.1fmA".format(env.current ?: 0f),
value = formatString("%.1fmA", env.current ?: 0f),
label = stringResource(Res.string.current),
contentColor = contentColor,
)

View file

@ -18,7 +18,6 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -28,6 +27,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.RadioController
@ -60,7 +60,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
override val lastRequestNeighborTimes: StateFlow<Map<Int, Long>> = _lastRequestNeighborTimes.asStateFlow()
override fun requestUserInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting UserInfo for '$destNum'" }
radioController.requestUserInfo(destNum)
_effects.emit(
@ -72,7 +72,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestNeighborInfo(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting NeighborInfo for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestNeighborInfo(packetId, destNum)
@ -86,7 +86,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestPosition(scope: CoroutineScope, destNum: Int, longName: String, position: Position) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting position for '$destNum'" }
radioController.requestPosition(destNum, position)
_effects.emit(
@ -98,7 +98,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestTelemetry(scope: CoroutineScope, destNum: Int, longName: String, type: TelemetryType) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting telemetry for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTelemetry(packetId, destNum, type.ordinal)
@ -121,7 +121,7 @@ class CommonNodeRequestActions constructor(private val radioController: RadioCon
}
override fun requestTraceroute(scope: CoroutineScope, destNum: Int, longName: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Requesting traceroute for '$destNum'" }
val packetId = radioController.getPacketId()
radioController.requestTraceroute(packetId, destNum)

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
@Composable
expect fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier = Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit = {},
onNavigate: (Route) -> Unit = {},
onNavigateUp: () -> Unit = {},
compassViewModel: CompassViewModel? = null,
)

View file

@ -18,10 +18,10 @@ package org.meshtastic.feature.node.detail
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.getString
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.service.ServiceAction
@ -59,7 +59,7 @@ constructor(
}
open fun removeNode(scope: CoroutineScope, nodeNum: Int) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
Logger.i { "Removing node '$nodeNum'" }
val packetId = radioController.getPacketId()
radioController.removeByNodenum(packetId, nodeNum)
@ -80,7 +80,7 @@ constructor(
}
open fun ignoreNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) }
scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Ignore(node)) }
}
open fun requestMuteNode(scope: CoroutineScope, node: Node) {
@ -96,7 +96,7 @@ constructor(
}
open fun muteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) }
scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Mute(node)) }
}
open fun requestFavoriteNode(scope: CoroutineScope, node: Node) {
@ -115,11 +115,11 @@ constructor(
}
open fun favoriteNode(scope: CoroutineScope, node: Node) {
scope.launch(Dispatchers.IO) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) }
scope.launch(ioDispatcher) { serviceRepository.onServiceAction(ServiceAction.Favorite(node)) }
}
open fun setNodeNotes(scope: CoroutineScope, nodeNum: Int, notes: String) {
scope.launch(Dispatchers.IO) {
scope.launch(ioDispatcher) {
try {
nodeRepository.setNodeNotes(nodeNum, notes)
} catch (ex: Exception) {

View file

@ -42,7 +42,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.Flow
@ -59,7 +58,6 @@ import org.meshtastic.core.ui.component.MeshtasticImportFAB
import org.meshtastic.core.ui.component.ScrollToTopEvent
import org.meshtastic.core.ui.component.smartScrollToTop
import org.meshtastic.core.ui.qr.ScannedQrCodeDialog
import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.node.component.NodeContextMenu
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
@ -74,7 +72,7 @@ fun NodeListScreen(
scrollToTopEvents: Flow<ScrollToTopEvent>? = null,
activeNodeId: Int? = null,
) {
val context = LocalContext.current
val showToast = org.meshtastic.core.ui.util.rememberShowToastResource()
val scope = rememberCoroutineScope()
val state by viewModel.nodesUiState.collectAsStateWithLifecycle()
@ -125,9 +123,7 @@ fun NodeListScreen(
MeshtasticImportFAB(
sharedContact = sharedContact,
onImport = { uriString ->
viewModel.handleScannedUri(uriString) {
scope.launch { context.showToast(Res.string.channel_invalid) }
}
viewModel.handleScannedUri(uriString) { scope.launch { showToast(Res.string.channel_invalid) } }
},
onDismissSharedContact = { viewModel.setSharedContactRequested(null) },
isContactContext = true,

View file

@ -61,6 +61,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.rememberVicoScrollState
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
@ -256,11 +257,11 @@ private fun DeviceMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
batteryColor -> percentValueTemplate.format(batteryLabel, value)
voltageColor -> voltageValueTemplate.format(voltageLabel, value)
chUtilColor -> percentValueTemplate.format(channelUtilizationLabel, value)
airUtilColor -> percentValueTemplate.format(airUtilizationLabel, value)
else -> numericValueTemplate.format(value)
batteryColor -> formatString(percentValueTemplate, batteryLabel, value)
voltageColor -> formatString(voltageValueTemplate, voltageLabel, value)
chUtilColor -> formatString(percentValueTemplate, channelUtilizationLabel, value)
airUtilColor -> formatString(percentValueTemplate, airUtilizationLabel, value)
else -> formatString(numericValueTemplate, value)
}
},
)
@ -366,7 +367,7 @@ private fun DeviceMetricsChart(
if (leftLayer != null) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = batteryColor),
valueFormatter = { _, value, _ -> "%.0f%%".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f%%", value) },
)
} else {
null
@ -375,7 +376,7 @@ private fun DeviceMetricsChart(
if (rightLayer != null) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
)
} else {
null
@ -488,7 +489,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
Spacer(Modifier.width(4.dp))
Text(
text =
percentValueTemplate.format(
formatString(
percentValueTemplate,
channelUtilizationLabel,
deviceMetrics.channel_utilization ?: 0f,
),
@ -502,7 +504,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
Spacer(Modifier.width(4.dp))
Text(
text =
percentValueTemplate.format(
formatString(
percentValueTemplate,
airUtilizationLabel,
deviceMetrics.air_util_tx ?: 0f,
),
@ -513,7 +516,8 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
}
Text(
text =
labelValueTemplate.format(
formatString(
labelValueTemplate,
uptimeLabel,
formatUptime(deviceMetrics?.uptime_seconds ?: 0),
),

View file

@ -33,6 +33,7 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
@ -187,7 +188,7 @@ fun EnvironmentMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
val label = colorToLabel[color.copy(alpha = 1f)] ?: ""
"%s: %.1f".format(label, value)
formatString("%s: %.1f", label, value)
},
)
@ -229,7 +230,7 @@ fun EnvironmentMetricsChart(
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && pressureData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = Environment.BAROMETRIC_PRESSURE.color),
valueFormatter = { _, value, _ -> "%.0f hPa".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f hPa", value) },
)
} else {
null
@ -237,7 +238,7 @@ fun EnvironmentMetricsChart(
endAxis =
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = endAxisColor),
valueFormatter = { _, value, _ -> "%.0f".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f", value) },
),
bottomAxis =
HorizontalAxis.rememberBottom(

View file

@ -49,6 +49,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
@ -146,7 +147,7 @@ private fun TemperatureDisplay(
MetricIndicator(Environment.TEMPERATURE.color)
Spacer(Modifier.width(4.dp))
Text(
text = textFormat.format(stringResource(Res.string.temperature), temperature),
text = formatString(textFormat, stringResource(Res.string.temperature), temperature),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -171,7 +172,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
MetricIndicator(Environment.HUMIDITY.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.2f%%".format(stringResource(Res.string.humidity), humidity),
text = formatString("%s %.2f%%", stringResource(Res.string.humidity), humidity),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
modifier = Modifier.padding(vertical = 0.dp),
@ -184,7 +185,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.prot
MetricIndicator(Environment.BAROMETRIC_PRESSURE.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.2f hPa".format(pressure),
text = formatString("%.2f hPa", pressure),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
modifier = Modifier.padding(vertical = 0.dp),
@ -214,7 +215,8 @@ private fun SoilMetricsDisplay(
Spacer(Modifier.width(4.dp))
Text(
text =
soilMoistureTextFormat.format(
formatString(
soilMoistureTextFormat,
stringResource(Res.string.soil_moisture),
soilMoistureValue,
),
@ -231,7 +233,8 @@ private fun SoilMetricsDisplay(
Spacer(Modifier.width(4.dp))
Text(
text =
soilTemperatureTextFormat.format(
formatString(
soilTemperatureTextFormat,
stringResource(Res.string.soil_temperature),
soilTemperature,
),
@ -258,7 +261,7 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
MetricIndicator(Environment.LUX.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.0f lx".format(stringResource(Res.string.lux), luxValue),
text = formatString("%s %.0f lx", stringResource(Res.string.lux), luxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -270,7 +273,7 @@ private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
MetricIndicator(Environment.UV_LUX.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.0f UVlx".format(stringResource(Res.string.uv_lux), uvLuxValue),
text = formatString("%s %.0f UVlx", stringResource(Res.string.uv_lux), uvLuxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -290,7 +293,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
if (hasVoltage) {
val voltage = envMetrics.voltage!!
Text(
text = "%s %.2f V".format(stringResource(Res.string.voltage), voltage),
text = formatString("%s %.2f V", stringResource(Res.string.voltage), voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -298,7 +301,7 @@ private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
if (hasCurrent) {
val currentValue = envMetrics.current!!
Text(
text = "%s %.2f mA".format(stringResource(Res.string.current), currentValue),
text = formatString("%s %.2f mA", stringResource(Res.string.current), currentValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -332,7 +335,7 @@ private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMe
MetricIndicator(Environment.GAS_RESISTANCE.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%s %.2f Ohm".format(stringResource(Res.string.gas_resistance), gasResistance),
text = formatString("%s %.2f Ohm", stringResource(Res.string.gas_resistance), gasResistance),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -348,7 +351,7 @@ private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics
if (!radiation.isNaN() && radiation > 0f) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.2f µR/h".format(stringResource(Res.string.radiation), radiation),
text = formatString("%s %.2f µR/h", stringResource(Res.string.radiation), radiation),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)

View file

@ -43,6 +43,7 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.MeshtasticUri
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MeshLog
@ -341,9 +342,7 @@ open class MetricsViewModel(
val altitude = position.altitude
val satsInView = position.sats_in_view
val speed = position.ground_speed
// Kotlin string format is available in common code on 1.9.20+ via String.format,
// but we can just do basic string manipulation if needed.
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
val heading = formatString("%.2f", (position.ground_track ?: 0) * 1e-5)
sink.writeUtf8(
"$rxDateTime,\"$latitude\",\"$longitude\",\"$altitude\",\"$satsInView\",\"$speed\",\"$heading\"\n",

View file

@ -54,6 +54,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLa
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.formatUptime
@ -120,10 +121,10 @@ private fun PaxMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
bleColor -> "BLE: %.0f".format(value)
wifiColor -> "WiFi: %.0f".format(value)
paxColor -> "PAX: %.0f".format(value)
else -> "%.0f".format(value)
bleColor -> formatString("BLE: %.0f", value)
wifiColor -> formatString("WiFi: %.0f", value)
paxColor -> formatString("PAX: %.0f", value)
else -> formatString("%.0f", value)
}
},
)

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.util.metersIn
import org.meshtastic.core.model.util.toString
import org.meshtastic.core.resources.Res
@ -86,13 +87,13 @@ fun PositionItem(compactWidth: Boolean, position: Position, system: Config.Displ
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(formatString("%.5f", (position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(formatString("%.5f", (position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
PositionText(formatString("%.0f°", (position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.runtime.Composable
@Composable expect fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit)

View file

@ -62,6 +62,7 @@ import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.channel_1
@ -201,9 +202,9 @@ private fun PowerMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(alpha = 1f)) {
currentColor -> "Current: %.0f mA".format(value)
voltageColor -> "Voltage: %.1f V".format(value)
else -> "%.1f".format(value)
currentColor -> formatString("Current: %.0f mA", value)
voltageColor -> formatString("Voltage: %.1f V", value)
else -> formatString("%.1f", value)
}
},
)
@ -275,7 +276,7 @@ private fun PowerMetricsChart(
if (currentData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = currentColor),
valueFormatter = { _, value, _ -> "%.0f mA".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f mA", value) },
)
} else {
null
@ -284,7 +285,7 @@ private fun PowerMetricsChart(
if (voltageData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = voltageColor),
valueFormatter = { _, value, _ -> "%.1f V".format(value) },
valueFormatter = { _, value, _ -> formatString("%.1f V", value) },
)
} else {
null
@ -372,7 +373,7 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
MetricIndicator(PowerMetric.VOLTAGE.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.2fV".format(voltage),
text = formatString("%.2fV", voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -381,7 +382,7 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
MetricIndicator(PowerMetric.CURRENT.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.1fmA".format(current),
text = formatString("%.1fmA", current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)

View file

@ -56,6 +56,7 @@ import com.patrykandpatrick.vico.compose.cartesian.data.CartesianChartModelProdu
import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries
import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer
import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.rssi
@ -182,9 +183,9 @@ private fun SignalMetricsChart(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
if (color.copy(alpha = 1f) == rssiColor) {
"RSSI: %.0f dBm".format(value)
formatString("RSSI: %.0f dBm", value)
} else {
"SNR: %.1f dB".format(value)
formatString("SNR: %.1f dB", value)
}
},
)
@ -226,7 +227,7 @@ private fun SignalMetricsChart(
if (rssiData.isNotEmpty()) {
VerticalAxis.rememberStart(
label = ChartStyling.rememberAxisLabel(color = rssiColor),
valueFormatter = { _, value, _ -> "%.0f dBm".format(value) },
valueFormatter = { _, value, _ -> formatString("%.0f dBm", value) },
)
} else {
null
@ -235,7 +236,7 @@ private fun SignalMetricsChart(
if (snrData.isNotEmpty()) {
VerticalAxis.rememberEnd(
label = ChartStyling.rememberAxisLabel(color = snrColor),
valueFormatter = { _, value, _ -> "%.1f dB".format(value) },
valueFormatter = { _, value, _ -> formatString("%.1f dB", value) },
)
} else {
null
@ -296,14 +297,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
MetricIndicator(SignalMetric.RSSI.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.0f dBm".format(meshPacket.rx_rssi.toFloat()),
text = formatString("%.0f dBm", meshPacket.rx_rssi.toFloat()),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(12.dp))
MetricIndicator(SignalMetric.SNR.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.1f dB".format(meshPacket.rx_snr),
text = formatString("%.1f dB", meshPacket.rx_snr),
style = MaterialTheme.typography.labelLarge,
)
}

View file

@ -45,6 +45,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.model.fullRouteDiscovery
import org.meshtastic.core.model.getTracerouteResponse
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
@ -163,7 +164,8 @@ fun TracerouteLogScreen(
statusYellow = statusYellow,
statusOrange = statusOrange,
)
val durationText = stringResource(Res.string.traceroute_duration, "%.1f".format(seconds))
val durationText =
stringResource(Res.string.traceroute_duration, formatString("%.1f", seconds))
buildAnnotatedString {
append(annotatedBase)
append("\n\n$durationText")

View file

@ -16,7 +16,6 @@
*/
package org.meshtastic.feature.node.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
@ -30,12 +29,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalFocusManager
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ChannelsRoutes
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.nodes
import org.meshtastic.core.ui.component.EmptyDetailPlaceholder
@ -55,6 +58,7 @@ fun AdaptiveNodeListScreen(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
initialNodeId: Int? = null,
onNavigate: (Route) -> Unit = {},
onNavigateToMessages: (String) -> Unit = {},
) {
val nodeListViewModel: NodeListViewModel = koinViewModel()
@ -78,7 +82,13 @@ fun AdaptiveNodeListScreen(
}
}
BackHandler(enabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail) { handleBack() }
val navState = rememberNavigationEventState(NavigationEventInfo.None)
NavigationBackHandler(
state = navState,
isBackEnabled = navigator.currentDestination?.pane == ListDetailPaneScaffoldRole.Detail,
onBackCancelled = {},
onBackCompleted = { handleBack() },
)
LaunchedEffect(initialNodeId) {
if (initialNodeId != null) {
@ -134,7 +144,7 @@ fun AdaptiveNodeListScreen(
viewModel = nodeDetailViewModel,
compassViewModel = compassViewModel,
navigateToMessages = onNavigateToMessages,
onNavigate = { route -> backStack.add(route) },
onNavigate = onNavigate,
onNavigateUp = handleBack,
)
}

View file

@ -32,6 +32,7 @@ import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import org.jetbrains.compose.resources.StringResource
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -60,18 +61,19 @@ import org.meshtastic.feature.node.metrics.PositionLogScreen
import org.meshtastic.feature.node.metrics.PowerMetricsScreen
import org.meshtastic.feature.node.metrics.SignalMetricsScreen
import org.meshtastic.feature.node.metrics.TracerouteLogScreen
import org.meshtastic.feature.node.metrics.TracerouteMapScreen
import kotlin.reflect.KClass
@Suppress("LongMethod")
fun EntryProviderScope<NavKey>.nodesGraph(
backStack: NavBackStack<NavKey>,
scrollToTopEvents: Flow<ScrollToTopEvent>,
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit,
scrollToTopEvents: Flow<ScrollToTopEvent> = MutableSharedFlow(),
nodeMapScreen: @Composable (destNum: Int, onNavigateUp: () -> Unit) -> Unit = { _, _ -> },
) {
entry<NodesRoutes.NodesGraph> {
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@ -80,6 +82,7 @@ fun EntryProviderScope<NavKey>.nodesGraph(
AdaptiveNodeListScreen(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@ -98,6 +101,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@ -107,6 +111,7 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
backStack = backStack,
scrollToTopEvents = scrollToTopEvents,
initialNodeId = args.destNum,
onNavigate = { backStack.add(it) },
onNavigateToMessages = { backStack.add(ContactsRoutes.Messages(it)) },
)
}
@ -134,12 +139,8 @@ fun EntryProviderScope<NavKey>.nodeDetailGraph(
}
entry<NodeDetailRoutes.TracerouteMap> { args ->
val metricsViewModel =
koinViewModel<MetricsViewModel>(key = "metrics-${args.destNum}") { parametersOf(args.destNum) }
metricsViewModel.setNodeId(args.destNum)
TracerouteMapScreen(
metricsViewModel = metricsViewModel,
destNum = args.destNum,
requestId = args.requestId,
logUuid = args.logUuid,
onNavigateUp = { backStack.removeLastOrNull() },
@ -185,6 +186,9 @@ private inline fun <reified R : Route> EntryProviderScope<NavKey>.addNodeDetailS
}
}
/** Expect declaration for the platform-specific traceroute map screen. */
@Composable expect fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit)
enum class NodeDetailRoute(
val title: StringResource,
val routeClass: KClass<out Route>,

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
@Composable
actual fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel?,
) {
// TODO: Implement iOS node detail screen
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.runtime.Composable
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
// TODO: Implement iOS position log screen
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.navigation
import androidx.compose.runtime.Composable
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// TODO: Implement iOS traceroute map screen
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.detail
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
@Composable
actual fun NodeDetailScreen(
nodeId: Int,
modifier: Modifier,
viewModel: NodeDetailViewModel,
navigateToMessages: (String) -> Unit,
onNavigate: (Route) -> Unit,
onNavigateUp: () -> Unit,
compassViewModel: CompassViewModel?,
) {
LaunchedEffect(nodeId) { viewModel.start(nodeId) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Desktop just renders the NodeDetailContent directly. Overlays like Compass are no-ops.
NodeDetailContent(
uiState = uiState,
modifier = modifier,
onAction = { action ->
when (action) {
is NodeDetailAction.Navigate -> onNavigate(action.route)
is NodeDetailAction.TriggerServiceAction -> viewModel.onServiceAction(action.action)
is NodeDetailAction.HandleNodeMenuAction -> {
when (val menuAction = action.action) {
is NodeMenuAction.DirectMessage -> {
val route = viewModel.getDirectMessageRoute(menuAction.node, uiState.ourNode)
navigateToMessages(route)
}
is NodeMenuAction.Remove -> {
viewModel.handleNodeMenuAction(menuAction)
onNavigateUp()
}
else -> viewModel.handleNodeMenuAction(menuAction)
}
}
else -> {}
}
},
onFirmwareSelect = { /* No-op on desktop for now */ },
onSaveNotes = { num, notes -> viewModel.setNodeNotes(num, notes) },
)
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.metrics
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
actual fun PositionLogScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = "Position Log",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.navigation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
actual fun TracerouteMapScreen(destNum: Int, requestId: Int, logUuid: String?, onNavigateUp: () -> Unit) {
// Desktop placeholder for now
PlaceholderScreen(name = "Traceroute Map")
}
@Composable
internal fun PlaceholderScreen(name: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
text = name,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View file

@ -2,12 +2,10 @@
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CyclomaticComplexMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>CyclomaticComplexMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
<ID>LongMethod:AudioConfigItemList.kt$@Composable fun AudioConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:DetectionSensorConfigItemList.kt$@Composable fun DetectionSensorConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:LoRaConfigItemList.kt$@Composable fun LoRaConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:NetworkConfigItemList.kt$@Composable fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:PowerConfigItemList.kt$@Composable fun PowerConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$fun setResponseStateLoading(route: Enum&lt;*>)</ID>
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import org.meshtastic.core.navigation.SettingsRoutes
actual fun getAboutLibrariesJson(): String =
SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""

View file

@ -0,0 +1,62 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.settings.SettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.DeviceConfigScreen as AndroidDeviceConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen as AndroidExternalNotificationConfigScreen
import org.meshtastic.feature.settings.radio.component.PositionConfigScreen as AndroidPositionConfigScreen
import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen as AndroidSecurityConfigScreen
@Composable
actual fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
) {
SettingsScreen(
settingsViewModel = settingsViewModel,
viewModel = radioConfigViewModel,
onClickNodeChip = onClickNodeChip,
onNavigate = onNavigate,
)
}
@Composable
actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidDeviceConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidPositionConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidSecurityConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
AndroidExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack)
}

View file

@ -34,6 +34,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.model.MeshLog
@ -277,7 +278,7 @@ class DebugViewModel(
}
}
suspend fun loadLogsForExport(): ImmutableList<UiMeshLog> = withContext(Dispatchers.IO) {
suspend fun loadLogsForExport(): ImmutableList<UiMeshLog> = withContext(ioDispatcher) {
val unbounded = meshLogRepository.getAllLogsUnbounded().first()
val logs = if (unbounded.isEmpty()) meshLogRepository.getAllLogs().first() else unbounded
toUiState(logs)
@ -405,7 +406,7 @@ class DebugViewModel(
)
}
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) { meshLogRepository.deleteAll() }
fun deleteAllLogs() = viewModelScope.launch(ioDispatcher) { meshLogRepository.deleteAll() }
@Immutable
data class UiMeshLog(

View file

@ -0,0 +1,19 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
expect fun getAboutLibrariesJson(): String

View file

@ -14,8 +14,6 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("Wrapping", "SpacingAroundColon")
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
@ -33,7 +31,6 @@ 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.SettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.debugging.DebugScreen
import org.meshtastic.feature.settings.debugging.DebugViewModel
@ -48,19 +45,15 @@ 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.DeviceConfigScreen
import org.meshtastic.feature.settings.radio.component.DisplayConfigScreen
import org.meshtastic.feature.settings.radio.component.ExternalNotificationConfigScreen
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.NetworkConfigScreen
import org.meshtastic.feature.settings.radio.component.PaxcounterConfigScreen
import org.meshtastic.feature.settings.radio.component.PositionConfigScreen
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.SecurityConfigScreen
import org.meshtastic.feature.settings.radio.component.SerialConfigScreen
import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen
import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen
@ -70,9 +63,8 @@ import org.meshtastic.feature.settings.radio.component.TrafficManagementConfigSc
import org.meshtastic.feature.settings.radio.component.UserConfigScreen
import kotlin.reflect.KClass
@PublishedApi
@Composable
internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConfigViewModel {
val viewModel = koinViewModel<RadioConfigViewModel>()
LaunchedEffect(backStack) {
val destNum =
@ -88,23 +80,21 @@ internal fun getRadioConfigViewModel(backStack: NavBackStack<NavKey>): RadioConf
@Suppress("LongMethod", "CyclomaticComplexMethod")
fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
entry<SettingsRoutes.SettingsGraph> {
SettingsScreen(
settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
SettingsMainScreen(
settingsViewModel = koinViewModel(),
radioConfigViewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
backStack.add(it)
}
onNavigate = { backStack.add(it) },
)
}
entry<SettingsRoutes.Settings> {
SettingsScreen(
settingsViewModel = koinViewModel<SettingsViewModel>(),
viewModel = getRadioConfigViewModel(backStack),
SettingsMainScreen(
settingsViewModel = koinViewModel(),
radioConfigViewModel = getRadioConfigViewModel(backStack),
onClickNodeChip = { backStack.add(NodesRoutes.NodeDetailGraph(it)) },
) {
backStack.add(it)
}
onNavigate = { backStack.add(it) },
)
}
entry<SettingsRoutes.DeviceConfiguration> {
@ -192,10 +182,7 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
entry<SettingsRoutes.About> {
AboutScreen(
onNavigateUp = { backStack.removeLastOrNull() },
jsonProvider = { SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: "" },
)
AboutScreen(onNavigateUp = { backStack.removeLastOrNull() }, jsonProvider = { getAboutLibrariesJson() })
}
entry<SettingsRoutes.FilterSettings> {
@ -204,6 +191,24 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
}
}
/** Expect declaration for the platform-specific settings main screen. */
@Composable
expect fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
)
/** Expect declarations for platform-specific config screens. */
@Composable expect fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@Composable expect fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@Composable expect fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
@Composable expect fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit)
fun <R : Route> EntryProviderScope<NavKey>.configComposable(
route: KClass<R>,
backStack: NavBackStack<NavKey>,
@ -211,10 +216,3 @@ fun <R : Route> EntryProviderScope<NavKey>.configComposable(
) {
addEntryProvider(route) { content(getRadioConfigViewModel(backStack)) }
}
inline fun <reified R : Route> EntryProviderScope<NavKey>.configComposable(
backStack: NavBackStack<NavKey>,
noinline content: @Composable (RadioConfigViewModel) -> Unit,
) {
entry<R> { content(getRadioConfigViewModel(backStack)) }
}

View file

@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.meshtastic.core.common.util.formatString
import org.meshtastic.feature.settings.radio.ResponseState
private const val LOADING_OVERLAY_ALPHA = 0.8f
@ -72,7 +73,7 @@ fun LoadingOverlay(state: ResponseState<*>, modifier: Modifier = Modifier) {
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Text(
text = "%.0f%%".format(progress * PERCENTAGE_FACTOR),
text = formatString("%.0f%%", progress * PERCENTAGE_FACTOR),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)

View file

@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -31,19 +30,15 @@ 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.LocalContext
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.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.common.util.extractWifiCredentials
import org.meshtastic.core.model.util.handleMeshtasticUri
import org.meshtastic.core.model.util.toCommonUri
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.advanced
import org.meshtastic.core.resources.cancel
@ -56,7 +51,6 @@ 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.nfc_disabled
@ -81,7 +75,9 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.component.TitledCard
import org.meshtastic.core.ui.util.openNfcSettings
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
@ -89,12 +85,18 @@ import org.meshtastic.proto.Config
private fun ScanErrorDialog(onDismiss: () -> Unit = {}) =
MeshtasticDialog(titleRes = Res.string.error, messageRes = Res.string.wifi_qr_code_error, onDismiss = onDismiss)
@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}"
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit, onOpenNfcSettings: () -> Unit = {}) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val networkConfig = state.radioConfig.network ?: Config.NetworkConfig()
val formState = rememberConfigState(initialValue = networkConfig)
val context = LocalContext.current
var showScanErrorDialog: Boolean by rememberSaveable { mutableStateOf(false) }
if (showScanErrorDialog) {
@ -109,7 +111,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
message = stringResource(Res.string.nfc_disabled),
confirmText = stringResource(Res.string.open_settings),
onConfirm = {
context.openNfcSettings()
onOpenNfcSettings()
showNfcDisabledDialog = false
},
dismissText = stringResource(Res.string.cancel),
@ -120,7 +122,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
if (contents != null) {
val handled =
handleMeshtasticUri(
uri = contents.toUri().toCommonUri(),
uri = CommonUri.parse(contents),
onChannel = {}, // No-op, not supported in network config
onContact = {}, // No-op, not supported in network config
)
@ -136,8 +138,10 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
}
}
val barcodeScanner = rememberBarcodeScanner(onResult = onResult)
NfcScannerEffect(onResult = onResult, onNfcDisabled = { showNfcDisabledDialog = true })
val barcodeScanner = LocalBarcodeScannerProvider.current(onResult)
if (LocalNfcScannerSupported.current) {
LocalNfcScannerProvider.current(onResult) { showNfcDisabledDialog = true }
}
val focusManager = LocalFocusManager.current
@ -164,7 +168,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
if (wifiStatus.is_connected) {
ListItem(
text = stringResource(Res.string.wifi_ip),
supportingText = formatIpAddress(wifiStatus.ip_address),
supportingText = formatIpAddress(wifiStatus.ip_address ?: 0),
trailingIcon = null,
)
}
@ -173,7 +177,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
if (ethernetStatus.is_connected) {
ListItem(
text = stringResource(Res.string.ethernet_ip),
supportingText = formatIpAddress(ethernetStatus.ip_address),
supportingText = formatIpAddress(ethernetStatus.ip_address ?: 0),
trailingIcon = null,
)
}
@ -182,17 +186,17 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
}
}
}
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,
enabled = state.connected,
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
containerColor = CardDefaults.cardColors().containerColor,
)
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,
onCheckedChange = { formState.value = formState.value.copy(wifi_enabled = it) },
enabled = state.connected,
)
if (formState.value.wifi_enabled) {
HorizontalDivider()
EditTextPreference(
title = stringResource(Res.string.ssid),
@ -232,31 +236,12 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
title = stringResource(Res.string.ethernet_enabled),
summary = stringResource(Res.string.config_network_eth_enabled_summary),
checked = formState.value.eth_enabled,
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 == 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(
@ -264,7 +249,7 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
value = formState.value.ntp_server,
maxSize = 32, // ntp_server max_size:33
enabled = state.connected,
isError = formState.value.ntp_server.isEmpty(),
isError = false,
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
@ -283,57 +268,63 @@ fun NetworkConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
onValueChanged = { formState.value = formState.value.copy(rsyslog_server = it) },
)
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
SwitchPreference(
title = stringResource(Res.string.udp_enabled),
summary = stringResource(Res.string.config_network_udp_enabled_summary),
checked = formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC,
onCheckedChange = {
formState.value =
formState.value.copy(
address_mode =
if (it) {
Config.NetworkConfig.AddressMode.STATIC
} else {
Config.NetworkConfig.AddressMode.DHCP
},
)
},
enabled = state.connected,
items = Config.NetworkConfig.AddressMode.entries.map { it to it.name },
selectedItem = formState.value.address_mode,
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)) },
)
if (formState.value.address_mode == Config.NetworkConfig.AddressMode.STATIC) {
HorizontalDivider()
DropDownPreference(
title = stringResource(Res.string.ipv4_mode),
enabled = state.connected,
selectedItem = formState.value.address_mode,
onItemSelected = { formState.value = formState.value.copy(address_mode = it) },
itemLabel = { it.name },
)
HorizontalDivider()
val ipv4 = formState.value.ipv4_config ?: Config.NetworkConfig.IpV4Config()
EditIPv4Preference(
title = stringResource(Res.string.wifi_ip),
value = ipv4.ip,
enabled = state.connected,
onValueChanged = { formState.value = formState.value.copy(ipv4_config = ipv4.copy(ip = it)) },
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.gateway),
value = ipv4.gateway,
enabled = state.connected,
onValueChanged = {
formState.value = formState.value.copy(ipv4_config = ipv4.copy(gateway = it))
},
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
HorizontalDivider()
EditIPv4Preference(
title = stringResource(Res.string.subnet),
value = ipv4.subnet,
enabled = state.connected,
onValueChanged = {
formState.value = formState.value.copy(ipv4_config = ipv4.copy(subnet = it))
},
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
)
}
}
}
}
}
@Suppress("detekt:MagicNumber")
private fun formatIpAddress(ipAddress: Int): String = "${(ipAddress) and 0xFF}." +
"${(ipAddress shr 8) and 0xFF}." +
"${(ipAddress shr 16) and 0xFF}." +
"${(ipAddress shr 24) and 0xFF}"

View file

@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.close
@ -110,7 +111,7 @@ private fun LoadingContent(state: ResponseState.Loading, onComplete: () -> Unit)
val progress by animateFloatAsState(targetValue = clampedProgress, label = "progress")
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "%.0f%%".format(progress * 100f),
text = formatString("%.0f%%", progress * 100f),
style = MaterialTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.secondary,
)

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.debugging
import androidx.compose.runtime.Composable
@Composable
actual fun rememberLogExporter(logsProvider: suspend () -> List<DebugViewModel.UiMeshLog>): (fileName: String) -> Unit =
{ _ ->
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
actual fun getAboutLibrariesJson(): String {
return "" // TODO: Implement reading aboutlibraries.json on iOS
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
actual fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
) {
// TODO: Implement iOS settings main screen
}
@Composable
actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS device config screen
}
@Composable
actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS position config screen
}
@Composable
actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS security config screen
}
@Composable
actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
// TODO: Implement iOS external notification config screen
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,383 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.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.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()
}
}
}
},
)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import org.meshtastic.core.navigation.SettingsRoutes
actual fun getAboutLibrariesJson(): String =
SettingsRoutes::class.java.getResource("/aboutlibraries.json")?.readText() ?: ""

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.settings.navigation
import androidx.compose.runtime.Composable
import org.meshtastic.core.navigation.Route
import org.meshtastic.feature.settings.DesktopDeviceConfigScreen
import org.meshtastic.feature.settings.DesktopExternalNotificationConfigScreen
import org.meshtastic.feature.settings.DesktopPositionConfigScreen
import org.meshtastic.feature.settings.DesktopSecurityConfigScreen
import org.meshtastic.feature.settings.DesktopSettingsScreen
import org.meshtastic.feature.settings.SettingsViewModel
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
@Composable
actual fun SettingsMainScreen(
settingsViewModel: SettingsViewModel,
radioConfigViewModel: RadioConfigViewModel,
onClickNodeChip: (Int) -> Unit,
onNavigate: (Route) -> Unit,
) {
DesktopSettingsScreen(
settingsViewModel = settingsViewModel,
radioConfigViewModel = radioConfigViewModel,
onNavigate = onNavigate,
)
}
@Composable
actual fun DeviceConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopDeviceConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun PositionConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopPositionConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun SecurityConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopSecurityConfigScreen(viewModel = viewModel, onBack = onBack)
}
@Composable
actual fun ExternalNotificationConfigScreen(viewModel: RadioConfigViewModel, onBack: () -> Unit) {
DesktopExternalNotificationConfigScreen(viewModel = viewModel, onBack = onBack)
}