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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 734 additions and 374 deletions

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