diff --git a/app/src/main/java/com/geeksville/mesh/navigation/ChannelsGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsGraph.kt
new file mode 100644
index 000000000..2de3722ec
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/navigation/ChannelsGraph.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 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 .
+ */
+
+package com.geeksville.mesh.navigation
+
+import androidx.compose.runtime.remember
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.composable
+import androidx.navigation.navigation
+import com.geeksville.mesh.model.UIViewModel
+import com.geeksville.mesh.ui.sharing.ChannelScreen
+import com.geeksville.mesh.ui.radioconfig.components.ChannelConfigScreen
+import com.geeksville.mesh.ui.radioconfig.components.LoRaConfigScreen
+
+/**
+ * Navigation graph for for the top level ChannelScreen - [Route.Channels].
+ */
+fun NavGraphBuilder.channelsGraph(navController: NavHostController, uiViewModel: UIViewModel) {
+ navigation(
+ startDestination = Route.Channels,
+ ) {
+ composable { backStackEntry ->
+ val parentEntry = remember(backStackEntry) {
+ navController.getBackStackEntry()
+ }
+ ChannelScreen(
+ viewModel = uiViewModel,
+ radioConfigViewModel = hiltViewModel(parentEntry),
+ onNavigate = { route -> navController.navigate(route) }
+ )
+ }
+ configRoutes(navController)
+ }
+}
+
+private fun NavGraphBuilder.configRoutes(
+ navController: NavHostController,
+) {
+ ConfigRoute.entries.forEach { configRoute ->
+ composable(configRoute.route::class) { backStackEntry ->
+ val parentEntry = remember(backStackEntry) {
+ navController.getBackStackEntry()
+ }
+ when (configRoute) {
+ ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry))
+ ConfigRoute.LORA -> LoRaConfigScreen(hiltViewModel(parentEntry))
+ else -> Unit
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt
index 48f17caee..89c1770f9 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt
@@ -39,7 +39,6 @@ import com.geeksville.mesh.ui.map.MapView
import com.geeksville.mesh.ui.message.MessageScreen
import com.geeksville.mesh.ui.message.QuickChatScreen
import com.geeksville.mesh.ui.node.NodeScreen
-import com.geeksville.mesh.ui.sharing.ChannelScreen
import com.geeksville.mesh.ui.sharing.ShareScreen
import kotlinx.serialization.Serializable
@@ -54,6 +53,9 @@ const val DEEP_LINK_BASE_URI = "meshtastic://meshtastic"
@Serializable
sealed interface Graph : Route {
+ @Serializable
+ data class ChannelsGraph(val destNum: Int?)
+
@Serializable
data class NodeDetailGraph(val destNum: Int) : Graph
@@ -241,9 +243,9 @@ fun NavGraph(
composable {
MapView(uIViewModel)
}
- composable {
- ChannelScreen(uIViewModel)
- }
+
+ channelsGraph(navController, uIViewModel)
+
composable(
deepLinks = listOf(
navDeepLink {
diff --git a/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigGraph.kt
index 2346d4073..5402c2297 100644
--- a/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigGraph.kt
+++ b/app/src/main/java/com/geeksville/mesh/navigation/RadioConfigGraph.kt
@@ -77,6 +77,11 @@ import com.geeksville.mesh.ui.radioconfig.components.StoreForwardConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.TelemetryConfigScreen
import com.geeksville.mesh.ui.radioconfig.components.UserConfigScreen
+fun getNavRouteFrom(routeName: String): Route? {
+ return ConfigRoute.entries.find { it.name == routeName }?.route
+ ?: ModuleRoute.entries.find { it.name == routeName }?.route
+}
+
fun NavGraphBuilder.radioConfigGraph(navController: NavHostController, uiViewModel: UIViewModel) {
navigation(
startDestination = Route.RadioConfig(),
diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt
index 2c5faf836..b391ec888 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfig.kt
@@ -68,16 +68,12 @@ import com.geeksville.mesh.navigation.AdminRoute
import com.geeksville.mesh.navigation.ConfigRoute
import com.geeksville.mesh.navigation.ModuleRoute
import com.geeksville.mesh.navigation.Route
+import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.ui.common.components.PreferenceCategory
import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.radioconfig.components.EditDeviceProfileDialog
import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog
-private fun getNavRouteFrom(routeName: String): Route? {
- return ConfigRoute.entries.find { it.name == routeName }?.route
- ?: ModuleRoute.entries.find { it.name == routeName }?.route
-}
-
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun RadioConfigScreen(
diff --git a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
index 8a4703697..4ce002ddc 100644
--- a/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
+++ b/app/src/main/java/com/geeksville/mesh/ui/sharing/Channel.kt
@@ -23,14 +23,22 @@ import android.os.RemoteException
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.twotone.Check
import androidx.compose.material.icons.twotone.Close
import androidx.compose.material.icons.twotone.ContentCopy
@@ -56,6 +64,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
@@ -64,13 +73,14 @@ import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
-import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -85,24 +95,21 @@ import com.geeksville.mesh.android.GeeksvilleApplication
import com.geeksville.mesh.android.getCameraPermissions
import com.geeksville.mesh.android.hasCameraPermission
import com.geeksville.mesh.channelSet
-import com.geeksville.mesh.channelSettings
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Channel
-import com.geeksville.mesh.model.ChannelOption
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.model.getChannelUrl
import com.geeksville.mesh.model.qrCode
import com.geeksville.mesh.model.toChannelSet
+import com.geeksville.mesh.navigation.ConfigRoute
+import com.geeksville.mesh.navigation.Route
+import com.geeksville.mesh.navigation.getNavRouteFrom
import com.geeksville.mesh.service.MeshService
+import com.geeksville.mesh.ui.radioconfig.RadioConfigViewModel
import com.geeksville.mesh.ui.common.components.AdaptiveTwoPane
-import com.geeksville.mesh.ui.common.components.DropDownPreference
import com.geeksville.mesh.ui.common.components.PreferenceFooter
-import com.geeksville.mesh.ui.common.components.dragContainer
-import com.geeksville.mesh.ui.common.components.dragDropItemsIndexed
-import com.geeksville.mesh.ui.common.components.rememberDragDropState
-import com.geeksville.mesh.ui.radioconfig.components.ChannelCard
import com.geeksville.mesh.ui.radioconfig.components.ChannelSelection
-import com.geeksville.mesh.ui.radioconfig.components.EditChannelDialog
+import com.geeksville.mesh.ui.radioconfig.components.PacketResponseStateDialog
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import kotlinx.coroutines.launch
@@ -111,20 +118,44 @@ import kotlinx.coroutines.launch
@Composable
fun ChannelScreen(
viewModel: UIViewModel = hiltViewModel(),
+ radioConfigViewModel: RadioConfigViewModel = hiltViewModel(),
+ onNavigate: (Route) -> Unit
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
+ val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
+
val enabled = connectionState == MeshService.ConnectionState.CONNECTED && !viewModel.isManaged
val channels by viewModel.channels.collectAsStateWithLifecycle()
var channelSet by remember(channels) { mutableStateOf(channels) }
- var showChannelEditor by rememberSaveable { mutableStateOf(false) }
- var showSendDialog by remember { mutableStateOf(false) }
+ val modemPresetName by remember(channels) {
+ mutableStateOf(Channel(loraConfig = channels.loraConfig).name)
+ }
+
var showResetDialog by remember { mutableStateOf(false) }
var showScanDialog by remember { mutableStateOf(false) }
- val isEditing = channelSet != channels || showChannelEditor
+
+ /* Animate waiting for the channel configurations */
+ var isWaiting by remember { mutableStateOf(false) }
+ if (isWaiting) {
+ PacketResponseStateDialog(
+ state = radioConfigState.responseState,
+ onDismiss = {
+ isWaiting = false
+ radioConfigViewModel.clearPacketResponse()
+ },
+ onComplete = {
+ getNavRouteFrom(radioConfigState.route)?.let { route ->
+ isWaiting = false
+ radioConfigViewModel.clearPacketResponse()
+ onNavigate(route)
+ }
+ },
+ )
+ }
/* Holds selections made by the user for QR generation. */
val channelSelections = rememberSaveable(
@@ -139,7 +170,6 @@ fun ChannelScreen(
settings.clear()
settings.addAll(result)
}
- val modemPresetName = Channel(loraConfig = channelSet.loraConfig).name
val barcodeLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
if (result.contents != null) {
@@ -147,19 +177,6 @@ fun ChannelScreen(
}
}
- fun updateSettingsList(update: MutableList.() -> Unit) {
- try {
- val list = channelSet.settingsList.toMutableList()
- list.update()
- channelSet = channelSet.copy {
- settings.clear()
- settings.addAll(list)
- }
- } catch (ex: Exception) {
- errormsg("Error updating ChannelSettings list:", ex)
- }
- }
-
fun zxingScan() {
debug("Starting zxing QR code scanner")
val zxingScan = ScanOptions()
@@ -209,8 +226,6 @@ fun ChannelScreen(
// Tell the user to try again
viewModel.showSnackbar(R.string.cant_change_no_radio)
- } finally {
- showChannelEditor = false
}
}
@@ -255,146 +270,51 @@ fun ChannelScreen(
)
}
- if (showSendDialog) {
- AlertDialog(
- onDismissRequest = {
- showSendDialog = false
- showChannelEditor = false
- channelSet = channels
- },
- title = { Text(text = stringResource(id = R.string.change_channel)) },
- text = { Text(text = stringResource(id = R.string.are_you_sure_channel)) },
- confirmButton = {
- TextButton(onClick = {
- installSettings(channelSet)
- showSendDialog = false
- }) { Text(text = stringResource(id = R.string.accept)) }
- installSettings(channelSet)
- }
- )
- }
-
- var showEditChannelDialog: Int? by remember { mutableStateOf(null) }
-
- if (showEditChannelDialog != null) {
- val index = showEditChannelDialog ?: return
- EditChannelDialog(
- channelSettings = with(channelSet) {
- if (settingsCount > index) getSettings(index) else channelSettings { }
- },
- modemPresetName = modemPresetName,
- onAddClick = {
- with(channelSet) {
- if (settingsCount > index) {
- channelSet = copy { settings[index] = it }
- } else {
- channelSet = copy { settings.add(it) }
- }
- }
- showEditChannelDialog = null
- },
- onDismissRequest = { showEditChannelDialog = null }
- )
- }
-
val listState = rememberLazyListState()
- val dragDropState = rememberDragDropState(listState) { fromIndex, toIndex ->
- updateSettingsList { add(toIndex, removeAt(fromIndex)) }
- }
-
LazyColumn(
- modifier = Modifier.dragContainer(
- dragDropState = dragDropState,
- haptics = LocalHapticFeedback.current,
- ),
state = listState,
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
) {
- if (!showChannelEditor) {
- item {
- ChannelListView(
- enabled = enabled,
- channelSet = channelSet,
- modemPresetName = modemPresetName,
- channelSelections = channelSelections,
- onClick = { showChannelEditor = true }
- )
- EditChannelUrl(
- enabled = enabled,
- channelUrl = selectedChannelSet.getChannelUrl(),
- onConfirm = viewModel::requestChannelUrl
- )
- }
- } else {
- dragDropItemsIndexed(
- items = channelSet.settingsList,
- dragDropState = dragDropState,
- ) { index, channel, isDragging ->
- ChannelCard(
- index = index,
- title = channel.name.ifEmpty { modemPresetName },
- enabled = enabled,
- onEditClick = { showEditChannelDialog = index },
- onDeleteClick = { updateSettingsList { removeAt(index) } }
- )
- }
- item {
- OutlinedButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- channelSet = channelSet.copy {
- settings.add(channelSettings { psk = Channel.default.settings.psk })
- }
- showEditChannelDialog = channelSet.settingsList.lastIndex
- },
- enabled = enabled && viewModel.maxChannels > channelSet.settingsCount,
- ) { Text(text = stringResource(R.string.add)) }
- }
- }
-
item {
- DropDownPreference(
- title = stringResource(id = R.string.channel_options),
+ ChannelListView(
enabled = enabled,
- items = ChannelOption.entries
- .map { it.modemPreset to stringResource(it.configRes) },
- selectedItem = channelSet.loraConfig.modemPreset,
- onItemSelected = {
- val lora = channelSet.loraConfig.copy { modemPreset = it }
- channelSet = channelSet.copy { loraConfig = lora }
+ channelSet = channelSet,
+ modemPresetName = modemPresetName,
+ channelSelections = channelSelections,
+ onClick = {
+ isWaiting = true
+ radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
}
)
+ EditChannelUrl(
+ enabled = enabled,
+ channelUrl = selectedChannelSet.getChannelUrl(),
+ onConfirm = viewModel::requestChannelUrl
+ )
}
-
item {
- if (isEditing) {
- PreferenceFooter(
- enabled = enabled,
- onCancelClicked = {
- focusManager.clearFocus()
- showChannelEditor = false
- channelSet = channels
- },
- onSaveClicked = {
- focusManager.clearFocus()
- showSendDialog = true
- }
- )
- } else {
- PreferenceFooter(
- enabled = enabled,
- negativeText = R.string.reset,
- onNegativeClicked = {
- focusManager.clearFocus()
- showResetDialog = true
- },
- positiveText = R.string.scan,
- onPositiveClicked = {
- focusManager.clearFocus()
- if (context.hasCameraPermission()) zxingScan() else showScanDialog = true
- }
- )
- }
+ ModemPresetInfo(
+ modemPresetName = modemPresetName,
+ onClick = {
+ isWaiting = true
+ radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
+ }
+ )
+ }
+ item {
+ PreferenceFooter(
+ enabled = enabled,
+ negativeText = R.string.reset,
+ onNegativeClicked = {
+ focusManager.clearFocus()
+ showResetDialog = true
+ },
+ positiveText = R.string.scan,
+ onPositiveClicked = {
+ focusManager.clearFocus()
+ if (context.hasCameraPermission()) zxingScan() else showScanDialog = true
+ }
+ )
}
}
}
@@ -433,6 +353,7 @@ private fun EditChannelUrl(
enabled = enabled,
label = { Text(stringResource(R.string.url)) },
isError = isError,
+ shape = RoundedCornerShape(8.dp),
trailingIcon = {
val label = stringResource(R.string.url)
val isUrlEqual = valueState == channelUrl
@@ -560,6 +481,55 @@ private fun ChannelListView(
)
}
+@Composable
+private fun ModemPresetInfo(
+ modemPresetName: String,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .padding(top = 12.dp)
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .border(
+ 1.dp,
+ MaterialTheme.colorScheme.onBackground,
+ RoundedCornerShape(8.dp)
+ ),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(16.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.modem_preset),
+ fontSize = 16.sp,
+ )
+ Text(
+ text = modemPresetName,
+ fontSize = 14.sp,
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Icon(
+ imageVector = Icons.Default.ChevronRight,
+ contentDescription = stringResource(R.string.navigate_into_label),
+ modifier = Modifier.padding(end = 16.dp)
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+fun ModemPresetInfoPreview() {
+ ModemPresetInfo(
+ modemPresetName = "Long Fast",
+ onClick = {}
+ )
+}
+
@PreviewScreenSizes
@Composable
private fun ChannelScreenPreview() {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b981d6283..bd1e3f6f3 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -481,7 +481,7 @@
Use I2S as buzzer
LoRa Config
Use modem preset
- Modem preset
+ Modem Preset
Bandwidth
Spread factor
Coding rate
@@ -655,6 +655,7 @@
Disk Free
Load
User String
+ Navigate Into
Connections
Map
Contacts