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
|
|
@ -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