From 08ced486529d87b4e4b8b324478830566fabcd3f Mon Sep 17 00:00:00 2001 From: Phil Oliver <3497406+poliver@users.noreply.github.com> Date: Fri, 5 Sep 2025 15:51:09 -0400 Subject: [PATCH] Move remaining 3-dot menu items to Settings (#2985) --- .../java/com/geeksville/mesh/MainActivity.kt | 31 +------- .../java/com/geeksville/mesh/model/UIState.kt | 2 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 31 +++----- .../mesh/ui/common/components/MainAppBar.kt | 71 +++++------------- .../mesh/ui/connections/Connections.kt | 14 ++-- .../mesh/ui/settings/SettingsScreen.kt | 73 ++++++++++++++++++- .../mesh/ui/settings/radio/RadioConfig.kt | 34 --------- app/src/main/res/values/strings.xml | 4 +- 8 files changed, 111 insertions(+), 149 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index ecb15d6fc..2c54c6fc0 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -28,7 +28,6 @@ import android.os.Bundle import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate @@ -47,7 +46,6 @@ import com.geeksville.mesh.model.BluetoothViewModel import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.navigation.DEEP_LINK_BASE_URI import com.geeksville.mesh.ui.MainScreen -import com.geeksville.mesh.ui.common.components.MainMenuAction import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC import com.geeksville.mesh.ui.intro.AppIntroductionScreen @@ -117,11 +115,7 @@ class MainActivity : }, ) } else { - MainScreen( - uIViewModel = model, - bluetoothViewModel = bluetoothViewModel, - onAction = ::onMainMenuAction, - ) + MainScreen(uIViewModel = model, bluetoothViewModel = bluetoothViewModel) } } } @@ -204,30 +198,7 @@ class MainActivity : return resultPendingIntent!! } - private val createRangetestLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - it.data?.data?.let { file_uri -> model.saveRangetestCSV(file_uri) } - } - } - private fun showSettingsPage() { createSettingsIntent().send() } - - private fun onMainMenuAction(action: MainMenuAction) { - when (action) { - MainMenuAction.EXPORT_RANGETEST -> { - val intent = - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "application/csv" - putExtra(Intent.EXTRA_TITLE, "rangetest.csv") - } - createRangetestLauncher.launch(intent) - } - - else -> warn("Unexpected action: $action") - } - } } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index dfed41f21..4f677308f 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -847,7 +847,7 @@ constructor( /** Write the persisted packet data out to a CSV file in the specified location. */ @Suppress("detekt:CyclomaticComplexMethod", "detekt:LongMethod") - fun saveRangetestCSV(uri: Uri) { + fun saveRangeTestCsv(uri: Uri) { viewModelScope.launch(Dispatchers.Main) { // Extract distances to this device from position messages and put (node,SNR,distance) // in diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 3abb9431d..3e8e0f0e8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -94,7 +94,6 @@ import com.geeksville.mesh.repository.radio.MeshActivity import com.geeksville.mesh.service.ConnectionState import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.ui.common.components.MainAppBar -import com.geeksville.mesh.ui.common.components.MainMenuAction import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog import com.geeksville.mesh.ui.common.components.SimpleAlertDialog @@ -146,7 +145,6 @@ fun MainScreen( uIViewModel: UIViewModel = hiltViewModel(), bluetoothViewModel: BluetoothViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel(), - onAction: (MainMenuAction) -> Unit, ) { val navController = rememberNavController() val connectionState by uIViewModel.connectionState.collectAsStateWithLifecycle() @@ -349,26 +347,19 @@ fun MainScreen( viewModel = uIViewModel, navController = navController, onAction = { action -> - if (action is MainMenuAction) { - when (action) { - MainMenuAction.QUICK_CHAT -> navController.navigate(ContactsRoutes.QuickChat) - else -> onAction(action) + when (action) { + is NodeMenuAction.MoreDetails -> { + navController.navigate( + NodesRoutes.NodeDetailGraph(action.node.num), + { + launchSingleTop = true + restoreState = true + }, + ) } - } else if (action is NodeMenuAction) { - when (action) { - is NodeMenuAction.MoreDetails -> { - navController.navigate( - NodesRoutes.NodeDetailGraph(action.node.num), - { - launchSingleTop = true - restoreState = true - }, - ) - } - is NodeMenuAction.Share -> sharedContact = action.node - else -> {} - } + is NodeMenuAction.Share -> sharedContact = action.node + else -> {} } }, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt index f3ae7fd26..35030ddc0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/MainAppBar.kt @@ -17,27 +17,21 @@ package com.geeksville.mesh.ui.common.components -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource @@ -45,6 +39,7 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination.Companion.hasRoute @@ -61,7 +56,7 @@ import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.debug.DebugMenuActions import com.geeksville.mesh.ui.node.components.NodeChip -import com.geeksville.mesh.ui.settings.radio.RadioConfigMenuActions +import com.geeksville.mesh.ui.node.components.NodeMenuAction @Suppress("CyclomaticComplexMethod") @Composable @@ -69,7 +64,7 @@ fun MainAppBar( modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel(), navController: NavHostController, - onAction: (Any?) -> Unit, + onAction: (NodeMenuAction) -> Unit, ) { val backStackEntry by navController.currentBackStackEntryAsState() val currentDestination = backStackEntry?.destination @@ -117,13 +112,7 @@ fun MainAppBar( actions = { currentDestination?.let { when { - it.isTopLevel() -> MainMenuActions(onAction) - currentDestination.hasRoute() -> DebugMenuActions() - - currentDestination.hasRoute() -> - RadioConfigMenuActions(viewModel = viewModel) - else -> {} } } @@ -144,7 +133,7 @@ private fun MainAppBar( canNavigateUp: Boolean, onNavigateUp: () -> Unit, actions: @Composable () -> Unit, - onAction: (Any?) -> Unit, + onAction: (NodeMenuAction) -> Unit, ) { TopAppBar( title = { @@ -195,43 +184,21 @@ private fun TopBarActions( isConnected: Boolean, showNodeChip: Boolean, actions: @Composable () -> Unit, - onAction: (Any?) -> Unit, + onAction: (NodeMenuAction) -> Unit, ) { - AnimatedVisibility(showNodeChip) { - ourNode?.let { NodeChip(node = it, isThisNode = true, isConnected = isConnected, onAction = onAction) } - } - - actions() -} - -enum class MainMenuAction(@StringRes val stringRes: Int) { - EXPORT_RANGETEST(R.string.save_rangetest), - QUICK_CHAT(R.string.quick_chat), -} - -@Composable -private fun MainMenuActions(onAction: (MainMenuAction) -> Unit) { - var showMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showMenu = true }) { - Icon(imageVector = Icons.Default.MoreVert, contentDescription = stringResource(R.string.overflow_menu)) - } - - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - modifier = Modifier.background(colorScheme.background.copy(alpha = 1f)), - ) { - MainMenuAction.entries.forEach { action -> - DropdownMenuItem( - text = { Text(stringResource(id = action.stringRes)) }, - onClick = { - onAction(action) - showMenu = false - }, - enabled = true, + AnimatedVisibility(visible = showNodeChip, enter = fadeIn(), exit = fadeOut()) { + ourNode?.let { + NodeChip( + modifier = Modifier.padding(horizontal = 16.dp), + node = it, + isThisNode = true, + isConnected = isConnected, + onAction = onAction, ) } } + + actions() } @PreviewLightDark @@ -246,7 +213,7 @@ private fun MainAppBarPreview(@PreviewParameter(BooleanProvider::class) canNavig showNodeChip = true, canNavigateUp = canNavigateUp, onNavigateUp = {}, - actions = { MainMenuActions(onAction = {}) }, + actions = {}, ) {} } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index 20a8f3ac6..30a9d6815 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -25,7 +25,6 @@ import android.net.InetAddresses import android.os.Build import android.util.Patterns import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize @@ -77,7 +76,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.ConfigProtos import com.geeksville.mesh.R import com.geeksville.mesh.android.gpsDisabled @@ -472,14 +470,12 @@ fun ConnectionsScreen( } Box(modifier = Modifier.fillMaxWidth().padding(8.dp)) { - Row( + Text( + text = scanStatusText.orEmpty(), modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = BuildConfig.VERSION_NAME, fontSize = 10.sp, textAlign = TextAlign.Start) - Text(text = scanStatusText.orEmpty(), fontSize = 10.sp, textAlign = TextAlign.End) - } + fontSize = 10.sp, + textAlign = TextAlign.End, + ) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt index 096889890..eede0aafc 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/SettingsScreen.kt @@ -19,8 +19,10 @@ package com.geeksville.mesh.ui.settings import android.app.Activity import android.content.Intent +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -30,9 +32,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.rounded.FormatPaint import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Memory +import androidx.compose.material.icons.rounded.Output import androidx.compose.material.icons.rounded.WavingHand import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -42,8 +48,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile -import com.geeksville.mesh.DeviceUIProtos.Language import com.geeksville.mesh.R import com.geeksville.mesh.android.BuildUtils.debug import com.geeksville.mesh.model.UIViewModel @@ -52,12 +58,15 @@ import com.geeksville.mesh.navigation.getNavRouteFrom import com.geeksville.mesh.ui.common.components.TitledCard import com.geeksville.mesh.ui.common.theme.MODE_DYNAMIC import com.geeksville.mesh.ui.settings.components.SettingsItem +import com.geeksville.mesh.ui.settings.components.SettingsItemDetail import com.geeksville.mesh.ui.settings.components.SettingsItemSwitch import com.geeksville.mesh.ui.settings.radio.RadioConfigItemList import com.geeksville.mesh.ui.settings.radio.RadioConfigViewModel import com.geeksville.mesh.ui.settings.radio.components.EditDeviceProfileDialog import com.geeksville.mesh.ui.settings.radio.components.PacketResponseStateDialog import com.geeksville.mesh.util.LanguageUtils +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -166,7 +175,7 @@ fun SettingsScreen( onNavigate = onNavigate, ) - TitledCard(title = stringResource(R.string.phone_settings), modifier = Modifier.padding(top = 16.dp)) { + TitledCard(title = stringResource(R.string.app_settings), modifier = Modifier.padding(top = 16.dp)) { if (state.analyticsAvailable) { SettingsItemSwitch( text = stringResource(R.string.analytics_okay), @@ -214,6 +223,26 @@ fun SettingsScreen( ) } + val exportRangeTestLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + it.data?.data?.let { uri -> uiViewModel.saveRangeTestCsv(uri) } + } + } + SettingsItem( + text = stringResource(R.string.save_rangetest), + leadingIcon = Icons.Rounded.Output, + trailingIcon = null, + ) { + val intent = + Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/csv" + putExtra(Intent.EXTRA_TITLE, "rangetest.csv") + } + exportRangeTestLauncher.launch(intent) + } + SettingsItem( text = stringResource(R.string.intro_show), leadingIcon = Icons.Rounded.WavingHand, @@ -221,6 +250,46 @@ fun SettingsScreen( ) { uiViewModel.showAppIntro() } + + AppVersionButton(excludedModulesUnlocked) { uiViewModel.unlockExcludedModules() } + } + } +} + +private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules. +private const val UNLOCKED_CLICK_COUNT = 3 // Number of clicks before we toast that modules are already unlocked. +private const val UNLOCK_TIMEOUT_SECONDS = 1 // Timeout in seconds to reset the click counter. + +/** A button to display the app version. Clicking it 5 times will unlock the excluded modules. */ +@Composable +private fun AppVersionButton(excludedModulesUnlocked: Boolean, onUnlockExcludedModules: () -> Unit) { + val context = LocalContext.current + var clickCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(clickCount) { + if (clickCount in 1.. { + clickCount = 0 + Toast.makeText(context, context.getString(R.string.modules_already_unlocked), Toast.LENGTH_LONG).show() + } + clickCount == UNLOCK_CLICK_COUNT -> { + clickCount = 0 + onUnlockExcludedModules() + Toast.makeText(context, context.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show() + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt index e61ebe5b6..2cf523b5d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/settings/radio/RadioConfig.kt @@ -17,7 +17,6 @@ package com.geeksville.mesh.ui.settings.radio -import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -25,24 +24,19 @@ import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Upload import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.CleaningServices -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import com.geeksville.mesh.R -import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.navigation.AdminRoute import com.geeksville.mesh.navigation.ConfigRoute import com.geeksville.mesh.navigation.ModuleRoute @@ -53,8 +47,6 @@ import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed import com.geeksville.mesh.ui.settings.components.SettingsItem import com.geeksville.mesh.ui.settings.radio.components.WarningDialog -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.seconds @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable @@ -168,32 +160,6 @@ fun RadioConfigItemList( } } -private const val UNLOCK_CLICK_COUNT = 5 // Number of clicks required to unlock excluded modules. -private const val UNLOCK_TIMEOUT_SECONDS = 3 // Timeout in seconds to reset the click counter. - -@Composable -fun RadioConfigMenuActions(modifier: Modifier = Modifier, viewModel: UIViewModel = hiltViewModel()) { - val context = LocalContext.current - var counter by remember { mutableIntStateOf(0) } - LaunchedEffect(counter) { - if (counter > 0 && counter < UNLOCK_CLICK_COUNT) { - delay(UNLOCK_TIMEOUT_SECONDS.seconds) - counter = 0 - } - } - IconButton( - enabled = counter < UNLOCK_CLICK_COUNT, - onClick = { - counter++ - if (counter == UNLOCK_CLICK_COUNT) { - viewModel.unlockExcludedModules() - Toast.makeText(context, context.getString(R.string.modules_unlocked), Toast.LENGTH_LONG).show() - } - }, - modifier = modifier, - ) {} -} - @Preview(showBackground = true) @Composable private fun RadioSettingsScreenPreview() = AppTheme { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 77b4473b9..51faa4391 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -653,6 +653,7 @@ Export Keys Exports public and private keys to a file. Please store somewhere securely. Modules unlocked + Modules already unlocked Remote (%1$d online / %2$d total) React @@ -801,7 +802,8 @@ URL Template https://a.tile.openstreetmap.org/{z}/{x}/{y}.png track point - Phone Settings + App + Version Channel Features Location Sharing Periodic position broadcast