mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor: migrate MessagesFragment RecyclerView to Compose (#1133)
This commit is contained in:
parent
f2b06bd8d8
commit
9d5cf47762
9 changed files with 374 additions and 343 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
171
app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt
Normal file
171
app/src/main/java/com/geeksville/mesh/ui/MessageItem.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
96
app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt
Normal file
96
app/src/main/java/com/geeksville/mesh/ui/MessageListView.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue