refactor: migrate MessagesFragment RecyclerView to Compose (#1133)

This commit is contained in:
Andre K 2024-09-09 06:15:27 -03:00 committed by GitHub
parent f2b06bd8d8
commit 9d5cf47762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 374 additions and 343 deletions

View file

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

View file

@ -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<List<Packet>>

View file

@ -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<Map<String, NodeInfo>> get() = _nodeDBbyID
val nodes get() = nodeDBbyID
private val _users = MutableStateFlow<Map<String, MeshUser?>>(mapOf())
val users: StateFlow<Map<String, MeshUser?>> 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)
}

View file

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

View file

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

View file

@ -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<Message>,
selectedList: List<Message>,
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 <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

@ -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<Message>().toMutableStateList()
private fun onClick(message: Message) {
if (actionMode != null) {
onLongClick(message)
}
}
private val messagesAdapter = object : RecyclerView.Adapter<ViewHolder>() {
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<Packet>()
var selectedList = ArrayList<Packet>()
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<Packet>) {
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)
}
}
}

View file

@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:contentDescription="@string/message_delivery_status">
<androidx.cardview.widget.CardView
android:id="@+id/Card"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="@dimen/message_offset"
app:cardBackgroundColor="@color/colorMsg"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardUseCompatPadding="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.chip.Chip
android:id="@+id/username"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="@string/some_username"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/messageText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:autoLink="all"
android:text="@string/sample_message"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/username"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/messageStatusIcon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:contentDescription="@string/message_reception_state"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/messageText"
app:srcCompat="@drawable/cloud_on" />
<TextView
android:id="@+id/messageTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:contentDescription="@string/message_reception_time"
android:text="3 minutes ago"
android:textSize="12sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/messageStatusIcon"
app:layout_constraintTop_toBottomOf="@id/messageText" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>

View file

@ -18,7 +18,7 @@
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="?android:attr/homeAsUpIndicator"/>
<androidx.recyclerview.widget.RecyclerView
<androidx.compose.ui.platform.ComposeView
android:id="@+id/messageListView"
android:layout_width="0dp"
android:layout_height="0dp"
@ -29,7 +29,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar">
</androidx.recyclerview.widget.RecyclerView>
</androidx.compose.ui.platform.ComposeView>
<HorizontalScrollView
android:id="@+id/quickChatView"