mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-13 01:05:55 -04:00
feat: Allow unlocking excluded modules (#2180)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -206,7 +206,18 @@ class UIViewModel @Inject constructor(
|
||||
private val _lastTraceRouteTime = MutableStateFlow<Long?>(null)
|
||||
val lastTraceRouteTime: StateFlow<Long?> = _lastTraceRouteTime.asStateFlow()
|
||||
|
||||
val clientNotification: StateFlow<MeshProtos.ClientNotification?> = radioConfigRepository.clientNotification
|
||||
private val _excludedModulesUnlocked = MutableStateFlow(false)
|
||||
val excludedModulesUnlocked: StateFlow<Boolean> = _excludedModulesUnlocked.asStateFlow()
|
||||
|
||||
fun unlockExcludedModules() {
|
||||
viewModelScope.launch {
|
||||
_excludedModulesUnlocked.value = true
|
||||
}
|
||||
}
|
||||
|
||||
val clientNotification: StateFlow<MeshProtos.ClientNotification?> =
|
||||
radioConfigRepository.clientNotification
|
||||
|
||||
fun clearClientNotification(notification: MeshProtos.ClientNotification) {
|
||||
radioConfigRepository.clearClientNotification()
|
||||
meshServiceNotifications.clearClientNotification(notification)
|
||||
|
||||
@@ -90,6 +90,7 @@ import com.geeksville.mesh.ui.common.components.MultipleChoiceAlertDialog
|
||||
import com.geeksville.mesh.ui.common.components.ScannedQrCodeDialog
|
||||
import com.geeksville.mesh.ui.common.components.SimpleAlertDialog
|
||||
import com.geeksville.mesh.ui.debug.DebugMenuActions
|
||||
import com.geeksville.mesh.ui.radioconfig.RadioConfigMenuActions
|
||||
|
||||
enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) {
|
||||
Contacts(R.string.contacts, Icons.AutoMirrored.TwoTone.Chat, ContactsRoutes.Contacts),
|
||||
@@ -126,8 +127,6 @@ fun MainScreen(
|
||||
|
||||
VersionChecks(viewModel)
|
||||
|
||||
val title by viewModel.title.collectAsStateWithLifecycle()
|
||||
|
||||
val alertDialogState by viewModel.currentAlert.collectAsStateWithLifecycle()
|
||||
alertDialogState?.let { state ->
|
||||
if (state.choices.isNotEmpty()) {
|
||||
@@ -245,7 +244,7 @@ fun MainScreen(
|
||||
.fillMaxSize()
|
||||
) {
|
||||
MainAppBar(
|
||||
title = title,
|
||||
viewModel = viewModel,
|
||||
isManaged = localConfig.security.isManaged,
|
||||
navController = navController,
|
||||
onAction = { action ->
|
||||
@@ -336,7 +335,7 @@ enum class MainMenuAction(@StringRes val stringRes: Int) {
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
private fun MainAppBar(
|
||||
title: String,
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
isManaged: Boolean,
|
||||
navController: NavHostController,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -350,6 +349,7 @@ private fun MainAppBar(
|
||||
if (currentDestination?.hasRoute<ContactsRoutes.Messages>() == true) {
|
||||
return
|
||||
}
|
||||
val title by viewModel.title.collectAsStateWithLifecycle("")
|
||||
TopAppBar(
|
||||
title = {
|
||||
when {
|
||||
@@ -405,19 +405,39 @@ private fun MainAppBar(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
when {
|
||||
currentDestination == null || isTopLevelRoute ->
|
||||
MainMenuActions(isManaged, onAction)
|
||||
|
||||
currentDestination.hasRoute<Route.DebugPanel>() ->
|
||||
DebugMenuActions()
|
||||
|
||||
else -> {}
|
||||
}
|
||||
TopBarActions(
|
||||
viewModel = viewModel,
|
||||
currentDestination = currentDestination,
|
||||
isManaged = isManaged,
|
||||
isTopLevelRoute = isTopLevelRoute,
|
||||
onAction = onAction
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopBarActions(
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
currentDestination: NavDestination?,
|
||||
isManaged: Boolean,
|
||||
isTopLevelRoute: Boolean,
|
||||
onAction: (MainMenuAction) -> Unit
|
||||
) {
|
||||
when {
|
||||
currentDestination == null || isTopLevelRoute ->
|
||||
MainMenuActions(isManaged, onAction)
|
||||
|
||||
currentDestination.hasRoute<Route.DebugPanel>() ->
|
||||
DebugMenuActions()
|
||||
|
||||
currentDestination.hasRoute<RadioConfigRoutes.RadioConfig>() ->
|
||||
RadioConfigMenuActions(viewModel = viewModel)
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainMenuActions(
|
||||
isManaged: Boolean,
|
||||
|
||||
@@ -19,6 +19,7 @@ package com.geeksville.mesh.ui.radioconfig
|
||||
|
||||
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.annotation.StringRes
|
||||
@@ -45,17 +46,21 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
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
|
||||
@@ -73,6 +78,8 @@ 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
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
@@ -88,6 +95,8 @@ fun RadioConfigScreen(
|
||||
uiViewModel.setTitle(it)
|
||||
}
|
||||
|
||||
val excludedModulesUnlocked by uiViewModel.excludedModulesUnlocked.collectAsStateWithLifecycle()
|
||||
|
||||
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
|
||||
var isWaiting by remember { mutableStateOf(false) }
|
||||
if (isWaiting) {
|
||||
@@ -161,6 +170,7 @@ fun RadioConfigScreen(
|
||||
RadioConfigItemList(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
excludedModulesUnlocked = excludedModulesUnlocked,
|
||||
onRouteClick = { route ->
|
||||
isWaiting = true
|
||||
viewModel.setResponseStateLoading(route)
|
||||
@@ -288,12 +298,21 @@ private fun NavButton(@StringRes title: Int, enabled: Boolean, onClick: () -> Un
|
||||
@Composable
|
||||
private fun RadioConfigItemList(
|
||||
state: RadioConfigState,
|
||||
excludedModulesUnlocked: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
onRouteClick: (Enum<*>) -> Unit = {},
|
||||
onImport: () -> Unit = {},
|
||||
onExport: () -> Unit = {},
|
||||
) {
|
||||
val enabled = state.connected && !state.responseState.isWaiting()
|
||||
var modules by remember { mutableStateOf(ModuleRoute.filterExcludedFrom(state.metadata)) }
|
||||
LaunchedEffect(excludedModulesUnlocked) {
|
||||
if (excludedModulesUnlocked) {
|
||||
modules = ModuleRoute.entries
|
||||
} else {
|
||||
modules = ModuleRoute.filterExcludedFrom(state.metadata)
|
||||
}
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
@@ -308,7 +327,7 @@ private fun RadioConfigItemList(
|
||||
}
|
||||
|
||||
item { PreferenceCategory(stringResource(R.string.module_settings)) }
|
||||
items(ModuleRoute.filterExcludedFrom(state.metadata)) {
|
||||
items(modules) {
|
||||
NavCard(
|
||||
title = stringResource(it.title),
|
||||
icon = it.icon,
|
||||
@@ -338,6 +357,40 @@ private 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 {
|
||||
|
||||
@@ -692,4 +692,5 @@
|
||||
<string name="regenerate_keys_confirmation">Are you sure you want to regenerate your Private Key?\n\nNodes that may have previously exchanged keys with this node will need to Remove that node and re-exchange keys in order to resume secure communication.</string>
|
||||
<string name="export_keys">Export Keys</string>
|
||||
<string name="export_keys_confirmation">Exports public and private keys to a file. Please store somewhere securely.</string>
|
||||
<string name="modules_unlocked">Modules unlocked</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user