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 b06d5e25f..48f17caee 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt @@ -32,13 +32,13 @@ import androidx.navigation.toRoute import com.geeksville.mesh.R import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.ui.TopLevelDestination.Companion.isTopLevel +import com.geeksville.mesh.ui.connections.ConnectionsScreen import com.geeksville.mesh.ui.contact.ContactsScreen import com.geeksville.mesh.ui.debug.DebugScreen 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.settings.SettingsScreen import com.geeksville.mesh.ui.sharing.ChannelScreen import com.geeksville.mesh.ui.sharing.ShareScreen import kotlinx.serialization.Serializable @@ -76,7 +76,7 @@ sealed interface Route { data object Channels : Route @Serializable - data object Settings : Route + data object Connections : Route @Serializable data object DebugPanel : Route @@ -219,7 +219,7 @@ fun NavGraph( NavHost( navController = navController, startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) { - Route.Settings + Route.Connections } else { Route.Contacts }, @@ -244,19 +244,19 @@ fun NavGraph( composable { ChannelScreen(uIViewModel) } - composable( + composable( deepLinks = listOf( navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/settings" + uriPattern = "$DEEP_LINK_BASE_URI/connections" action = "android.intent.action.VIEW" } ) ) { backStackEntry -> - SettingsScreen( + ConnectionsScreen( uIViewModel, onNavigateToRadioConfig = { navController.navigate(Route.RadioConfig()) { - popUpTo(Route.Settings) { + popUpTo(Route.Connections) { inclusive = false } } 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 45588e81b..68f0b2e3f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -17,12 +17,7 @@ package com.geeksville.mesh.ui -import android.widget.Toast import androidx.annotation.StringRes -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding @@ -36,7 +31,6 @@ 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.material.icons.twotone.Settings import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,10 +39,14 @@ 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 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 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 @@ -56,8 +54,8 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.hilt.navigation.compose.hiltViewModel @@ -82,12 +80,12 @@ 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(val label: String, val icon: ImageVector, val route: Route) { - Contacts("Contacts", Icons.AutoMirrored.TwoTone.Chat, Route.Contacts), - Nodes("Nodes", Icons.TwoTone.People, Route.Nodes), - Map("Map", Icons.TwoTone.Map, Route.Map), - Channels("Channels", Icons.TwoTone.Contactless, Route.Channels), - Settings("Settings", Icons.TwoTone.Settings, Route.Settings), +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 { @@ -109,7 +107,6 @@ fun MainScreen( 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) @@ -154,7 +151,6 @@ fun MainScreen( MainAppBar( title = title, isManaged = localConfig.security.isManaged, - connectionState = connectionState, navController = navController, ) { action -> when (action) { @@ -167,6 +163,7 @@ fun MainScreen( }, bottomBar = { BottomNavigation( + connectionState = connectionState, navController = navController, ) }, @@ -197,7 +194,6 @@ enum class MainMenuAction(@StringRes val stringRes: Int) { private fun MainAppBar( title: String, isManaged: Boolean, - connectionState: MeshService.ConnectionState, navController: NavHostController, modifier: Modifier = Modifier, onAction: (MainMenuAction) -> Unit @@ -267,7 +263,7 @@ private fun MainAppBar( actions = { when { currentDestination == null || isTopLevelRoute -> - MainMenuActions(isManaged, connectionState, onAction) + MainMenuActions(isManaged, onAction) currentDestination.hasRoute() -> DebugMenuActions() @@ -278,30 +274,13 @@ private fun MainAppBar( ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun MainMenuActions( isManaged: Boolean, - connectionState: MeshService.ConnectionState, onAction: (MainMenuAction) -> Unit ) { - val context = LocalContext.current - val (image, tooltip) = when (connectionState) { - MeshService.ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone to R.string.connected - MeshService.ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload to R.string.device_sleeping - MeshService.ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff to R.string.disconnected - } - var showMenu by remember { mutableStateOf(false) } - IconButton( - onClick = { - Toast.makeText(context, tooltip, Toast.LENGTH_SHORT).show() - }, - ) { - Icon( - imageVector = image, - contentDescription = stringResource(id = tooltip), - ) - } IconButton(onClick = { showMenu = true }) { Icon( imageVector = Icons.Default.MoreVert, @@ -330,55 +309,103 @@ private fun MainMenuActions( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun BottomNavigation( + connectionState: MeshService.ConnectionState, navController: NavController, ) { val currentDestination = navController.currentBackStackEntryAsState().value?.destination val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) - AnimatedVisibility( - visible = topLevelDestination != null, - enter = slideInVertically( - initialOffsetY = { it / 2 }, - animationSpec = tween(durationMillis = 50), - ), - exit = slideOutVertically( - targetOffsetY = { it / 2 }, - animationSpec = tween(durationMillis = 50), - ), - ) { - NavigationBar { - TopLevelDestination.entries.forEach { - val isSelected = it == topLevelDestination - NavigationBarItem( - icon = { - Icon( - imageVector = it.icon, - contentDescription = it.name, - ) - }, - // label = { Text(it.label) }, - selected = isSelected, - onClick = { - if (!isSelected) { - navController.navigate(it.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 + 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), + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/theme/Theme.kt b/app/src/main/java/com/geeksville/mesh/ui/common/theme/Theme.kt index 458f7c418..26e68a4d6 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/theme/Theme.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/theme/Theme.kt @@ -21,7 +21,8 @@ package com.geeksville.mesh.ui.common.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme @@ -271,10 +272,10 @@ val unspecified_scheme = ColorFamily( Color.Unspecified, Color.Unspecified, Color.Unspecified, Color.Unspecified ) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun AppTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable() () -> Unit ) { @@ -288,7 +289,7 @@ fun AppTheme( else -> lightScheme } - MaterialTheme( + MaterialExpressiveTheme( colorScheme = colorScheme, typography = AppTypography, content = content diff --git a/app/src/main/java/com/geeksville/mesh/ui/settings/Settings.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt similarity index 99% rename from app/src/main/java/com/geeksville/mesh/ui/settings/Settings.kt rename to app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index b53400776..8edc90e2f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/settings/Settings.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package com.geeksville.mesh.ui.settings +package com.geeksville.mesh.ui.connections import android.app.Activity import android.content.Context @@ -108,9 +108,9 @@ fun String?.isIPAddress(): Boolean { } } -@Suppress("CyclomaticComplexMethod", "LongMethod") +@Suppress("CyclomaticComplexMethod", "LongMethod", "MagicNumber") @Composable -fun SettingsScreen( +fun ConnectionsScreen( uiViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanModel = hiltViewModel(), bluetoothViewModel: BluetoothViewModel = hiltViewModel(), @@ -389,7 +389,7 @@ fun SettingsScreen( label = { Text(stringResource(R.string.ip_address)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier - .weight(1f) + .weight(0.7f) .padding(start = 16.dp) ) OutlinedTextField( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 76ab7f60d..b981d6283 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,20 @@ + + // Language tags native names (not available via .getDisplayLanguage) Kreyòl ayisyen @@ -638,4 +655,8 @@ Disk Free Load User String + Connections + Map + Contacts + Nodes