Extract MainAppBar to its own file (#2788)

This commit is contained in:
Phil Oliver
2025-08-19 19:55:10 -04:00
committed by GitHub
parent 68545a7bab
commit da1932fae0
5 changed files with 312 additions and 167 deletions

View File

@@ -49,8 +49,8 @@ import com.geeksville.mesh.android.prefs.UiPrefs
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.MainMenuAction
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

View File

@@ -61,7 +61,7 @@ import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.repository.radio.RadioInterfaceService
import com.geeksville.mesh.service.MeshServiceNotifications
import com.geeksville.mesh.service.ServiceAction
import com.geeksville.mesh.ui.MainMenuAction
import com.geeksville.mesh.ui.common.components.MainMenuAction
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.util.getShortDate
import com.geeksville.mesh.util.positionToMeter

View File

@@ -15,16 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MatchingDeclarationName")
package com.geeksville.mesh.ui
import android.Manifest
import android.os.Build
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -34,29 +34,21 @@ import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.twotone.Chat
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.twotone.CloudDone
import androidx.compose.material.icons.twotone.CloudOff
import androidx.compose.material.icons.twotone.CloudUpload
import androidx.compose.material.icons.twotone.Contactless
import androidx.compose.material.icons.twotone.Map
import androidx.compose.material.icons.twotone.People
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.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffoldDefaults
@@ -78,15 +70,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.geeksville.mesh.BuildConfig
@@ -107,11 +96,11 @@ import com.geeksville.mesh.navigation.NavGraph
import com.geeksville.mesh.navigation.NodesRoutes
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.showLongNameTitle
import com.geeksville.mesh.repository.radio.MeshActivity
import com.geeksville.mesh.service.ConnectionState
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
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
@@ -119,10 +108,7 @@ import com.geeksville.mesh.ui.common.theme.StatusColors.StatusBlue
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusGreen
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusRed
import com.geeksville.mesh.ui.common.theme.StatusColors.StatusYellow
import com.geeksville.mesh.ui.debug.DebugMenuActions
import com.geeksville.mesh.ui.node.components.NodeChip
import com.geeksville.mesh.ui.node.components.NodeMenuAction
import com.geeksville.mesh.ui.radioconfig.RadioConfigMenuActions
import com.geeksville.mesh.ui.sharing.SharedContactDialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@@ -494,153 +480,6 @@ private fun VersionChecks(viewModel: UIViewModel) {
}
}
enum class MainMenuAction(@StringRes val stringRes: Int) {
DEBUG(R.string.debug_panel),
RADIO_CONFIG(R.string.radio_configuration),
EXPORT_RANGETEST(R.string.save_rangetest),
THEME(R.string.theme),
LANGUAGE(R.string.preferences_language),
SHOW_INTRO(R.string.intro_show),
QUICK_CHAT(R.string.quick_chat),
ABOUT(R.string.about),
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod")
@Composable
private fun MainAppBar(
viewModel: UIViewModel = hiltViewModel(),
isManaged: Boolean,
navController: NavHostController,
modifier: Modifier = Modifier,
onAction: (Any?) -> Unit,
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
val canNavigateBack = navController.previousBackStackEntry != null
val navigateUp: () -> Unit = navController::navigateUp
if (currentDestination?.hasRoute<ContactsRoutes.Messages>() == true) {
return
}
val title by viewModel.title.collectAsStateWithLifecycle("")
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
TopAppBar(
title = {
val titleText =
when {
currentDestination == null || currentDestination.isTopLevel() ->
stringResource(id = R.string.app_name)
currentDestination.hasRoute<Route.DebugPanel>() -> stringResource(id = R.string.debug_panel)
currentDestination.hasRoute<ContactsRoutes.QuickChat>() -> stringResource(id = R.string.quick_chat)
currentDestination.hasRoute<ContactsRoutes.Share>() -> stringResource(id = R.string.share_to)
currentDestination.showLongNameTitle() -> title
else -> stringResource(id = R.string.app_name)
}
Text(
text = titleText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
)
},
subtitle = {
if (currentDestination?.hasRoute<NodesRoutes.Nodes>() == true) {
Text(text = stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount))
}
},
modifier = modifier,
navigationIcon =
if (canNavigateBack && currentDestination?.isTopLevel() == false) {
{
IconButton(onClick = navigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.navigate_back),
)
}
}
} else {
{
IconButton(enabled = false, onClick = {}) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.app_icon),
contentDescription = stringResource(id = R.string.application_icon),
)
}
}
},
actions = {
TopBarActions(
viewModel = viewModel,
currentDestination = currentDestination,
isManaged = isManaged,
onAction = onAction,
)
},
)
}
@Composable
private fun TopBarActions(
viewModel: UIViewModel = hiltViewModel(),
currentDestination: NavDestination?,
isManaged: Boolean,
onAction: (Any?) -> Unit,
) {
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
AnimatedVisibility(ourNode != null && currentDestination?.isTopLevel() == true && isConnected) {
ourNode?.let { NodeChip(node = it, isThisNode = true, isConnected = isConnected, onAction = onAction) }
}
currentDestination?.let {
when {
it.isTopLevel() -> MainMenuActions(isManaged, onAction)
currentDestination.hasRoute<Route.DebugPanel>() -> DebugMenuActions()
currentDestination.hasRoute<RadioConfigRoutes.RadioConfig>() ->
RadioConfigMenuActions(viewModel = viewModel)
else -> {}
}
}
}
@Composable
private fun MainMenuActions(isManaged: Boolean, 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 =
when (action) {
MainMenuAction.RADIO_CONFIG -> !isManaged
else -> true
},
)
}
}
}
@Composable
private fun ConnectionState.getConnectionColor(): Color = when (this) {
ConnectionState.CONNECTED -> colorScheme.StatusGreen

View File

@@ -0,0 +1,264 @@
/*
* 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.ui.common.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
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
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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Node
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.ContactsRoutes
import com.geeksville.mesh.navigation.NodesRoutes
import com.geeksville.mesh.navigation.RadioConfigRoutes
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.showLongNameTitle
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.radioconfig.RadioConfigMenuActions
@Suppress("CyclomaticComplexMethod")
@Composable
fun MainAppBar(
modifier: Modifier = Modifier,
viewModel: UIViewModel = hiltViewModel(),
navController: NavHostController,
isManaged: Boolean,
onAction: (Any?) -> Unit,
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
if (currentDestination?.hasRoute<ContactsRoutes.Messages>() == true) {
return
}
val longTitle by viewModel.title.collectAsStateWithLifecycle("")
val onlineNodeCount by viewModel.onlineNodeCount.collectAsStateWithLifecycle(0)
val totalNodeCount by viewModel.totalNodeCount.collectAsStateWithLifecycle(0)
val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false)
val title: String =
when {
currentDestination == null || currentDestination.isTopLevel() -> stringResource(id = R.string.app_name)
currentDestination.hasRoute<Route.DebugPanel>() -> stringResource(id = R.string.debug_panel)
currentDestination.hasRoute<ContactsRoutes.QuickChat>() -> stringResource(id = R.string.quick_chat)
currentDestination.hasRoute<ContactsRoutes.Share>() -> stringResource(id = R.string.share_to)
currentDestination.showLongNameTitle() -> longTitle
else -> stringResource(id = R.string.app_name)
}
val subtitle =
if (currentDestination?.hasRoute<NodesRoutes.Nodes>() == true) {
stringResource(R.string.node_count_template, onlineNodeCount, totalNodeCount)
} else {
null
}
MainAppBar(
modifier = modifier,
title = title,
subtitle = subtitle,
canNavigateUp = navController.previousBackStackEntry != null && currentDestination?.isTopLevel() == false,
ourNode = ourNode,
isConnected = isConnected,
showNodeChip = ourNode != null && currentDestination?.isTopLevel() == true && isConnected,
onNavigateUp = navController::navigateUp,
actions = {
currentDestination?.let {
when {
it.isTopLevel() -> MainMenuActions(isManaged, onAction)
currentDestination.hasRoute<Route.DebugPanel>() -> DebugMenuActions()
currentDestination.hasRoute<RadioConfigRoutes.RadioConfig>() ->
RadioConfigMenuActions(viewModel = viewModel)
else -> {}
}
}
},
onAction = onAction,
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun MainAppBar(
modifier: Modifier = Modifier,
title: String,
subtitle: String? = null,
ourNode: Node?,
isConnected: Boolean,
showNodeChip: Boolean,
canNavigateUp: Boolean,
onNavigateUp: () -> Unit,
actions: @Composable () -> Unit,
onAction: (Any?) -> Unit,
) {
TopAppBar(
title = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleLarge,
)
},
subtitle = { subtitle?.let { Text(text = it) } },
modifier = modifier,
navigationIcon =
if (canNavigateUp) {
{
IconButton(onClick = onNavigateUp) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(id = R.string.navigate_back),
)
}
}
} else {
{
IconButton(enabled = false, onClick = {}) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.app_icon),
contentDescription = stringResource(id = R.string.application_icon),
)
}
}
},
actions = {
TopBarActions(
ourNode = ourNode,
isConnected = isConnected,
showNodeChip = showNodeChip,
actions = actions,
onAction = onAction,
)
},
)
}
@Composable
private fun TopBarActions(
ourNode: Node?,
isConnected: Boolean,
showNodeChip: Boolean,
actions: @Composable () -> Unit,
onAction: (Any?) -> Unit,
) {
AnimatedVisibility(showNodeChip) {
ourNode?.let { NodeChip(node = it, isThisNode = true, isConnected = isConnected, onAction = onAction) }
}
actions()
}
enum class MainMenuAction(@StringRes val stringRes: Int) {
DEBUG(R.string.debug_panel),
RADIO_CONFIG(R.string.radio_configuration),
EXPORT_RANGETEST(R.string.save_rangetest),
THEME(R.string.theme),
LANGUAGE(R.string.preferences_language),
SHOW_INTRO(R.string.intro_show),
QUICK_CHAT(R.string.quick_chat),
ABOUT(R.string.about),
}
@Composable
private fun MainMenuActions(isManaged: Boolean, 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 =
when (action) {
MainMenuAction.RADIO_CONFIG -> !isManaged
else -> true
},
)
}
}
}
@PreviewLightDark
@Composable
private fun MainAppBarPreview(@PreviewParameter(BooleanProvider::class) canNavigateUp: Boolean) {
AppTheme {
MainAppBar(
title = "Title",
subtitle = "Subtitle",
ourNode = previewNode,
isConnected = false,
showNodeChip = true,
canNavigateUp = canNavigateUp,
onNavigateUp = {},
actions = { MainMenuActions(isManaged = false, onAction = {}) },
) {}
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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/>.
*/
@file:Suppress("MatchingDeclarationName")
package com.geeksville.mesh.ui.common.components
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.PaxcountProtos
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.model.Node
/** Simple [PreviewParameterProvider] that provides true and false values. */
class BooleanProvider : PreviewParameterProvider<Boolean> {
override val values: Sequence<Boolean> = sequenceOf(false, true)
}
private val user = MeshProtos.User.newBuilder().setShortName("\uD83E\uDEE0").setLongName("John Doe").build()
val previewNode =
Node(
num = 13444,
user = user,
isIgnored = false,
paxcounter = PaxcountProtos.Paxcount.newBuilder().setBle(10).setWifi(5).build(),
environmentMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder().setTemperature(25f).setRelativeHumidity(60f).build(),
)