mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: move message files to separate package
This commit is contained in:
parent
2234f5a713
commit
96087cca59
8 changed files with 12 additions and 8 deletions
453
app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
Normal file
453
app/src/main/java/com/geeksville/mesh/ui/message/Message.kt
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.message
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.TextField
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.focus.onFocusEvent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.android.Logging
|
||||
import com.geeksville.mesh.database.entity.QuickChatAction
|
||||
import com.geeksville.mesh.model.UIViewModel
|
||||
import com.geeksville.mesh.model.getChannel
|
||||
import com.geeksville.mesh.ui.message.components.MessageList
|
||||
import com.geeksville.mesh.ui.components.NodeKeyStatusIcon
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal fun FragmentManager.navigateToMessages(contactKey: String, message: String = "") {
|
||||
val messagesFragment = MessagesFragment().apply {
|
||||
arguments = bundleOf("contactKey" to contactKey, "message" to message)
|
||||
}
|
||||
beginTransaction()
|
||||
.add(R.id.mainActivityLayout, messagesFragment)
|
||||
.addToBackStack(null)
|
||||
.commit()
|
||||
}
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MessagesFragment : Fragment(), Logging {
|
||||
private val model: UIViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val contactKey = arguments?.getString("contactKey").toString()
|
||||
val message = arguments?.getString("message").toString()
|
||||
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setBackgroundColor(ContextCompat.getColor(context, R.color.colorAdvancedBackground))
|
||||
setContent {
|
||||
AppTheme {
|
||||
MessageScreen(
|
||||
contactKey = contactKey,
|
||||
message = message,
|
||||
viewModel = model,
|
||||
) { parentFragmentManager.popBackStack() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class MessageMenuAction {
|
||||
data object ClipboardCopy : MessageMenuAction()
|
||||
data object Delete : MessageMenuAction()
|
||||
data object Dismiss : MessageMenuAction()
|
||||
data object SelectAll : MessageMenuAction()
|
||||
}
|
||||
|
||||
@Suppress("LongMethod", "CyclomaticComplexMethod")
|
||||
@Composable
|
||||
internal fun MessageScreen(
|
||||
contactKey: String,
|
||||
message: String,
|
||||
viewModel: UIViewModel = hiltViewModel(),
|
||||
onNavigateBack: () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
val channelIndex = contactKey[0].digitToIntOrNull()
|
||||
val nodeId = contactKey.substring(1)
|
||||
val channelName = channelIndex?.let { viewModel.channels.value.getChannel(it)?.name }
|
||||
?: "Unknown Channel"
|
||||
|
||||
val title = when (nodeId) {
|
||||
DataPacket.ID_BROADCAST -> channelName
|
||||
else -> viewModel.getUser(nodeId).longName
|
||||
}
|
||||
|
||||
// if (channelIndex != DataPacket.PKC_CHANNEL_INDEX && nodeId != DataPacket.ID_BROADCAST) {
|
||||
// subtitle = "(ch: $channelIndex - $channelName)"
|
||||
// }
|
||||
|
||||
val selectedIds = rememberSaveable { mutableStateOf(emptySet<Long>()) }
|
||||
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
|
||||
|
||||
val connState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val quickChat by viewModel.quickChatActions.collectAsStateWithLifecycle()
|
||||
val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf())
|
||||
|
||||
val messageInput = rememberSaveable(stateSaver = TextFieldValue.Saver) {
|
||||
mutableStateOf(TextFieldValue(message))
|
||||
}
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
if (showDeleteDialog) {
|
||||
DeleteMessageDialog(
|
||||
size = selectedIds.value.size,
|
||||
onConfirm = {
|
||||
viewModel.deleteMessages(selectedIds.value.toList())
|
||||
selectedIds.value = emptySet()
|
||||
showDeleteDialog = false
|
||||
},
|
||||
onDismiss = { showDeleteDialog = false }
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
if (inSelectionMode) {
|
||||
ActionModeTopBar(selectedIds.value) { action ->
|
||||
when (action) {
|
||||
MessageMenuAction.ClipboardCopy -> coroutineScope.launch {
|
||||
val copiedText = messages
|
||||
.filter { it.uuid in selectedIds.value }
|
||||
.joinToString("\n") { it.text }
|
||||
|
||||
clipboardManager.setText(AnnotatedString(copiedText))
|
||||
selectedIds.value = emptySet()
|
||||
}
|
||||
|
||||
MessageMenuAction.Delete -> {
|
||||
showDeleteDialog = true
|
||||
}
|
||||
|
||||
MessageMenuAction.Dismiss -> selectedIds.value = emptySet()
|
||||
MessageMenuAction.SelectAll -> {
|
||||
if (selectedIds.value.size == messages.size) {
|
||||
selectedIds.value = emptySet()
|
||||
} else {
|
||||
selectedIds.value = messages.map { it.uuid }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessageTopBar(title, channelIndex, onNavigateBack)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
val isConnected = connState.isConnected()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(start = 8.dp, end = 8.dp, bottom = 4.dp),
|
||||
) {
|
||||
QuickChatRow(isConnected, quickChat) { action ->
|
||||
if (action.mode == QuickChatAction.Mode.Append) {
|
||||
val originalText = messageInput.value.text
|
||||
val needsSpace = !originalText.endsWith(' ') && originalText.isNotEmpty()
|
||||
val newText = buildString {
|
||||
append(originalText)
|
||||
if (needsSpace) append(' ')
|
||||
append(action.message)
|
||||
}
|
||||
messageInput.value = TextFieldValue(newText, TextRange(newText.length))
|
||||
} else {
|
||||
viewModel.sendMessage(action.message, contactKey)
|
||||
}
|
||||
}
|
||||
TextInput(isConnected, messageInput) { viewModel.sendMessage(it, contactKey) }
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (messages.isNotEmpty()) {
|
||||
MessageList(
|
||||
messages = messages,
|
||||
selectedIds = selectedIds,
|
||||
onUnreadChanged = { viewModel.clearUnreadCount(contactKey, it) },
|
||||
contentPadding = innerPadding,
|
||||
onSendReaction = { emoji, id -> viewModel.sendReaction(emoji, id, contactKey) },
|
||||
) {
|
||||
// TODO onCLick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeleteMessageDialog(
|
||||
size: Int,
|
||||
onConfirm: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
) {
|
||||
val deleteMessagesString = pluralStringResource(R.plurals.delete_messages, size, size)
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
backgroundColor = MaterialTheme.colors.background,
|
||||
text = {
|
||||
Text(
|
||||
text = deleteMessagesString,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) {
|
||||
Text(stringResource(R.string.delete))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionModeTopBar(
|
||||
selectedList: Set<Long>,
|
||||
onAction: (MessageMenuAction) -> Unit,
|
||||
) = TopAppBar(
|
||||
title = { Text(text = selectedList.size.toString()) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onAction(MessageMenuAction.Dismiss) }) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.clear),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { onAction(MessageMenuAction.ClipboardCopy) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ContentCopy,
|
||||
contentDescription = stringResource(id = R.string.copy)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onAction(MessageMenuAction.Delete) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(id = R.string.delete)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { onAction(MessageMenuAction.SelectAll) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.SelectAll,
|
||||
contentDescription = stringResource(id = R.string.select_all)
|
||||
)
|
||||
}
|
||||
},
|
||||
backgroundColor = MaterialTheme.colors.primary,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun MessageTopBar(
|
||||
title: String,
|
||||
channelIndex: Int?,
|
||||
onNavigateBack: () -> Unit
|
||||
) = TopAppBar(
|
||||
title = { Text(text = title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(id = R.string.navigate_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (channelIndex == DataPacket.PKC_CHANNEL_INDEX) {
|
||||
NodeKeyStatusIcon(hasPKC = true, mismatchKey = false)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun QuickChatRow(
|
||||
enabled: Boolean,
|
||||
actions: List<QuickChatAction>,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (QuickChatAction) -> Unit
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = modifier,
|
||||
) {
|
||||
items(actions, key = { it.uuid }) { action ->
|
||||
Button(
|
||||
onClick = { onClick(action) },
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = colorResource(id = R.color.colorMyMsg),
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = action.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TextInput(
|
||||
enabled: Boolean,
|
||||
message: MutableState<TextFieldValue>,
|
||||
modifier: Modifier = Modifier,
|
||||
maxSize: Int = 200,
|
||||
onClick: (String) -> Unit = {}
|
||||
) = Column(modifier) {
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextField(
|
||||
value = message.value,
|
||||
onValueChange = {
|
||||
if (it.text.toByteArray().size <= maxSize) {
|
||||
message.value = it
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusEvent { isFocused = it.isFocused },
|
||||
enabled = enabled,
|
||||
placeholder = { Text(stringResource(id = R.string.send_text)) },
|
||||
maxLines = 3,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
colors = TextFieldDefaults.textFieldColors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
if (message.value.text.isNotEmpty()) {
|
||||
onClick(message.value.text)
|
||||
message.value = TextFieldValue("")
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(48.dp),
|
||||
enabled = enabled,
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.Send,
|
||||
contentDescription = stringResource(id = R.string.send_text),
|
||||
modifier = Modifier.scale(scale = 1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isFocused) {
|
||||
Text(
|
||||
text = "${message.value.text.toByteArray().size}/$maxSize",
|
||||
style = MaterialTheme.typography.caption,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(top = 4.dp, end = 72.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun TextInputPreview() {
|
||||
AppTheme {
|
||||
TextInput(
|
||||
enabled = true,
|
||||
message = remember { mutableStateOf(TextFieldValue("")) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.message.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Chip
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.LocalTextStyle
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Cloud
|
||||
import androidx.compose.material.icons.twotone.CloudDone
|
||||
import androidx.compose.material.icons.twotone.CloudOff
|
||||
import androidx.compose.material.icons.twotone.CloudUpload
|
||||
import androidx.compose.material.icons.twotone.HowToReg
|
||||
import androidx.compose.material.icons.twotone.Warning
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geeksville.mesh.MessageStatus
|
||||
import com.geeksville.mesh.R
|
||||
import com.geeksville.mesh.ui.components.AutoLinkText
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun MessageItem(
|
||||
shortName: String?,
|
||||
messageText: String?,
|
||||
messageTime: String,
|
||||
messageStatus: MessageStatus?,
|
||||
selected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = {},
|
||||
onLongClick: () -> Unit = {},
|
||||
onChipClick: () -> Unit = {},
|
||||
onStatusClick: () -> Unit = {},
|
||||
) {
|
||||
val fromLocal = shortName == null
|
||||
val messageColor = if (fromLocal) R.color.colorMyMsg else R.color.colorMsg
|
||||
val (topStart, topEnd) = if (fromLocal) 12.dp to 4.dp else 4.dp to 12.dp
|
||||
val messageModifier = if (fromLocal) {
|
||||
Modifier.padding(start = 48.dp, top = 8.dp, end = 8.dp, bottom = 6.dp)
|
||||
} else {
|
||||
Modifier.padding(start = 8.dp, top = 8.dp, end = 48.dp, bottom = 6.dp)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.background(color = if (selected) Color.Gray else MaterialTheme.colors.background)
|
||||
.fillMaxWidth()
|
||||
.then(messageModifier),
|
||||
elevation = 4.dp,
|
||||
shape = RoundedCornerShape(topStart, topEnd, 12.dp, 12.dp),
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
color = colorResource(id = messageColor),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (shortName != null) {
|
||||
Chip(
|
||||
onClick = onChipClick,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.width(72.dp),
|
||||
) {
|
||||
Text(
|
||||
text = shortName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
fontSize = MaterialTheme.typography.button.fontSize,
|
||||
fontWeight = FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
) {
|
||||
// Text(
|
||||
// text = longName ?: stringResource(id = R.string.unknown_username),
|
||||
// color = MaterialTheme.colors.onSurface,
|
||||
// fontSize = MaterialTheme.typography.button.fontSize,
|
||||
// )
|
||||
AutoLinkText(
|
||||
text = messageText.orEmpty(),
|
||||
style = LocalTextStyle.current.copy(
|
||||
color = LocalContentColor.current,
|
||||
),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = messageTime,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
fontSize = MaterialTheme.typography.caption.fontSize,
|
||||
)
|
||||
AnimatedVisibility(visible = fromLocal) {
|
||||
Icon(
|
||||
imageVector = when (messageStatus) {
|
||||
MessageStatus.RECEIVED -> Icons.TwoTone.HowToReg
|
||||
MessageStatus.QUEUED -> Icons.TwoTone.CloudUpload
|
||||
MessageStatus.DELIVERED -> Icons.TwoTone.CloudDone
|
||||
MessageStatus.ENROUTE -> Icons.TwoTone.Cloud
|
||||
MessageStatus.ERROR -> Icons.TwoTone.CloudOff
|
||||
else -> Icons.TwoTone.Warning
|
||||
},
|
||||
contentDescription = stringResource(R.string.message_delivery_status),
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.clickable { onStatusClick() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun MessageItemPreview() {
|
||||
AppTheme {
|
||||
MessageItem(
|
||||
shortName = stringResource(R.string.some_username),
|
||||
// longName = stringResource(R.string.unknown_username),
|
||||
messageText = stringResource(R.string.sample_message),
|
||||
messageTime = "10:00",
|
||||
messageStatus = MessageStatus.DELIVERED,
|
||||
selected = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.message.components
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.geeksville.mesh.DataPacket
|
||||
import com.geeksville.mesh.model.Message
|
||||
import com.geeksville.mesh.ui.components.SimpleAlertDialog
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
|
||||
@Composable
|
||||
internal fun MessageList(
|
||||
messages: List<Message>,
|
||||
selectedIds: MutableState<Set<Long>>,
|
||||
onUnreadChanged: (Long) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
onSendReaction: (String, Int) -> Unit,
|
||||
onClick: (Message) -> Unit = {}
|
||||
) {
|
||||
val inSelectionMode by remember { derivedStateOf { selectedIds.value.isNotEmpty() } }
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = messages.indexOfLast { !it.read }.coerceAtLeast(0)
|
||||
)
|
||||
AutoScrollToBottom(listState, messages)
|
||||
UpdateUnreadCount(listState, messages, onUnreadChanged)
|
||||
|
||||
var showStatusDialog by remember { mutableStateOf<Message?>(null) }
|
||||
if (showStatusDialog != null) {
|
||||
val msg = showStatusDialog ?: return
|
||||
val (title, text) = msg.getStatusStringRes()
|
||||
SimpleAlertDialog(title = title, text = text) { showStatusDialog = null }
|
||||
}
|
||||
|
||||
fun toggle(uuid: Long) = if (selectedIds.value.contains(uuid)) {
|
||||
selectedIds.value -= uuid
|
||||
} else {
|
||||
selectedIds.value += uuid
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
contentPadding = contentPadding
|
||||
) {
|
||||
items(messages, key = { it.uuid }) { msg ->
|
||||
val fromLocal = msg.user.id == DataPacket.ID_LOCAL
|
||||
val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } }
|
||||
|
||||
ReactionRow(fromLocal, msg.emojis) { onSendReaction(it, msg.packetId) }
|
||||
MessageItem(
|
||||
shortName = msg.user.shortName.takeIf { !fromLocal },
|
||||
messageText = msg.text,
|
||||
messageTime = msg.time,
|
||||
messageStatus = msg.status,
|
||||
selected = selected,
|
||||
onClick = { if (inSelectionMode) toggle(msg.uuid) },
|
||||
onLongClick = { toggle(msg.uuid) },
|
||||
onChipClick = { onClick(msg) },
|
||||
onStatusClick = { showStatusDialog = msg }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T> AutoScrollToBottom(
|
||||
listState: LazyListState,
|
||||
list: List<T>,
|
||||
itemThreshold: Int = 3,
|
||||
) = with(listState) {
|
||||
val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } }
|
||||
if (shouldAutoScroll) {
|
||||
LaunchedEffect(list) {
|
||||
if (!isScrollInProgress) {
|
||||
scrollToItem(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun UpdateUnreadCount(
|
||||
listState: LazyListState,
|
||||
messages: List<Message>,
|
||||
onUnreadChanged: (Long) -> Unit,
|
||||
) {
|
||||
val unreadIndex by remember { derivedStateOf { messages.indexOfLast { !it.read } } }
|
||||
val firstVisibleItemIndex by remember { derivedStateOf { listState.firstVisibleItemIndex } }
|
||||
|
||||
if (unreadIndex != -1 && firstVisibleItemIndex != -1 && firstVisibleItemIndex <= unreadIndex) {
|
||||
LaunchedEffect(firstVisibleItemIndex, unreadIndex) {
|
||||
snapshotFlow { listState.firstVisibleItemIndex }
|
||||
.debounce(timeoutMillis = 500L)
|
||||
.collectLatest { index ->
|
||||
val lastVisibleItem = messages[index]
|
||||
onUnreadChanged(lastVisibleItem.receivedTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
/*
|
||||
* Copyright (c) 2024 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.message.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Badge
|
||||
import androidx.compose.material.BadgedBox
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.twotone.Add
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geeksville.mesh.MeshProtos
|
||||
import com.geeksville.mesh.database.entity.Reaction
|
||||
import com.geeksville.mesh.ui.components.EmojiPicker
|
||||
import com.geeksville.mesh.ui.theme.AppTheme
|
||||
|
||||
@Composable
|
||||
private fun ReactionItem(
|
||||
emoji: String,
|
||||
isAddEmojiItem: Boolean = false,
|
||||
emojiCount: Int = 1,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
BadgedBox(
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp),
|
||||
badge = {
|
||||
if (emojiCount > 1) {
|
||||
Badge(
|
||||
backgroundColor = MaterialTheme.colors.onBackground,
|
||||
contentColor = MaterialTheme.colors.background,
|
||||
) {
|
||||
Text(
|
||||
fontWeight = FontWeight.Bold,
|
||||
text = emojiCount.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable { onClick() },
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
elevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (isAddEmojiItem) {
|
||||
Icon(
|
||||
imageVector = Icons.TwoTone.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = emoji,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clip(CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun ReactionRow(
|
||||
fromLocal: Boolean,
|
||||
reactions: List<Reaction> = emptyList(),
|
||||
onSendReaction: (String) -> Unit = {}
|
||||
) {
|
||||
val emojiList by remember(reactions) {
|
||||
mutableStateOf(
|
||||
reduceEmojis(
|
||||
if (fromLocal) {
|
||||
reactions.map { it.emoji }
|
||||
} else {
|
||||
reactions.map { it.emoji }.reversed()
|
||||
}
|
||||
).entries
|
||||
)
|
||||
}
|
||||
var showEmojiPickerDialog by remember { mutableStateOf(false) }
|
||||
if (showEmojiPickerDialog) {
|
||||
EmojiPickerDialog(
|
||||
onConfirm = {
|
||||
showEmojiPickerDialog = false
|
||||
onSendReaction(it)
|
||||
},
|
||||
onDismiss = { showEmojiPickerDialog = false }
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
fun AddEmojiItem() {
|
||||
ReactionItem(
|
||||
emoji = "\uD83D\uDE42",
|
||||
isAddEmojiItem = true,
|
||||
onClick = {
|
||||
showEmojiPickerDialog = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmojiList() {
|
||||
emojiList.forEach { entry ->
|
||||
ReactionItem(
|
||||
emoji = entry.key,
|
||||
emojiCount = entry.value,
|
||||
onClick = {
|
||||
onSendReaction(entry.key)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
FlowRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (fromLocal) Arrangement.End else Arrangement.Start
|
||||
) {
|
||||
EmojiList()
|
||||
AddEmojiItem()
|
||||
}
|
||||
}
|
||||
|
||||
fun reduceEmojis(emojis: List<String>): Map<String, Int> = emojis.groupingBy { it }.eachCount()
|
||||
|
||||
@Composable
|
||||
fun EmojiPickerDialog(
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit = {},
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
EmojiPicker(
|
||||
onConfirm = onConfirm,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
fun ReactionItemPreview() {
|
||||
AppTheme {
|
||||
Column(
|
||||
modifier = Modifier.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
ReactionItem(emoji = "\uD83D\uDE42")
|
||||
ReactionItem(emoji = "\uD83D\uDE42", emojiCount = 2)
|
||||
ReactionItem(emoji = "\uD83D\uDE42", isAddEmojiItem = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun ReactionRowPreview() {
|
||||
AppTheme {
|
||||
ReactionRow(
|
||||
fromLocal = true, reactions = listOf(
|
||||
Reaction(
|
||||
replyId = 1,
|
||||
user = MeshProtos.User.getDefaultInstance(),
|
||||
emoji = "\uD83D\uDE42",
|
||||
timestamp = 1L
|
||||
),
|
||||
Reaction(
|
||||
replyId = 1,
|
||||
user = MeshProtos.User.getDefaultInstance(),
|
||||
emoji = "\uD83D\uDE42",
|
||||
timestamp = 1L
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue