feat(node): consolidate node chip and menu (#1941)

This commit is contained in:
James Rich
2025-05-26 19:36:32 -05:00
committed by GitHub
parent 62e2368887
commit 6332b3bd42
18 changed files with 734 additions and 374 deletions

View File

@@ -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) {

View File

@@ -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<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?> 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

View File

@@ -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<Route.Contacts> {
ContactsScreen(
uIViewModel,
onNavigate = { navController.navigate(Route.Messages(it)) }
)
}
composable<Route.Nodes> {
NodeScreen(
model = uIViewModel,
navigateToMessages = { navController.navigate(Route.Messages(it)) },
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
)
}
composable<Route.Map> {
MapView(uIViewModel)
}
composable<Route.Channels> {
ChannelScreen(uIViewModel)
}
composable<Route.Settings>(
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<Route.Contacts> {
ContactsScreen(
uIViewModel,
onNavigate = { navController.navigate(Route.Messages(it)) }
)
}
composable<Route.Nodes> {
NodeScreen(
model = uIViewModel,
navigateToMessages = { navController.navigate(Route.Messages(it)) },
navigateToNodeDetails = { navController.navigate(Route.NodeDetail(it)) },
sharedTransitionScope = this@SharedTransitionLayout,
this@composable,
)
}
composable<Route.Map> {
MapView(uIViewModel)
}
composable<Route.Channels> {
ChannelScreen(uIViewModel)
}
composable<Route.Settings>(
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<Route.DebugPanel> {
DebugScreen()
}
composable<Route.Messages>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
action = "android.intent.action.VIEW"
},
)
) { backStackEntry ->
val args = backStackEntry.toRoute<Route.Messages>()
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<Route.QuickChat> {
QuickChatScreen()
}
nodeDetailGraph(navController, uIViewModel)
radioConfigGraph(navController, uIViewModel)
composable<Route.Share>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
action = "android.intent.action.VIEW"
}
)
) { backStackEntry ->
val message = backStackEntry.toRoute<Route.Share>().message
ShareScreen(uIViewModel) {
navController.navigate(Route.Messages(it, message)) {
popUpTo<Route.Share> { inclusive = true }
composable<Route.DebugPanel> {
DebugScreen()
}
composable<Route.Messages>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/messages/{contactKey}?message={message}"
action = "android.intent.action.VIEW"
},
)
) { backStackEntry ->
val args = backStackEntry.toRoute<Route.Messages>()
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<Route.QuickChat> {
QuickChatScreen()
}
nodeDetailGraph(navController, uIViewModel, sharedTransitionScope = this@SharedTransitionLayout)
radioConfigGraph(navController, uIViewModel)
composable<Route.Share>(
deepLinks = listOf(
navDeepLink {
uriPattern = "$DEEP_LINK_BASE_URI/share?message={message}"
action = "android.intent.action.VIEW"
}
)
) { backStackEntry ->
val message = backStackEntry.toRoute<Route.Share>().message
ShareScreen(uIViewModel) {
navController.navigate(Route.Messages(it, message)) {
popUpTo<Route.Share> { inclusive = true }
}
}
}
}

View File

@@ -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<Graph.NodeDetailGraph>(
startDestination = Route.NodeDetail(),
@@ -54,7 +58,12 @@ fun NavGraphBuilder.nodeDetailGraph(
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry<Graph.NodeDetailGraph>()
}
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))

View File

@@ -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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
)
}

View File

@@ -15,9 +15,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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,
)
}
}

View File

@@ -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,
)
}
}

View File

@@ -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
) {

View File

@@ -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()

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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)) },

View File

@@ -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,
)
}
}

View File

@@ -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<Set<Long>>,
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
)
}
}

View File

@@ -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,
)

View File

@@ -613,7 +613,7 @@
<string name="primary_channel_feature">Periodic position and telemetry broadcast</string>
<string name="secondary">Secondary</string>
<string name="secondary_no_telemetry">No periodic telemetry broadcast</string>
<string name="manuel_position_request">Manual position request required</string>
<string name="manual_position_request">Manual position request required</string>
<string name="press_and_drag">Press and drag to reorder</string>
<string name="set_region">Set Region</string>
<string name="unmute">Unmute</string>