From 9d5cf4776287e4909191869f05b933a433a4d7a1 Mon Sep 17 00:00:00 2001 From: Andre K Date: Mon, 9 Sep 2024 06:15:27 -0300 Subject: [PATCH] refactor: migrate `MessagesFragment` RecyclerView to Compose (#1133) --- app/build.gradle | 5 +- .../geeksville/mesh/database/dao/PacketDao.kt | 2 +- .../java/com/geeksville/mesh/model/NodeDB.kt | 9 +- .../java/com/geeksville/mesh/model/UIState.kt | 33 +- .../com/geeksville/mesh/ui/MessageItem.kt | 171 ++++++++++ .../com/geeksville/mesh/ui/MessageListView.kt | 96 ++++++ .../geeksville/mesh/ui/MessagesFragment.kt | 323 ++++-------------- .../res/layout/adapter_message_layout.xml | 74 ---- app/src/main/res/layout/messages_fragment.xml | 4 +- 9 files changed, 374 insertions(+), 343 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt create mode 100644 app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt delete mode 100644 app/src/main/res/layout/adapter_message_layout.xml diff --git a/app/build.gradle b/app/build.gradle index 98da875c0..89313d4a9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -152,7 +152,6 @@ dependencies { implementation 'androidx.core:core-location-altitude:1.0.0-alpha02' implementation 'androidx.fragment:fragment-ktx:1.8.3' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.viewpager2:viewpager2:1.1.0' @@ -264,9 +263,11 @@ dependencies { // MQTT implementation "org.eclipse.paho:org.eclipse.paho.client.mqttv3:1.2.5" - //detekt ktlint formatting + // detekt ktlint formatting detektPlugins("io.gitlab.arturbosch.detekt:detekt-formatting:1.23.7") + // https://github.com/Calvin-LL/AutoLinkText + implementation 'sh.calvin.autolinktext:autolinktext:1.1.1' } ksp { diff --git a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt index 0edcd5341..8f4317a09 100644 --- a/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt +++ b/app/src/main/java/com/geeksville/mesh/database/dao/PacketDao.kt @@ -72,7 +72,7 @@ interface PacketDao { SELECT * FROM packet WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM MyNodeInfo)) AND port_num = 1 AND contact_key = :contact - ORDER BY received_time ASC + ORDER BY received_time DESC """ ) fun getMessagesFrom(contact: String): Flow> diff --git a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt index ec576bd49..6958c9a24 100644 --- a/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt +++ b/app/src/main/java/com/geeksville/mesh/model/NodeDB.kt @@ -3,6 +3,7 @@ package com.geeksville.mesh.model import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshUser import com.geeksville.mesh.MyNodeInfo import com.geeksville.mesh.NodeInfo import com.geeksville.mesh.database.dao.NodeInfoDao @@ -43,6 +44,9 @@ class NodeDB @Inject constructor( val nodeDBbyID: StateFlow> get() = _nodeDBbyID val nodes get() = nodeDBbyID + private val _users = MutableStateFlow>(mapOf()) + val users: StateFlow> get() = _users + init { nodeInfoDao.getMyNodeInfo().onEach { _myNodeInfo.value = it } .launchIn(processLifecycle.coroutineScope) @@ -54,7 +58,10 @@ class NodeDB @Inject constructor( _myId.value = ourNodeInfo?.user?.id }.launchIn(processLifecycle.coroutineScope) - nodeInfoDao.nodeDBbyID().onEach { _nodeDBbyID.value = it } + nodeInfoDao.nodeDBbyID().onEach { + _nodeDBbyID.value = it + _users.value = it.mapValues { node -> node.value.user } + } .launchIn(processLifecycle.coroutineScope) } diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 7a4c4e5ec..2761f4852 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -113,6 +113,16 @@ data class NodesUiState( } } +data class Message( + val uuid: Long, + val receivedTime: Long, + val user: MeshUser, + val text: String, + val time: Long, + val read: Boolean, + val status: MessageStatus?, +) + @HiltViewModel class UIViewModel @Inject constructor( private val app: Application, @@ -229,7 +239,28 @@ class UIViewModel @Inject constructor( debug("ViewModel created") } - fun getMessagesFrom(contactKey: String) = packetRepository.getMessagesFrom(contactKey) + fun getMessagesFrom(contactKey: String) = combine( + nodeDB.users, + packetRepository.getMessagesFrom(contactKey), + ) { users, packets -> + packets.map { + val defaultUser = MeshUser( + it.data.from ?: DataPacket.ID_LOCAL, + app.getString(R.string.unknown_username), + app.getString(R.string.unknown_node_short_name), + MeshProtos.HardwareModel.UNSET, + ) + Message( + uuid = it.uuid, + receivedTime = it.received_time, + user = users[it.data.from] ?: defaultUser, + text = it.data.text.orEmpty(), + time = it.data.time, + read = it.read, + status = it.data.status, + ) + } + } @OptIn(ExperimentalCoroutinesApi::class) val waypoints = packetRepository.getWaypoints().mapLatest { list -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt b/app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt new file mode 100644 index 000000000..ba0fb7530 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt @@ -0,0 +1,171 @@ +package com.geeksville.mesh.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +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.theme.AppTheme +import com.geeksville.mesh.ui.theme.HyperlinkBlue +import sh.calvin.autolinktext.AutoLinkText +import sh.calvin.autolinktext.TextRuleDefaults + +@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 = {}, +) { + 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, + ), + textRules = TextRuleDefaults.defaultList().map { + it.copy( + style = SpanStyle( + color = HyperlinkBlue, + textDecoration = TextDecoration.Underline + ) + ) + } + ) + 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) { + val icon = when (messageStatus) { + MessageStatus.RECEIVED -> R.drawable.ic_twotone_how_to_reg_24 + MessageStatus.QUEUED -> R.drawable.ic_twotone_cloud_upload_24 + MessageStatus.DELIVERED -> R.drawable.cloud_on + MessageStatus.ENROUTE -> R.drawable.ic_twotone_cloud_24 + MessageStatus.ERROR -> R.drawable.cloud_off + else -> R.drawable.ic_twotone_warning_24 + } + Icon( + imageVector = ImageVector.vectorResource(id = icon), + contentDescription = stringResource(R.string.message_delivery_status), + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + } + } + } +} + +@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, + ) + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt new file mode 100644 index 000000000..8ade74e1b --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt @@ -0,0 +1,96 @@ +package com.geeksville.mesh.ui + +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.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import com.geeksville.mesh.DataPacket +import com.geeksville.mesh.model.Message +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import java.util.Date + +@Composable +internal fun MessageListView( + messages: List, + selectedList: List, + onClick: (Message) -> Unit, + onLongClick: (Message) -> Unit, + onChipClick: (Message) -> Unit, + onUnreadChanged: (Long) -> Unit, +) { + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = messages.indexOfLast { !it.read }.coerceAtLeast(0) + ) + AutoScrollToBottom(listState, messages) + UpdateUnreadCount(listState, messages, onUnreadChanged) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + reverseLayout = true, + // contentPadding = PaddingValues(8.dp) + ) { + items(messages, key = { it.uuid }) { msg -> + val selected by remember { derivedStateOf { selectedList.contains(msg) } } + + MessageItem( + shortName = msg.user.shortName.takeIf { msg.user.id != DataPacket.ID_LOCAL }, + messageText = msg.text, + messageTime = getShortDateTime(Date(msg.time)), + messageStatus = msg.status, + selected = selected, + onClick = { onClick(msg) }, + onLongClick = { onLongClick(msg) }, + onChipClick = { onChipClick(msg) }, + ) + } + } +} + +@Composable +private fun AutoScrollToBottom( + listState: LazyListState, + list: List, + 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, + 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) + } + } + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt index 53cf72bcf..46d2ad6e4 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/MessagesFragment.kt @@ -1,8 +1,5 @@ package com.geeksville.mesh.ui -import android.graphics.Color -import android.graphics.Rect -import android.graphics.drawable.GradientDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -10,12 +7,10 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button -import android.widget.ImageView -import android.widget.TextView -import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode -import androidx.cardview.widget.CardView +import androidx.compose.runtime.getValue +import androidx.compose.runtime.toMutableStateList import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.allViews @@ -24,25 +19,21 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.asLiveData +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.geeksville.mesh.DataPacket -import com.geeksville.mesh.MessageStatus -import com.geeksville.mesh.NodeInfo -import com.geeksville.mesh.R import com.geeksville.mesh.android.Logging -import com.geeksville.mesh.database.entity.Packet +import com.geeksville.mesh.R import com.geeksville.mesh.database.entity.QuickChatAction -import com.geeksville.mesh.databinding.AdapterMessageLayoutBinding import com.geeksville.mesh.databinding.MessagesFragmentBinding +import com.geeksville.mesh.model.Message import com.geeksville.mesh.model.UIViewModel import com.geeksville.mesh.model.getChannel +import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.Utf8ByteLengthFilter -import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Date @@ -80,206 +71,29 @@ class MessagesFragment : Fragment(), Logging { private val model: UIViewModel by activityViewModels() - // Provide a direct reference to each of the views within a data item - // Used to cache the views within the item layout for fast access - class ViewHolder(itemView: AdapterMessageLayoutBinding) : - RecyclerView.ViewHolder(itemView.root) { - val username: Chip = itemView.username - val messageText: TextView = itemView.messageText - val messageTime: TextView = itemView.messageTime - val messageStatusIcon: ImageView = itemView.messageStatusIcon - val card: CardView = itemView.Card + private lateinit var contactKey: String + + private val selectedList = emptyList().toMutableStateList() + + private fun onClick(message: Message) { + if (actionMode != null) { + onLongClick(message) + } } - private val messagesAdapter = object : RecyclerView.Adapter() { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { - val inflater = LayoutInflater.from(requireContext()) - - // Inflate the custom layout - val contactViewBinding = AdapterMessageLayoutBinding.inflate(inflater, parent, false) - - // Return a new holder instance - return ViewHolder(contactViewBinding) + private fun onLongClick(message: Message) { + if (actionMode == null) { + actionMode = (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) } - - var messages = listOf() - var selectedList = ArrayList() - val layoutManager get() = binding.messageListView.layoutManager as LinearLayoutManager - - fun scrollToBottom() { - if (itemCount > 0) layoutManager.scrollToPosition(itemCount - 1) + selectedList.apply { + if (contains(message)) remove(message) else add(message) } - - fun scrollToFirstUnreadMessage() { - val position = messages.indexOfFirst { !it.read } - if (position >= 0) { - val rect = Rect() - binding.toolbar.getGlobalVisibleRect(rect) - val toolbarOffset = rect.bottom - val offset = binding.messageListView.height - toolbarOffset - - layoutManager.scrollToPositionWithOffset(position, offset) - } else { - scrollToBottom() - } - } - - fun clearUnreadCount() { - val firstUnreadItem = messages.firstOrNull { !it.read } ?: return - val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() - if (lastVisibleItemPosition != RecyclerView.NO_POSITION) { - val lastVisibleItem = messages[lastVisibleItemPosition] - val contactKey = lastVisibleItem.contact_key - val timestamp = lastVisibleItem.received_time - - if (timestamp >= firstUnreadItem.received_time) { - model.clearUnreadCount(contactKey, timestamp) - } - } - } - - override fun getItemCount(): Int = messages.size - - override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val packet = messages[position] - val msg = packet.data - val nodes = model.nodeDB.nodes.value - val node = nodes[msg.from] - // Determine if this is my message (originated on this device) - val isLocal = msg.from == DataPacket.ID_LOCAL - - // Set cardview offset and color. - val marginParams = holder.card.layoutParams as ViewGroup.MarginLayoutParams - val messageOffset = resources.getDimensionPixelOffset(R.dimen.message_offset) - if (isLocal) { - marginParams.leftMargin = messageOffset - marginParams.rightMargin = 0 - holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_END - context?.let { - holder.card.setCardBackgroundColor( - ContextCompat.getColor( - it, - R.color.colorMyMsg - ) - ) - } - } else { - marginParams.rightMargin = messageOffset - marginParams.leftMargin = 0 - holder.messageText.textAlignment = View.TEXT_ALIGNMENT_TEXT_START - context?.let { - holder.card.setCardBackgroundColor( - ContextCompat.getColor( - it, - R.color.colorMsg - ) - ) - } - } - - // Hide the username chip for my messages - if (isLocal) { - holder.username.visibility = View.GONE - } else { - holder.username.visibility = View.VISIBLE - // If we can't find the sender, just use the ID - val user = node?.user - holder.username.text = user?.shortName ?: msg.from - - holder.username.setOnClickListener { - node?.let { openNodeInfo(it) } - } - } - - if (msg.errorMessage != null) { - context?.let { holder.card.setCardBackgroundColor(Color.RED) } - holder.messageText.text = msg.errorMessage - } else { - holder.messageText.text = msg.text - } - - holder.messageTime.text = getShortDateTime(Date(msg.time)) - - val icon = when (msg.status) { - MessageStatus.RECEIVED -> R.drawable.ic_twotone_how_to_reg_24 - MessageStatus.QUEUED -> R.drawable.ic_twotone_cloud_upload_24 - MessageStatus.DELIVERED -> R.drawable.cloud_on - MessageStatus.ENROUTE -> R.drawable.ic_twotone_cloud_24 - MessageStatus.ERROR -> R.drawable.cloud_off - else -> null - } - - if (icon != null && isLocal) { - holder.messageStatusIcon.setImageResource(icon) - holder.messageStatusIcon.visibility = View.VISIBLE - } else - holder.messageStatusIcon.visibility = View.GONE - - holder.messageStatusIcon.setOnClickListener { - if (isAdded) { - Toast.makeText(context, "${msg.status}", Toast.LENGTH_SHORT).show() - } - } - - holder.itemView.setOnLongClickListener { - clickItem(position) - if (actionMode == null) { - actionMode = - (activity as AppCompatActivity).startSupportActionMode(actionModeCallback) - } - true - } - holder.itemView.setOnClickListener { - if (actionMode != null) clickItem(position) - } - - if (selectedList.contains(packet)) { - holder.itemView.background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 32f - setColor(Color.rgb(127, 127, 127)) - } - } else { - holder.itemView.background = GradientDrawable().apply { - shape = GradientDrawable.RECTANGLE - cornerRadius = 32f - setColor( - ContextCompat.getColor( - holder.itemView.context, - R.color.colorAdvancedBackground - ) - ) - } - } - } - - private fun clickItem(position: Int) { - val message = messages[position] - selectedList.apply { - if (contains(message)) remove(message) else add(message) - } - if (selectedList.isEmpty()) { - // finish action mode when no items selected - actionMode?.finish() - } else { - // show total items selected on action mode title - actionMode?.title = selectedList.size.toString() - } - notifyItemChanged(position) - } - - /// Called when our node DB changes - fun onMessagesChanged(messages: List) { - val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() - val shouldScrollToUnread = lastVisibleItemPosition <= 0 - val shouldScrollToBottom = lastVisibleItemPosition == itemCount - 1 - - this.messages = messages - notifyDataSetChanged() // FIXME, this is super expensive and redraws all messages - - if (shouldScrollToBottom) scrollToBottom() - if (shouldScrollToUnread) scrollToFirstUnreadMessage() + if (selectedList.isEmpty()) { + // finish action mode when no items selected + actionMode?.finish() + } else { + // show total items selected on action mode title + actionMode?.title = selectedList.size.toString() } } @@ -296,6 +110,7 @@ class MessagesFragment : Fragment(), Logging { return binding.root } + @Suppress("LongMethod", "CyclomaticComplexMethod") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -303,10 +118,9 @@ class MessagesFragment : Fragment(), Logging { parentFragmentManager.popBackStack() } - val contactKey = arguments?.getString("contactKey").toString() + contactKey = arguments?.getString("contactKey").toString() val contactName = arguments?.getString("contactName").toString() - val title = contactName - binding.toolbar.title = title + binding.toolbar.title = contactName if (contactKey[1] == '!') { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -325,7 +139,6 @@ class MessagesFragment : Fragment(), Logging { val str = binding.messageInputText.text.toString().trim() if (str.isNotEmpty()) { model.sendMessage(str, contactKey) - messagesAdapter.scrollToBottom() } binding.messageInputText.setText("") // blow away the string the user just entered // requireActivity().hideKeyboard() @@ -339,32 +152,21 @@ class MessagesFragment : Fragment(), Logging { // max payload length should be 237 bytes but anything over 235 bytes crashes the radio binding.messageInputText.filters += Utf8ByteLengthFilter(234) - binding.messageListView.adapter = messagesAdapter - val layoutManager = LinearLayoutManager(requireContext()) - layoutManager.stackFromEnd = true // We want the last rows to always be shown - binding.messageListView.layoutManager = layoutManager + binding.messageListView.setContent { + val messages by model.getMessagesFrom(contactKey).collectAsStateWithLifecycle(listOf()) - binding.messageListView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - super.onScrollStateChanged(recyclerView, newState) - - if (newState == RecyclerView.SCROLL_STATE_IDLE) { - messagesAdapter.clearUnreadCount() + AppTheme { + if (messages.isNotEmpty()) { + MessageListView( + messages = messages, + selectedList = selectedList, + onClick = ::onClick, + onLongClick = ::onLongClick, + onChipClick = ::openNodeInfo, + onUnreadChanged = { model.clearUnreadCount(contactKey, it) }, + ) } } - - override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { - super.onScrolled(recyclerView, dx, dy) - - if (dy == 0) { - messagesAdapter.clearUnreadCount() - } - } - }) - - model.getMessagesFrom(contactKey).asLiveData().observe(viewLifecycleOwner) { - debug("New messages received: ${it.size}") - messagesAdapter.onMessagesChanged(it) } // If connection state _OR_ myID changes we have to fix our ability to edit outgoing messages @@ -406,7 +208,6 @@ class MessagesFragment : Fragment(), Logging { binding.messageInputText.setSelection(newText.length) } else { model.sendMessage(action.message, contactKey) - messagesAdapter.scrollToBottom() } } binding.quickChatLayout.addView(button) @@ -437,7 +238,6 @@ class MessagesFragment : Fragment(), Logging { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { when (item.itemId) { R.id.deleteButton -> { - val selectedList = messagesAdapter.selectedList val deleteMessagesString = resources.getQuantityString( R.plurals.delete_messages, selectedList.size, @@ -454,27 +254,26 @@ class MessagesFragment : Fragment(), Logging { } .show() } - - R.id.selectAllButton -> { - // if all selected -> unselect all - if (messagesAdapter.selectedList.size == messagesAdapter.messages.size) { - messagesAdapter.selectedList.clear() - mode.finish() - } else { - // else --> select all - messagesAdapter.selectedList.clear() - messagesAdapter.selectedList.addAll(messagesAdapter.messages) + R.id.selectAllButton -> lifecycleScope.launch { + model.getMessagesFrom(contactKey).firstOrNull()?.let { messages -> + if (selectedList.size == messages.size) { + // if all selected -> unselect all + selectedList.clear() + mode.finish() + } else { + // else --> select all + selectedList.clear() + selectedList.addAll(messages) + } + actionMode?.title = selectedList.size.toString() } - actionMode?.title = messagesAdapter.selectedList.size.toString() - messagesAdapter.notifyDataSetChanged() } - R.id.resendButton -> { + R.id.resendButton -> lifecycleScope.launch { debug("User clicked resendButton") - val selectedList = messagesAdapter.selectedList var resendText = "" selectedList.forEach { - resendText = resendText + it.data.text + System.lineSeparator() + resendText = resendText + it.text + System.lineSeparator() } if (resendText != "") resendText = resendText.substring(0, resendText.length - 1) @@ -486,15 +285,15 @@ class MessagesFragment : Fragment(), Logging { } override fun onDestroyActionMode(mode: ActionMode) { - messagesAdapter.selectedList.clear() - messagesAdapter.notifyDataSetChanged() + selectedList.clear() actionMode = null } } - private fun openNodeInfo(node: NodeInfo) { - parentFragmentManager.popBackStack() - model.focusUserNode(node) + private fun openNodeInfo(msg: Message) = lifecycleScope.launch { + model.nodeList.firstOrNull()?.find { it.user?.id == msg.user.id }?.let { node -> + parentFragmentManager.popBackStack() + model.focusUserNode(node) + } } - } diff --git a/app/src/main/res/layout/adapter_message_layout.xml b/app/src/main/res/layout/adapter_message_layout.xml deleted file mode 100644 index 3c9b0e059..000000000 --- a/app/src/main/res/layout/adapter_message_layout.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/messages_fragment.xml b/app/src/main/res/layout/messages_fragment.xml index 16ed04d60..72b3a7ad1 100644 --- a/app/src/main/res/layout/messages_fragment.xml +++ b/app/src/main/res/layout/messages_fragment.xml @@ -18,7 +18,7 @@ app:layout_constraintTop_toTopOf="parent" app:navigationIcon="?android:attr/homeAsUpIndicator"/> - - +