From 6332b3bd4240926805ce5449fe7790a2d3d47b02 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 26 May 2025 19:36:32 -0500 Subject: [PATCH] feat(node): consolidate node chip and menu (#1941) --- .../java/com/geeksville/mesh/MainActivity.kt | 9 - .../java/com/geeksville/mesh/model/UIState.kt | 17 ++ .../geeksville/mesh/navigation/NavGraph.kt | 171 ++++++------ .../mesh/navigation/NodeDetailGraph.kt | 20 +- .../main/java/com/geeksville/mesh/ui/Main.kt | 12 + .../java/com/geeksville/mesh/ui/NodeChip.kt | 114 ++++++++ .../java/com/geeksville/mesh/ui/NodeDetail.kt | 264 ++++++++++++++++-- .../java/com/geeksville/mesh/ui/NodeItem.kt | 82 ++---- .../java/com/geeksville/mesh/ui/NodeScreen.kt | 13 +- .../geeksville/mesh/ui/components/NodeMenu.kt | 149 ++++++---- .../ui/components/SharedTransitionPreview.kt | 47 ++++ .../mesh/ui/components/UserAvatar.kt | 87 ------ .../com/geeksville/mesh/ui/message/Message.kt | 43 +-- .../mesh/ui/message/components/MessageItem.kt | 33 ++- .../mesh/ui/message/components/MessageList.kt | 41 +-- .../components/ChannelSettingsItemList.kt | 2 +- app/src/main/proto | 2 +- app/src/main/res/values/strings.xml | 2 +- 18 files changed, 734 insertions(+), 374 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/NodeChip.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt delete mode 100644 app/src/main/java/com/geeksville/mesh/ui/components/UserAvatar.kt diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 533386de5..5c5f8680e 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -142,7 +142,6 @@ class MainActivity : AppCompatActivity(), Logging { AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM -> isSystemInDarkTheme() else -> isSystemInDarkTheme() } - AppTheme( dynamicColor = dynamic, darkTheme = dark, @@ -447,14 +446,6 @@ class MainActivity : AppCompatActivity(), Logging { } } - model.tracerouteResponse.observe(this) { response -> - model.showAlert( - title = getString(R.string.traceroute), - message = response ?: return@observe, - ) - model.clearTracerouteResponse() - } - try { bindMeshService() } catch (ex: BindFailedException) { 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 33c4eccdb..01a9fdccb 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -60,6 +60,7 @@ import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService import com.geeksville.mesh.service.MeshService import com.geeksville.mesh.service.ServiceAction +import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.map.MAP_STYLE_ID import com.geeksville.mesh.util.getShortDate import com.geeksville.mesh.util.positionToMeter @@ -74,6 +75,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn @@ -544,6 +546,7 @@ class UIViewModel @Inject constructor( // Connection state to our radio device val connectionState get() = radioConfigRepository.connectionState fun isConnected() = connectionState.value != MeshService.ConnectionState.DISCONNECTED + val isConnected = radioConfigRepository.connectionState.map { it == MeshService.ConnectionState.CONNECTED } private val _requestChannelSet = MutableStateFlow(null) val requestChannelSet: StateFlow get() = _requestChannelSet @@ -590,6 +593,20 @@ class UIViewModel @Inject constructor( } } + fun handleNodeMenuAction( + action: NodeMenuAction, + ) { + when (action) { + is NodeMenuAction.Remove -> removeNode(action.node.num) + is NodeMenuAction.Ignore -> ignoreNode(action.node) + is NodeMenuAction.Favorite -> favoriteNode(action.node) + is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num) + is NodeMenuAction.RequestPosition -> requestPosition(action.node.num) + is NodeMenuAction.TraceRoute -> requestTraceroute(action.node.num) + else -> {} + } + } + // managed mode disables all access to configuration val isManaged: Boolean get() = config.device.isManaged || config.security.isManaged 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 6cbc87764..44be8eb3f 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt @@ -35,6 +35,8 @@ package com.geeksville.mesh.navigation import androidx.annotation.StringRes +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel @@ -223,6 +225,7 @@ fun NavDestination.showLongNameTitle(): Boolean { ) } +@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongMethod") @Composable fun NavGraph( @@ -230,90 +233,96 @@ fun NavGraph( uIViewModel: UIViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), ) { - NavHost( - navController = navController, - startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) { - Route.Settings - } else { - Route.Contacts - }, - modifier = modifier, - ) { - composable { - ContactsScreen( - uIViewModel, - onNavigate = { navController.navigate(Route.Messages(it)) } - ) - } - composable { - NodeScreen( - model = uIViewModel, - navigateToMessages = { navController.navigate(Route.Messages(it)) }, - navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, - ) - } - composable { - MapView(uIViewModel) - } - composable { - ChannelScreen(uIViewModel) - } - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/settings" - action = "android.intent.action.VIEW" - } - ) - ) { backStackEntry -> - SettingsScreen( - uIViewModel, - ) { - navController.navigate(Route.RadioConfig()) { - popUpTo(Route.Settings) { - inclusive = false + SharedTransitionLayout { + NavHost( + navController = navController, + startDestination = if (uIViewModel.bondedAddress.isNullOrBlank()) { + Route.Settings + } else { + Route.Contacts + }, + modifier = modifier, + ) { + composable { + ContactsScreen( + uIViewModel, + onNavigate = { navController.navigate(Route.Messages(it)) } + ) + } + composable { + NodeScreen( + model = uIViewModel, + navigateToMessages = { navController.navigate(Route.Messages(it)) }, + navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, + sharedTransitionScope = this@SharedTransitionLayout, + this@composable, + ) + } + composable { + MapView(uIViewModel) + } + composable { + ChannelScreen(uIViewModel) + } + composable( + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEP_LINK_BASE_URI/settings" + action = "android.intent.action.VIEW" + } + ) + ) { backStackEntry -> + SettingsScreen( + uIViewModel, + ) { + navController.navigate(Route.RadioConfig()) { + popUpTo(Route.Settings) { + inclusive = false + } } } } - } - composable { - DebugScreen() - } - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}" - action = "android.intent.action.VIEW" - }, - ) - ) { backStackEntry -> - val args = backStackEntry.toRoute() - MessageScreen( - contactKey = args.contactKey, - message = args.message, - viewModel = uIViewModel, - navigateToMessages = { navController.navigate(Route.Messages(it)) }, - navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, - onNavigateBack = navController::navigateUp - ) - } - composable { - QuickChatScreen() - } - nodeDetailGraph(navController, uIViewModel) - radioConfigGraph(navController, uIViewModel) - composable( - deepLinks = listOf( - navDeepLink { - uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}" - action = "android.intent.action.VIEW" - } - ) - ) { backStackEntry -> - val message = backStackEntry.toRoute().message - ShareScreen(uIViewModel) { - navController.navigate(Route.Messages(it, message)) { - popUpTo { inclusive = true } + composable { + DebugScreen() + } + composable( + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}" + action = "android.intent.action.VIEW" + }, + ) + ) { backStackEntry -> + val args = backStackEntry.toRoute() + MessageScreen( + contactKey = args.contactKey, + message = args.message, + viewModel = uIViewModel, + navigateToMessages = { navController.navigate(Route.Messages(it)) }, + navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) }, + onNavigateBack = navController::navigateUp, + sharedTransitionScope = this@SharedTransitionLayout, + animatedContentScope = this@composable, + ) + } + composable { + QuickChatScreen() + } + nodeDetailGraph(navController, uIViewModel, sharedTransitionScope = this@SharedTransitionLayout) + radioConfigGraph(navController, uIViewModel) + composable( + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}" + action = "android.intent.action.VIEW" + } + ) + ) { backStackEntry -> + val message = backStackEntry.toRoute().message + ShareScreen(uIViewModel) { + navController.navigate(Route.Messages(it, message)) { + popUpTo { inclusive = true } + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt index 9cd981707..088dda55a 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NodeDetailGraph.kt @@ -18,6 +18,8 @@ package com.geeksville.mesh.navigation import androidx.annotation.StringRes +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CellTower import androidx.compose.material.icons.filled.LightMode @@ -43,9 +45,11 @@ import com.geeksville.mesh.ui.components.PowerMetricsScreen import com.geeksville.mesh.ui.components.SignalMetricsScreen import com.geeksville.mesh.ui.components.TracerouteLogScreen +@OptIn(ExperimentalSharedTransitionApi::class) fun NavGraphBuilder.nodeDetailGraph( navController: NavHostController, - uiViewModel: UIViewModel + uiViewModel: UIViewModel, + sharedTransitionScope: SharedTransitionScope, ) { navigation( startDestination = Route.NodeDetail(), @@ -54,7 +58,12 @@ fun NavGraphBuilder.nodeDetailGraph( val parentEntry = remember(backStackEntry) { navController.getBackStackEntry() } - NodeDetailScreen(uiViewModel = uiViewModel, viewModel = hiltViewModel(parentEntry)) { + NodeDetailScreen( + uiViewModel = uiViewModel, + viewModel = hiltViewModel(parentEntry), + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = this@composable, + ) { navController.navigate(it) { popUpTo(Route.NodeDetail()) { inclusive = false @@ -71,7 +80,12 @@ fun NavGraphBuilder.nodeDetailGraph( NodeDetailRoute.DEVICE -> DeviceMetricsScreen(hiltViewModel(parentEntry)) NodeDetailRoute.NODE_MAP -> NodeMapScreen(hiltViewModel(parentEntry)) NodeDetailRoute.POSITION_LOG -> PositionLogScreen(hiltViewModel(parentEntry)) - NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen(hiltViewModel(parentEntry)) + NodeDetailRoute.ENVIRONMENT -> EnvironmentMetricsScreen( + hiltViewModel( + parentEntry + ) + ) + NodeDetailRoute.SIGNAL -> SignalMetricsScreen(hiltViewModel(parentEntry)) NodeDetailRoute.TRACEROUTE -> TracerouteLogScreen(hiltViewModel(parentEntry)) NodeDetailRoute.POWER -> PowerMetricsScreen(hiltViewModel(parentEntry)) 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 9f906b994..0715d88d0 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -50,6 +50,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar 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 @@ -133,6 +134,17 @@ fun MainScreen( } } + val traceRouteResponse by viewModel.tracerouteResponse.observeAsState() + traceRouteResponse?.let { response -> + SimpleAlertDialog( + title = R.string.traceroute, + text = { + Text(text = response) + }, + onDismiss = { viewModel.clearTracerouteResponse() } + ) + } + Scaffold( topBar = { MainAppBar( diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeChip.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeChip.kt new file mode 100644 index 000000000..8ff3de4ef --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeChip.kt @@ -0,0 +1,114 @@ +/* + * 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 . + */ + +package com.geeksville.mesh.ui + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.ElevatedAssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.model.Node +import com.geeksville.mesh.ui.components.NodeMenu +import com.geeksville.mesh.ui.components.NodeMenuAction + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun NodeChip( + modifier: Modifier = Modifier, + node: Node, + isThisNode: Boolean, + isConnected: Boolean, + onAction: (NodeMenuAction) -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, +) { + val isIgnored = node.isIgnored + val (textColor, nodeColor) = node.colors + var menuExpanded by remember { mutableStateOf(false) } + val inputChipInteractionSource = remember { MutableInteractionSource() } + Box { + with(sharedTransitionScope) { + ElevatedAssistChip( + modifier = modifier + .width(IntrinsicSize.Min) + .defaultMinSize(minHeight = 32.dp, minWidth = 72.dp) + .sharedElement( + rememberSharedContentState("node_chip_${node.num}"), + animatedContentScope + ), + colors = AssistChipDefaults.assistChipColors( + containerColor = Color(nodeColor), + labelColor = Color(textColor), + ), + label = { + Text( + modifier = Modifier.Companion.fillMaxWidth().sharedElement( + rememberSharedContentState("node_shortname_${node.num}"), + animatedContentScope + ), + text = node.user.shortName.ifEmpty { "???" }, + fontSize = MaterialTheme.typography.labelLarge.fontSize, + textDecoration = TextDecoration.Companion.LineThrough.takeIf { isIgnored }, + textAlign = TextAlign.Companion.Center, + ) + }, + onClick = {}, + interactionSource = inputChipInteractionSource, + ) + } + Box( + modifier = Modifier.Companion + .matchParentSize() + .combinedClickable( + onClick = { onAction(NodeMenuAction.MoreDetails(node)) }, + onLongClick = { menuExpanded = true }, + interactionSource = inputChipInteractionSource, + indication = null, + ) + ) + } + NodeMenu( + expanded = menuExpanded, + node = node, + showFullMenu = !isThisNode && isConnected, + onDismissMenuRequest = { menuExpanded = false }, + onAction = { + menuExpanded = false + onAction(it) + } + ) +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt index 462c9a615..b6060d808 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetail.kt @@ -15,9 +15,15 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.geeksville.mesh.ui +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,13 +38,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.automirrored.outlined.VolumeMute import androidx.compose.material.icons.filled.Air import androidx.compose.material.icons.filled.BlurOn import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChargingStation import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Height import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyOff @@ -56,16 +66,23 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.SignalCellularAlt import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.filled.Thermostat import androidx.compose.material.icons.filled.WaterDrop import androidx.compose.material.icons.filled.Work import androidx.compose.material.icons.outlined.Navigation import androidx.compose.material.icons.outlined.NoCell +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.twotone.Person import androidx.compose.material.icons.twotone.Verified +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -83,6 +100,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -104,10 +122,12 @@ import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.isUnmessageableRole import com.geeksville.mesh.navigation.Route import com.geeksville.mesh.service.ServiceAction +import com.geeksville.mesh.ui.components.NodeActionDialogs +import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.PreferenceCategory +import com.geeksville.mesh.ui.components.SharedTransitionPreview import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider import com.geeksville.mesh.ui.radioconfig.NavCard -import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.UnitConversions.calculateDewPoint import com.geeksville.mesh.util.UnitConversions.toTempString import com.geeksville.mesh.util.formatAgo @@ -129,11 +149,14 @@ private enum class LogsType( TRACEROUTE(R.string.traceroute_log, Icons.Default.Route, Route.TracerouteLog) } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun NodeDetailScreen( modifier: Modifier = Modifier, viewModel: MetricsViewModel = hiltViewModel(), uiViewModel: UIViewModel = hiltViewModel(), + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, onNavigate: (Route) -> Unit = {}, ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -168,10 +191,17 @@ fun NodeDetailScreen( when (action) { is Route -> onNavigate(action) is ServiceAction -> viewModel.onServiceAction(action) + is NodeMenuAction -> { + uiViewModel.handleNodeMenuAction(action) + } + + else -> debug("Unhandled action: $action") } }, modifier = modifier, metricsAvailability = availabilities, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, onShared = { share = true } @@ -194,6 +224,8 @@ private fun NodeDetailList( metricsState: MetricsState, onAction: (Any) -> Unit = {}, metricsAvailability: BooleanArray, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, onShared: () -> Unit = {} ) { LazyColumn( @@ -203,13 +235,13 @@ private fun NodeDetailList( if (metricsState.deviceHardware != null) { item { PreferenceCategory(stringResource(R.string.device)) { - DeviceDetailsContent(metricsState) + DeviceDetailsContent(metricsState, sharedTransitionScope, animatedContentScope) } } } item { PreferenceCategory(stringResource(R.string.details)) { - NodeDetailsContent(node) + NodeDetailsContent(node, sharedTransitionScope, animatedContentScope) } } node.metadata?.firmwareVersion?.let { firmwareVersion -> @@ -296,13 +328,14 @@ private fun NodeDetailList( @Composable private fun NodeDetailRow( + modifier: Modifier = Modifier, label: String, icon: ImageVector, value: String, iconTint: Color = MaterialTheme.colorScheme.onSurface ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -320,15 +353,31 @@ private fun NodeDetailRow( } } +@Suppress("LongMethod") @Composable private fun DeviceActions( isLocal: Boolean = false, node: Node, onShared: () -> Unit, - onAction: (ServiceAction) -> Unit, + onAction: (Any) -> Unit, ) { + var displayFavoriteDialog by remember { mutableStateOf(false) } + var displayIgnoreDialog by remember { mutableStateOf(false) } + var displayRemoveDialog by remember { mutableStateOf(false) } + NodeActionDialogs( + node = node, + displayFavoriteDialog = displayFavoriteDialog, + displayIgnoreDialog = displayIgnoreDialog, + displayRemoveDialog = displayRemoveDialog, + onDismissMenuRequest = { + displayFavoriteDialog = false + displayIgnoreDialog = false + displayRemoveDialog = false + }, + onAction = onAction, + ) PreferenceCategory(text = stringResource(R.string.actions)) - NavCard( + NodeActionButton( title = stringResource(id = R.string.share_contact), icon = Icons.Default.Share, enabled = true, @@ -336,35 +385,94 @@ private fun DeviceActions( ) if (!isLocal) { - NavCard( + NodeActionButton( title = stringResource(id = R.string.request_metadata), icon = Icons.Default.Memory, enabled = true, onClick = { onAction(ServiceAction.GetDeviceMetadata(node.num)) } ) + NodeActionButton( + title = stringResource(id = R.string.exchange_position), + icon = Icons.Default.LocationOn, + enabled = true, + onClick = { onAction(NodeMenuAction.RequestPosition(node)) } + ) + NodeActionButton( + title = stringResource(id = R.string.exchange_userinfo), + icon = Icons.Default.Person, + enabled = true, + onClick = { onAction(NodeMenuAction.RequestUserInfo(node)) } + ) + NodeActionButton( + title = stringResource(id = R.string.traceroute), + icon = Icons.Default.Route, + enabled = true, + onClick = { onAction(NodeMenuAction.TraceRoute(node)) } + ) + NodeActionSwitch( + title = stringResource(R.string.favorite), + icon = if (node.isFavorite) { + Icons.Default.Star + } else { + Icons.Default.StarBorder + }, + iconTint = if (node.isFavorite) { + Color.Yellow + } else { + LocalContentColor.current + }, + enabled = true, + checked = node.isFavorite, + onClick = { displayFavoriteDialog = true } + ) + NodeActionSwitch( + title = stringResource(R.string.ignore), + icon = if (node.isIgnored) { + Icons.AutoMirrored.Outlined.VolumeMute + } else { + Icons.AutoMirrored.Default.VolumeUp + }, + enabled = true, + checked = node.isIgnored, + onClick = { displayIgnoreDialog = true } + ) + NodeActionButton( + title = stringResource(id = R.string.remove), + icon = Icons.Default.Delete, + enabled = true, + onClick = { displayRemoveDialog = true } + ) } } @Composable private fun DeviceDetailsContent( state: MetricsState, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, ) { val node = state.node ?: return val deviceHardware = state.deviceHardware ?: return val hwModelName = deviceHardware.displayName val isSupported = deviceHardware.activelySupported - Box( - modifier = Modifier - .size(100.dp) - .padding(4.dp) - .clip(CircleShape) - .background( - color = Color(node.colors.second).copy(alpha = .5f), - shape = CircleShape - ), - contentAlignment = Alignment.Center - ) { - DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize()) + with(sharedTransitionScope) { + Box( + modifier = Modifier + .size(100.dp) + .padding(4.dp) + .clip(CircleShape) + .background( + color = Color(node.colors.second).copy(alpha = .5f), + shape = CircleShape + ) + .sharedElement( + rememberSharedContentState("node_chip_${node.num}"), + animatedContentScope + ), + contentAlignment = Alignment.Center + ) { + DeviceHardwareImage(deviceHardware, Modifier.fillMaxSize()) + } } NodeDetailRow( label = stringResource(R.string.hardware), @@ -384,7 +492,9 @@ fun DeviceHardwareImage( deviceHardware: DeviceHardware, modifier: Modifier = Modifier, ) { - val hwImg = deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) ?: "unknown.svg" + val hwImg = + deviceHardware.images?.getOrNull(1) ?: deviceHardware.images?.getOrNull(0) + ?: "unknown.svg" val imageUrl = "https://flasher.meshtastic.org/img/devices/$hwImg" val listener = object : ImageRequest.Listener { override fun onStart(request: ImageRequest) { @@ -421,6 +531,8 @@ fun DeviceHardwareImage( @Composable private fun NodeDetailsContent( node: Node, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, ) { if (node.mismatchKey) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -444,6 +556,22 @@ private fun NodeDetailsContent( ) Spacer(Modifier.height(16.dp)) } + NodeDetailRow( + label = stringResource(R.string.long_name), + icon = Icons.TwoTone.Person, + value = node.user.longName.ifEmpty { "???" } + ) + with(sharedTransitionScope) { + NodeDetailRow( + modifier = Modifier.sharedElement( + rememberSharedContentState("node_shortname_${node.num}"), + animatedContentScope + ), + label = stringResource(R.string.short_name), + icon = Icons.Outlined.Person, + value = node.user.shortName.ifEmpty { "???" } + ) + } NodeDetailRow( label = stringResource(R.string.node_number), icon = Icons.Default.Numbers, @@ -695,17 +823,109 @@ private fun PowerMetrics(node: Node) = with(node.powerMetrics) { } } +@Composable +fun NodeActionButton( + title: String, + enabled: Boolean, + icon: ImageVector? = null, + iconTint: Color? = null, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .height(48.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = iconTint ?: LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +fun NodeActionSwitch( + title: String, + enabled: Boolean, + checked: Boolean, + icon: ImageVector? = null, + iconTint: Color? = null, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .height(48.dp) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = { onClick() } + ), + shape = MaterialTheme.shapes.large, + interactionSource = interactionSource, + onClick = onClick, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp) + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = title, + modifier = Modifier.size(24.dp), + tint = iconTint ?: LocalContentColor.current, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f) + ) + Switch( + checked = checked, + onCheckedChange = null, + ) + } + } +} + @Preview(showBackground = true) @Composable private fun NodeDetailsPreview( @PreviewParameter(NodePreviewParameterProvider::class) node: Node ) { - AppTheme { + SharedTransitionPreview { sharedTransitionScope, animatedContentScope -> NodeDetailList( node = node, metricsState = MetricsState.Empty, - metricsAvailability = BooleanArray(LogsType.entries.size) { false } + metricsAvailability = BooleanArray(LogsType.entries.size) { false }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt index 512b51aa9..302ece88f 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeItem.kt @@ -18,12 +18,11 @@ package com.geeksville.mesh.ui import android.content.res.Configuration -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize @@ -32,21 +31,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Card import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign @@ -62,16 +56,16 @@ import com.geeksville.mesh.model.DeviceVersion import com.geeksville.mesh.model.Node import com.geeksville.mesh.model.isUnmessageableRole import com.geeksville.mesh.ui.components.NodeKeyStatusIcon -import com.geeksville.mesh.ui.components.NodeMenu import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.NodeStatusIcons +import com.geeksville.mesh.ui.components.SharedTransitionPreview import com.geeksville.mesh.ui.components.SignalInfo import com.geeksville.mesh.ui.compose.ElevationInfo import com.geeksville.mesh.ui.compose.SatelliteCountInfo import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider -import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.toDistanceString +@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun NodeItem( @@ -85,17 +79,17 @@ fun NodeItem( expanded: Boolean = false, currentTimeMillis: Long, isConnected: Boolean = false, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, ) { val isFavorite = remember(thatNode) { thatNode.isFavorite } val isIgnored = thatNode.isIgnored val longName = thatNode.user.longName.ifEmpty { stringResource(id = R.string.unknown_username) } - val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num } val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) } val distance = remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) } - val (textColor, nodeColor) = thatNode.colors val hwInfoString = when (val hwModel = thatNode.user.hwModel) { MeshProtos.HardwareModel.UNSET -> MeshProtos.HardwareModel.UNSET.name @@ -122,7 +116,6 @@ fun NodeItem( thatNode.user.role?.isUnmessageableRole() == true } } - var menuExpanded by remember { mutableStateOf(false) } Card( modifier = modifier @@ -141,46 +134,15 @@ fun NodeItem( .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - val inputChipInteractionSource = remember { MutableInteractionSource() } - Box { - AssistChip( - modifier = Modifier - .width(IntrinsicSize.Min) - .defaultMinSize(minHeight = 32.dp, minWidth = 72.dp), - colors = AssistChipDefaults.assistChipColors( - containerColor = Color(nodeColor), - labelColor = Color(textColor), - ), - label = { - Text( - modifier = Modifier.fillMaxWidth(), - text = thatNode.user.shortName.ifEmpty { "???" }, - fontSize = MaterialTheme.typography.labelLarge.fontSize, - textDecoration = TextDecoration.LineThrough.takeIf { isIgnored }, - textAlign = TextAlign.Center, - ) - }, - onClick = {}, - interactionSource = inputChipInteractionSource, - ) - Box( - modifier = Modifier - .matchParentSize() - .combinedClickable( - onClick = { onAction(NodeMenuAction.MoreDetails(thatNode)) }, - onLongClick = { menuExpanded = true }, - interactionSource = inputChipInteractionSource, - indication = null, - ) - ) - } - NodeMenu( + NodeChip( node = thatNode, - showFullMenu = !isThisNode && isConnected, + isThisNode = isThisNode, + isConnected = isConnected, onAction = onAction, - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, ) + NodeKeyStatusIcon( hasPKC = thatNode.hasPKC, mismatchKey = thatNode.mismatchKey, @@ -309,10 +271,11 @@ fun NodeItem( } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable @Preview(showBackground = false) fun NodeInfoSimplePreview() { - AppTheme { + SharedTransitionPreview { sharedTransitionScope, animatedContentScope -> val thisNode = NodePreviewParameterProvider().values.first() val thatNode = NodePreviewParameterProvider().values.last() NodeItem( @@ -321,11 +284,14 @@ fun NodeInfoSimplePreview() { 1, 0, true, - currentTimeMillis = System.currentTimeMillis() + currentTimeMillis = System.currentTimeMillis(), + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, ) } } +@OptIn(ExperimentalSharedTransitionApi::class) @Composable @Preview( showBackground = true, @@ -335,7 +301,7 @@ fun NodeInfoPreview( @PreviewParameter(NodePreviewParameterProvider::class) thatNode: Node ) { - AppTheme { + SharedTransitionPreview { sharedTransitionScope, animatedContentScope -> val thisNode = NodePreviewParameterProvider().values.first() Column { Text( @@ -349,7 +315,9 @@ fun NodeInfoPreview( distanceUnits = 1, tempInFahrenheit = true, expanded = false, - currentTimeMillis = System.currentTimeMillis() + currentTimeMillis = System.currentTimeMillis(), + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, ) Text( text = "Details Shown", @@ -362,7 +330,9 @@ fun NodeInfoPreview( distanceUnits = 1, tempInFahrenheit = true, expanded = true, - currentTimeMillis = System.currentTimeMillis() + currentTimeMillis = System.currentTimeMillis(), + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt index b06b9d331..5af4c0e00 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeScreen.kt @@ -17,8 +17,10 @@ package com.geeksville.mesh.ui -import android.os.Build +import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi @@ -49,13 +51,15 @@ import com.geeksville.mesh.ui.components.NodeFilterTextField import com.geeksville.mesh.ui.components.NodeMenuAction import com.geeksville.mesh.ui.components.rememberTimeTickWithLifecycle -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun NodeScreen( model: UIViewModel = hiltViewModel(), navigateToMessages: (String) -> Unit, navigateToNodeDetails: (Int) -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, ) { val state by model.nodesUiState.collectAsStateWithLifecycle() @@ -135,6 +139,8 @@ fun NodeScreen( expanded = state.showDetails, currentTimeMillis = currentTimeMillis, isConnected = connectionState.isConnected(), + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope ) } } @@ -144,8 +150,7 @@ fun NodeScreen( AnimatedVisibility( modifier = Modifier.align(androidx.compose.ui.Alignment.BottomEnd), - visible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && - !listState.isScrollInProgress && + visible = !listState.isScrollInProgress && connectionState.isConnected() && shareCapable ) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt index e8793e124..854adc54c 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/components/NodeMenu.kt @@ -45,10 +45,10 @@ import com.geeksville.mesh.model.isUnmessageableRole @Suppress("LongMethod") @Composable fun NodeMenu( + expanded: Boolean, node: Node, showFullMenu: Boolean = false, - onDismissRequest: () -> Unit, - expanded: Boolean = false, + onDismissMenuRequest: () -> Unit, onAction: (NodeMenuAction) -> Unit, ) { val isUnmessageable = if (node.user.hasIsUnmessagable()) { @@ -57,94 +57,69 @@ fun NodeMenu( // for older firmwares node.user.role?.isUnmessageableRole() == true } + var displayFavoriteDialog by remember { mutableStateOf(false) } var displayIgnoreDialog by remember { mutableStateOf(false) } var displayRemoveDialog by remember { mutableStateOf(false) } - if (displayFavoriteDialog) { - SimpleAlertDialog( - title = R.string.favorite, - text = stringResource( - id = if (node.isFavorite) R.string.favorite_remove else R.string.favorite_add, - node.user.longName - ), - onConfirm = { - displayFavoriteDialog = false - onAction(NodeMenuAction.Favorite(node)) - }, - onDismiss = { - displayFavoriteDialog = false - } - ) + val dialogDismissRequest = { + displayFavoriteDialog = false + displayIgnoreDialog = false + displayRemoveDialog = false + onDismissMenuRequest() } - if (displayIgnoreDialog) { - SimpleAlertDialog( - title = R.string.ignore, - text = stringResource( - id = if (node.isIgnored) R.string.ignore_remove else R.string.ignore_add, - node.user.longName - ), - onConfirm = { - displayIgnoreDialog = false - onAction(NodeMenuAction.Ignore(node)) - }, - onDismiss = { - displayIgnoreDialog = false - } - ) - } - if (displayRemoveDialog) { - SimpleAlertDialog( - title = R.string.remove, - text = R.string.remove_node_text, - onConfirm = { - displayRemoveDialog = false - onAction(NodeMenuAction.Remove(node)) - }, - onDismiss = { - displayRemoveDialog = false - } - ) + val onMenuAction: (NodeMenuAction) -> Unit = { + dialogDismissRequest() + onDismissMenuRequest() + onAction(it) } + NodeActionDialogs( + node = node, + displayFavoriteDialog = displayFavoriteDialog, + displayIgnoreDialog = displayIgnoreDialog, + displayRemoveDialog = displayRemoveDialog, + onDismissMenuRequest = dialogDismissRequest, + onAction = onMenuAction + ) DropdownMenu( modifier = Modifier.background(MaterialTheme.colorScheme.background.copy(alpha = 1f)), expanded = expanded, - onDismissRequest = onDismissRequest, + onDismissRequest = onDismissMenuRequest, ) { if (showFullMenu) { if (!isUnmessageable) { DropdownMenuItem( onClick = { - onDismissRequest() - onAction(NodeMenuAction.DirectMessage(node)) + dialogDismissRequest() + onMenuAction(NodeMenuAction.DirectMessage(node)) }, text = { Text(stringResource(R.string.direct_message)) } ) } DropdownMenuItem( onClick = { - onDismissRequest() - onAction(NodeMenuAction.RequestUserInfo(node)) + dialogDismissRequest() + onMenuAction(NodeMenuAction.RequestUserInfo(node)) }, text = { Text(stringResource(R.string.exchange_userinfo)) } ) DropdownMenuItem( onClick = { - onDismissRequest() - onAction(NodeMenuAction.RequestPosition(node)) + dialogDismissRequest() + onMenuAction(NodeMenuAction.RequestPosition(node)) }, text = { Text(stringResource(R.string.exchange_position)) } ) DropdownMenuItem( onClick = { - onDismissRequest() - onAction(NodeMenuAction.TraceRoute(node)) + dialogDismissRequest() + onMenuAction(NodeMenuAction.TraceRoute(node)) }, text = { Text(stringResource(R.string.traceroute)) } ) DropdownMenuItem( onClick = { - onDismissRequest() + dialogDismissRequest() displayFavoriteDialog = true }, enabled = !node.isIgnored, @@ -160,7 +135,7 @@ fun NodeMenu( ) DropdownMenuItem( onClick = { - onDismissRequest() + dialogDismissRequest() displayIgnoreDialog = true }, text = { @@ -170,7 +145,7 @@ fun NodeMenu( Checkbox( checked = node.isIgnored, onCheckedChange = { - onDismissRequest() + dialogDismissRequest() displayIgnoreDialog = true }, modifier = Modifier.size(24.dp), @@ -179,7 +154,7 @@ fun NodeMenu( ) DropdownMenuItem( onClick = { - onDismissRequest() + dialogDismissRequest() displayRemoveDialog = true }, enabled = !node.isIgnored, @@ -189,22 +164,72 @@ fun NodeMenu( } DropdownMenuItem( onClick = { - onDismissRequest() - onAction(NodeMenuAction.Share(node)) + dialogDismissRequest() + onMenuAction(NodeMenuAction.Share(node)) }, text = { Text(stringResource(R.string.share_contact)) } ) DropdownMenuItem( onClick = { - onDismissRequest() - onAction(NodeMenuAction.MoreDetails(node)) + dialogDismissRequest() + onMenuAction(NodeMenuAction.MoreDetails(node)) }, text = { Text(stringResource(R.string.more_details)) } ) } } +@Composable +fun NodeActionDialogs( + node: Node, + displayFavoriteDialog: Boolean, + displayIgnoreDialog: Boolean, + displayRemoveDialog: Boolean, + onDismissMenuRequest: () -> Unit, + onAction: (NodeMenuAction) -> Unit +) { + if (displayFavoriteDialog) { + SimpleAlertDialog( + title = R.string.favorite, + text = stringResource( + id = if (node.isFavorite) R.string.favorite_remove else R.string.favorite_add, + node.user.longName + ), + onConfirm = { + onDismissMenuRequest() + onAction(NodeMenuAction.Favorite(node)) + }, + onDismiss = onDismissMenuRequest + ) + } + if (displayIgnoreDialog) { + SimpleAlertDialog( + title = R.string.ignore, + text = stringResource( + id = if (node.isIgnored) R.string.ignore_remove else R.string.ignore_add, + node.user.longName + ), + onConfirm = { + onDismissMenuRequest() + onAction(NodeMenuAction.Ignore(node)) + }, + onDismiss = onDismissMenuRequest + ) + } + if (displayRemoveDialog) { + SimpleAlertDialog( + title = R.string.remove, + text = R.string.remove_node_text, + onConfirm = { + onDismissMenuRequest() + onAction(NodeMenuAction.Remove(node)) + }, + onDismiss = onDismissMenuRequest + ) + } +} + sealed class NodeMenuAction { data class Remove(val node: Node) : NodeMenuAction() data class Ignore(val node: Node) : NodeMenuAction() diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt b/app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt new file mode 100644 index 000000000..63d8546a0 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/components/SharedTransitionPreview.kt @@ -0,0 +1,47 @@ +/* + * 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 . + */ + +package com.geeksville.mesh.ui.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable +import com.geeksville.mesh.ui.theme.AppTheme + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun SharedTransitionPreview( + content: @Composable (SharedTransitionScope, AnimatedContentScope) -> Unit +) { + AppTheme { + SharedTransitionLayout { + val sharedTransitionScope: SharedTransitionScope = this + AnimatedContent( + targetState = true, + label = "SharedTransitionPreview", + ) { + if (it) { + val animatedContentScope: AnimatedContentScope = this + content(sharedTransitionScope, animatedContentScope) + } + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/components/UserAvatar.kt b/app/src/main/java/com/geeksville/mesh/ui/components/UserAvatar.kt deleted file mode 100644 index f2946b878..000000000 --- a/app/src/main/java/com/geeksville/mesh/ui/components/UserAvatar.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * 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 . - */ - -package com.geeksville.mesh.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import com.geeksville.mesh.model.Node -import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider -import com.geeksville.mesh.ui.theme.AppTheme - -@Composable -fun UserAvatar( - node: Node, - modifier: Modifier = Modifier, - onClick: () -> Unit = {} -) { - val textMeasurer = rememberTextMeasurer() - val textStyle = MaterialTheme.typography.labelLarge.copy( - fontWeight = FontWeight.Normal, - ) - val textLayoutResult = remember { - textMeasurer.measure(text = "MMMM", style = textStyle) - } - - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .size(with(LocalDensity.current) { textLayoutResult.size.width.toDp() }) - .background( - color = Color(node.colors.second), - shape = CircleShape - ) - .clickable(onClick = onClick) - ) { - Text( - text = node.user.shortName.ifEmpty { "?" }, - color = Color(node.colors.first), - style = textStyle, - textAlign = TextAlign.Center, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun AvatarPreview( - @PreviewParameter(NodePreviewParameterProvider::class) - node: Node -) { - AppTheme { - UserAvatar(node) - } -} diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 4626b8fa3..0050814ed 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -18,6 +18,9 @@ package com.geeksville.mesh.ui.message import android.content.ClipData +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -86,6 +89,7 @@ import kotlinx.coroutines.launch private const val MESSAGE_CHARACTER_LIMIT = 200 +@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongMethod", "CyclomaticComplexMethod") @Composable internal fun MessageScreen( @@ -94,7 +98,9 @@ internal fun MessageScreen( viewModel: UIViewModel = hiltViewModel(), navigateToMessages: (String) -> Unit, navigateToNodeDetails: (Int) -> Unit, - onNavigateBack: () -> Unit + onNavigateBack: () -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, ) { val coroutineScope = rememberCoroutineScope() val clipboardManager = LocalClipboard.current @@ -222,25 +228,21 @@ internal fun MessageScreen( viewModel = viewModel, contactKey = contactKey, onNodeMenuAction = { action -> - when (action) { - is NodeMenuAction.Remove -> viewModel.removeNode(action.node.num) - is NodeMenuAction.Ignore -> viewModel.ignoreNode(action.node) - is NodeMenuAction.Favorite -> viewModel.favoriteNode(action.node) - is NodeMenuAction.DirectMessage -> { - val hasPKC = - viewModel.ourNodeInfo.value?.hasPKC == true && action.node.hasPKC - val channel = - if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else action.node.channel - navigateToMessages("$channel${action.node.user.id}") + when (action) { + is NodeMenuAction.DirectMessage -> { + val hasPKC = + viewModel.ourNodeInfo.value?.hasPKC == true && action.node.hasPKC + val channel = + if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else action.node.channel + navigateToMessages("$channel${action.node.user.id}") + } + is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num) + is NodeMenuAction.Share -> sharedContact = action.node + else -> viewModel.handleNodeMenuAction(action) } - - is NodeMenuAction.RequestUserInfo -> viewModel.requestUserInfo(action.node.num) - is NodeMenuAction.RequestPosition -> viewModel.requestPosition(action.node.num) - is NodeMenuAction.TraceRoute -> viewModel.requestTraceroute(action.node.num) - is NodeMenuAction.MoreDetails -> navigateToNodeDetails(action.node.num) - is NodeMenuAction.Share -> sharedContact = action.node - } - } + }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, ) } } @@ -393,7 +395,8 @@ private fun TextInput( message.value = it } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .onFocusEvent { isFocused = it.isFocused }, enabled = enabled, placeholder = { Text(stringResource(id = R.string.send_text)) }, diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt index abb5fe4a4..99a62ea60 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageItem.kt @@ -17,7 +17,10 @@ package com.geeksville.mesh.ui.message.components +import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -52,13 +55,14 @@ import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.model.Node +import com.geeksville.mesh.ui.NodeChip import com.geeksville.mesh.ui.components.AutoLinkText -import com.geeksville.mesh.ui.components.UserAvatar +import com.geeksville.mesh.ui.components.NodeMenuAction +import com.geeksville.mesh.ui.components.SharedTransitionPreview import com.geeksville.mesh.ui.preview.NodePreviewParameterProvider -import com.geeksville.mesh.ui.theme.AppTheme @Suppress("LongMethod", "CyclomaticComplexMethod") -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) @Composable internal fun MessageItem( node: Node, @@ -69,9 +73,12 @@ internal fun MessageItem( modifier: Modifier = Modifier, onClick: () -> Unit = {}, onLongClick: () -> Unit = {}, - onChipClick: () -> Unit = {}, + onAction: (NodeMenuAction) -> Unit = {}, onStatusClick: () -> Unit = {}, onSendReaction: (String) -> Unit = {}, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, + isConnected: Boolean, ) = Row( modifier = modifier .fillMaxWidth() @@ -91,12 +98,16 @@ internal fun MessageItem( Modifier.padding(start = 8.dp, top = 8.dp, end = 0.dp, bottom = 6.dp) } if (!fromLocal) { - UserAvatar( + NodeChip( node = node, modifier = Modifier - .padding(start = 8.dp, top = 8.dp) - .align(Alignment.Top), - ) { onChipClick() } + .padding(start = 8.dp, end = 4.dp), + onAction = onAction, + isConnected = isConnected, + isThisNode = false, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, + ) } Card( modifier = Modifier @@ -166,16 +177,20 @@ internal fun MessageItem( } } +@OptIn(ExperimentalSharedTransitionApi::class) @PreviewLightDark @Composable private fun MessageItemPreview() { - AppTheme { + SharedTransitionPreview { sharedTransitionScope, animatedContentScope -> MessageItem( node = NodePreviewParameterProvider().values.first(), messageText = stringResource(R.string.sample_message), messageTime = "10:00", messageStatus = MessageStatus.DELIVERED, selected = false, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, + isConnected = true, ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt index 4a94c9bb5..cdf89fdb4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/components/MessageList.kt @@ -18,6 +18,9 @@ package com.geeksville.mesh.ui.message.components import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -48,13 +51,13 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.geeksville.mesh.DataPacket import com.geeksville.mesh.MessageStatus import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.Reaction import com.geeksville.mesh.model.Message import com.geeksville.mesh.model.UIViewModel -import com.geeksville.mesh.ui.components.NodeMenu import com.geeksville.mesh.ui.components.NodeMenuAction import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.collectLatest @@ -105,6 +108,7 @@ fun DeliveryInfo( containerColor = MaterialTheme.colorScheme.surface ) +@OptIn(ExperimentalSharedTransitionApi::class) @Suppress("LongMethod") @Composable internal fun MessageList( @@ -113,9 +117,11 @@ internal fun MessageList( selectedIds: MutableState>, onUnreadChanged: (Long) -> Unit, onSendReaction: (String, Int) -> Unit, - onNodeMenuAction: (NodeMenuAction) -> Unit = {}, + onNodeMenuAction: (NodeMenuAction) -> Unit, viewModel: UIViewModel, - contactKey: String + contactKey: String, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope, ) { val haptics = LocalHapticFeedback.current val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } } @@ -155,6 +161,9 @@ internal fun MessageList( value += uuid } + val nodes by viewModel.nodeList.collectAsStateWithLifecycle() + val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false) + LazyColumn( modifier = modifier.fillMaxSize(), state = listState, @@ -163,12 +172,16 @@ internal fun MessageList( items(messages, key = { it.uuid }) { msg -> val fromLocal = msg.node.user.id == DataPacket.ID_LOCAL val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } - + var node by remember { + mutableStateOf(nodes.find { it.num == msg.node.num } ?: msg.node) + } + LaunchedEffect(nodes) { + node = nodes.find { it.num == msg.node.num } ?: msg.node + } ReactionRow(fromLocal, msg.emojis) { showReactionDialog = msg.emojis } Box(Modifier.wrapContentSize(Alignment.TopStart)) { - var expandedNodeMenu by remember { mutableStateOf(false) } MessageItem( - node = msg.node, + node = node, messageText = msg.text, messageTime = msg.time, messageStatus = msg.status, @@ -178,20 +191,12 @@ internal fun MessageList( selectedIds.toggle(msg.uuid) haptics.performHapticFeedback(HapticFeedbackType.LongPress) }, - onChipClick = { - if (msg.node.num != 0) { - expandedNodeMenu = true - } - }, + onAction = onNodeMenuAction, onStatusClick = { showStatusDialog = msg }, onSendReaction = { onSendReaction(it, msg.packetId) }, - ) - NodeMenu( - node = msg.node, - showFullMenu = true, - onDismissRequest = { expandedNodeMenu = false }, - expanded = expandedNodeMenu, - onAction = onNodeMenuAction, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope, + isConnected = isConnected ) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/ChannelSettingsItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/ChannelSettingsItemList.kt index d54bd4ebd..59d2d8570 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/ChannelSettingsItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/ChannelSettingsItemList.kt @@ -302,7 +302,7 @@ fun ChannelSettingsItemList( fontSize = 10.sp, ) Text( - text = stringResource(R.string.manuel_position_request), + text = stringResource(R.string.manual_position_request), color = MaterialTheme.colorScheme.onBackground, fontSize = 10.sp, ) diff --git a/app/src/main/proto b/app/src/main/proto index 91484534a..0b32ce24f 160000 --- a/app/src/main/proto +++ b/app/src/main/proto @@ -1 +1 @@ -Subproject commit 91484534a58cb4da8ab68ac046f1e76fd1936bf7 +Subproject commit 0b32ce24f029f69635026aec9428b5c8176e2ce1 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b1def8aef..30253efe0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -613,7 +613,7 @@ Periodic position and telemetry broadcast Secondary No periodic telemetry broadcast - Manual position request required + Manual position request required Press and drag to reorder Set Region Unmute