From 5edc2a8d57da32791f4a7fc16bfe7d06dde7295c Mon Sep 17 00:00:00 2001 From: Robert-0410 <62630290+Robert-0410@users.noreply.github.com> Date: Fri, 30 May 2025 17:50:45 -0700 Subject: [PATCH] refactor: channel qr clean (#1983) --- .../mesh/navigation/ChannelsGraph.kt | 67 +++++ .../geeksville/mesh/navigation/NavGraph.kt | 10 +- .../mesh/navigation/RadioConfigGraph.kt | 5 + .../mesh/ui/radioconfig/RadioConfig.kt | 6 +- .../com/geeksville/mesh/ui/sharing/Channel.kt | 284 ++++++++---------- app/src/main/res/values/strings.xml | 3 +- 6 files changed, 208 insertions(+), 167 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/navigation/ChannelsGraph.kt 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