refactor: move message files to separate package

This commit is contained in:
andrekir 2024-12-03 09:14:32 -03:00
parent 2234f5a713
commit 96087cca59
8 changed files with 12 additions and 8 deletions

View 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("")) },
)
}
}

View file

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

View file

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

View file

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