refactor: channel qr clean (#1983)

pull/1988/head
Robert-0410 2025-05-30 17:50:45 -07:00 zatwierdzone przez GitHub
rodzic 7dc2147169
commit 5edc2a8d57
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
6 zmienionych plików z 208 dodań i 167 usunięć

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Graph.ChannelsGraph>(
startDestination = Route.Channels,
) {
composable<Route.Channels> { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.ChannelsGraph>()
}
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<Graph.ChannelsGraph>()
}
when (configRoute) {
ConfigRoute.CHANNELS -> ChannelConfigScreen(hiltViewModel(parentEntry))
ConfigRoute.LORA -> LoRaConfigScreen(hiltViewModel(parentEntry))
else -> Unit
}
}
}
}

Wyświetl plik

@ -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<Route.Map> {
MapView(uIViewModel)
}
composable<Route.Channels> {
ChannelScreen(uIViewModel)
}
channelsGraph(navController, uIViewModel)
composable<Route.Connections>(
deepLinks = listOf(
navDeepLink {

Wyświetl plik

@ -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<Graph.RadioConfigGraph>(
startDestination = Route.RadioConfig(),

Wyświetl plik

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

Wyświetl plik

@ -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<ChannelProtos.ChannelSettings>.() -> 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() {

Wyświetl plik

@ -481,7 +481,7 @@
<string name="use_i2s_as_buzzer">Use I2S as buzzer</string>
<string name="lora_config">LoRa Config</string>
<string name="use_modem_preset">Use modem preset</string>
<string name="modem_preset">Modem preset</string>
<string name="modem_preset">Modem Preset</string>
<string name="bandwidth">Bandwidth</string>
<string name="spread_factor">Spread factor</string>
<string name="coding_rate">Coding rate</string>
@ -655,6 +655,7 @@
<string name="disk_free">Disk Free</string>
<string name="load">Load</string>
<string name="user_string">User String</string>
<string name="navigate_into_label">Navigate Into</string>
<string name="connections">Connections</string>
<string name="map">Map</string>
<string name="contacts">Contacts</string>