Meshtastic-Android/app/src/main/java/com/geeksville/mesh/ui/Main.kt

413 lines
16 KiB
Kotlin
Raw Normal View History

/*
* 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
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
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
2025-05-17 11:39:53 -05:00
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.PlainTooltip
2025-05-17 11:39:53 -05:00
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
2025-05-17 11:39:53 -05:00
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
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.R
import com.geeksville.mesh.model.UIViewModel
import com.geeksville.mesh.navigation.NavGraph
import com.geeksville.mesh.navigation.Route
import com.geeksville.mesh.navigation.showLongNameTitle
import com.geeksville.mesh.service.MeshService
import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel
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
enum class TopLevelDestination(@StringRes val label: Int, val icon: ImageVector, val route: Route) {
Contacts(R.string.contacts, Icons.AutoMirrored.TwoTone.Chat, Route.Contacts),
Nodes(R.string.nodes, Icons.TwoTone.People, Route.Nodes),
Map(R.string.map, Icons.TwoTone.Map, Route.Map),
Channels(R.string.channels, Icons.TwoTone.Contactless, Route.Channels),
Connections(R.string.connections, Icons.TwoTone.CloudOff, Route.Connections),
;
companion object {
fun NavDestination.isTopLevel(): Boolean = entries.any { hasRoute(it.route::class) }
fun fromNavDestination(destination: NavDestination?): TopLevelDestination? = entries
.find { dest -> destination?.hierarchy?.any { it.hasRoute(dest.route::class) } == true }
}
}
2025-05-17 11:39:53 -05:00
@Suppress("LongMethod")
@Composable
fun MainScreen(
modifier: Modifier = Modifier,
viewModel: UIViewModel = hiltViewModel(),
onAction: (MainMenuAction) -> Unit
) {
val navController = rememberNavController()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val localConfig by viewModel.localConfig.collectAsStateWithLifecycle()
val requestChannelSet by viewModel.requestChannelSet.collectAsStateWithLifecycle()
if (connectionState.isConnected()) {
requestChannelSet?.let { newChannelSet ->
ScannedQrCodeDialog(viewModel, newChannelSet)
}
}
val title by viewModel.title.collectAsStateWithLifecycle()
2025-05-17 11:39:53 -05:00
val alertDialogState by viewModel.currentAlert.collectAsStateWithLifecycle()
alertDialogState?.let { state ->
if (state.choices.isNotEmpty()) {
MultipleChoiceAlertDialog(
title = state.title,
message = state.message,
choices = state.choices,
onDismissRequest = { state.onDismiss?.let { it() } },
)
} else {
SimpleAlertDialog(
title = state.title,
message = state.message,
html = state.html,
onConfirmRequest = { state.onConfirm?.let { it() } },
onDismissRequest = { state.onDismiss?.let { it() } },
)
}
}
val traceRouteResponse by viewModel.tracerouteResponse.observeAsState()
traceRouteResponse?.let { response ->
SimpleAlertDialog(
title = R.string.traceroute,
text = {
Text(text = response)
},
dismissText = stringResource(id = R.string.okay),
onDismiss = { viewModel.clearTracerouteResponse() }
)
}
Scaffold(
modifier = modifier.safeDrawingPadding(),
topBar = {
MainAppBar(
title = title,
isManaged = localConfig.security.isManaged,
navController = navController,
) { action ->
when (action) {
MainMenuAction.DEBUG -> navController.navigate(Route.DebugPanel)
MainMenuAction.RADIO_CONFIG -> navController.navigate(Route.RadioConfig())
MainMenuAction.QUICK_CHAT -> navController.navigate(Route.QuickChat)
else -> onAction(action)
}
}
},
bottomBar = {
BottomNavigation(
connectionState = connectionState,
navController = navController,
)
},
snackbarHost = { SnackbarHost(hostState = viewModel.snackbarState) }
) { innerPadding ->
NavGraph(
modifier = Modifier.padding(innerPadding),
uIViewModel = viewModel,
navController = navController,
)
}
}
enum class MainMenuAction(@StringRes val stringRes: Int) {
DEBUG(R.string.debug_panel),
RADIO_CONFIG(R.string.radio_configuration),
EXPORT_MESSAGES(R.string.save_messages),
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),
}
2025-05-17 11:39:53 -05:00
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Composable
private fun MainAppBar(
title: String,
isManaged: Boolean,
navController: NavHostController,
modifier: Modifier = Modifier,
onAction: (MainMenuAction) -> Unit
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = backStackEntry?.destination
val canNavigateBack = navController.previousBackStackEntry != null
val isTopLevelRoute = currentDestination?.isTopLevel() == true
val navigateUp: () -> Unit = navController::navigateUp
2025-05-15 13:09:46 -05:00
if (currentDestination?.hasRoute<Route.Messages>() == true) {
return
}
TopAppBar(
title = {
when {
currentDestination == null || isTopLevelRoute -> {
Text(
text = stringResource(id = R.string.app_name),
)
}
currentDestination.hasRoute<Route.DebugPanel>() ->
Text(
stringResource(id = R.string.debug_panel),
)
currentDestination.hasRoute<Route.QuickChat>() ->
Text(
stringResource(id = R.string.quick_chat),
)
currentDestination.hasRoute<Route.Share>() ->
Text(
stringResource(id = R.string.share_to),
)
currentDestination.showLongNameTitle() -> {
Text(
title,
)
}
}
},
modifier = modifier,
navigationIcon = if (canNavigateBack && !isTopLevelRoute) {
{
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 = {
when {
currentDestination == null || isTopLevelRoute ->
MainMenuActions(isManaged, onAction)
currentDestination.hasRoute<Route.DebugPanel>() ->
DebugMenuActions()
else -> {}
}
},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@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 = "Overflow menu",
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
2025-05-17 11:39:53 -05:00
modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)),
) {
MainMenuAction.entries.forEach { action ->
DropdownMenuItem(
2025-05-17 11:39:53 -05:00
text = { Text(stringResource(id = action.stringRes)) },
onClick = {
onAction(action)
showMenu = false
},
enabled = when (action) {
MainMenuAction.RADIO_CONFIG -> !isManaged
else -> true
},
2025-05-17 11:39:53 -05:00
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomNavigation(
connectionState: MeshService.ConnectionState,
navController: NavController,
) {
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)
NavigationBar {
TopLevelDestination.entries.forEach { destination ->
val isSelected = destination == topLevelDestination
val isConnectionsRoute = destination == TopLevelDestination.Connections
NavigationBarItem(
icon = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
if (isConnectionsRoute) {
connectionState.getTooltipString()
} else {
stringResource(id = destination.label)
},
)
}
},
state = rememberTooltipState()
) {
TopLevelNavIcon(destination, connectionState)
}
},
selected = isSelected,
onClick = {
if (!isSelected) {
navController.navigate(destination.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
}
)
}
}
}
@Composable
private fun MeshService.ConnectionState.getConnectionColor(): Color {
return when (this) {
MeshService.ConnectionState.CONNECTED -> Color(color = 0xFF30C047)
MeshService.ConnectionState.DEVICE_SLEEP -> MaterialTheme.colorScheme.tertiary
MeshService.ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.error
}
}
private fun MeshService.ConnectionState.getConnectionIcon(): ImageVector {
return when (this) {
MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone
MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload
MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff
}
}
@Composable
private fun MeshService.ConnectionState.getTooltipString(): String {
return when (this) {
MeshService.ConnectionState.CONNECTED -> stringResource(R.string.connected)
MeshService.ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping)
MeshService.ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected)
}
}
@Composable
private fun TopLevelNavIcon(
dest: TopLevelDestination,
connectionState: MeshService.ConnectionState
) {
when (dest) {
TopLevelDestination.Connections -> Icon(
imageVector = connectionState.getConnectionIcon(),
contentDescription = stringResource(id = dest.label),
tint = connectionState.getConnectionColor(),
)
else -> Icon(
imageVector = dest.icon,
contentDescription = stringResource(id = dest.label),
)
}
}