mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Implement iOS support and unify Compose Multiplatform infrastructure (#4876)
This commit is contained in:
parent
f04924ded5
commit
d136b162a4
170 changed files with 2208 additions and 2432 deletions
|
|
@ -18,8 +18,6 @@
|
|||
plugins { alias(libs.plugins.meshtastic.kmp.feature) }
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.connections"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)) },
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -21,8 +21,6 @@ plugins {
|
|||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.intro"
|
||||
|
|
|
|||
|
|
@ -20,8 +20,6 @@ plugins {
|
|||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.map"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
@ -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,
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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() },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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>,
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<*>)</ID>
|
||||
<ID>LongMethod:RadioConfigViewModel.kt$RadioConfigViewModel$private fun processPacketResponse(packet: MeshPacket)</ID>
|
||||
|
|
|
|||
|
|
@ -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() ?: ""
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)) }
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
{ _ ->
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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() ?: ""
|
||||
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue