feat: Add Jetpack Paging 3 support for messages and threads/contacts (#3795)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Mac DeCourcy 2025-11-24 06:14:05 -08:00 committed by GitHub
parent 552097888f
commit 5e8c9794eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 551 additions and 250 deletions

View file

@ -17,6 +17,7 @@
package com.geeksville.mesh.ui.contact
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@ -36,6 +37,7 @@ import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material.icons.rounded.QrCode2
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
@ -63,6 +65,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import com.geeksville.mesh.model.Contact
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
@ -70,6 +76,7 @@ import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.model.util.formatMuteRemainingTime
import org.meshtastic.core.model.util.getChannel
import org.meshtastic.core.strings.Res
import org.meshtastic.core.strings.cancel
import org.meshtastic.core.strings.close_selection
@ -116,7 +123,27 @@ fun ContactsScreen(
val isSelectionModeActive by remember { derivedStateOf { selectedContactKeys.isNotEmpty() } }
// State for contacts list
val contacts by viewModel.contactList.collectAsStateWithLifecycle()
val pagedContacts = viewModel.contactListPaged.collectAsLazyPagingItems()
// Create channel placeholders (always show broadcast contacts, even when empty)
val channels by viewModel.channels.collectAsStateWithLifecycle()
val channelPlaceholders =
remember(channels.settingsList.size) {
(0 until channels.settingsList.size).map { ch ->
Contact(
contactKey = "$ch^all",
shortName = "$ch",
longName = channels.getChannel(ch)?.name ?: "Channel $ch",
lastMessageTime = "",
lastMessageText = "",
unreadCount = 0,
messageCount = 0,
isMuted = false,
isUnmessageable = false,
nodeColors = null,
)
}
}
val contactsListState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
@ -131,7 +158,11 @@ fun ContactsScreen(
// Derived state for selected contacts and count
val selectedContacts =
remember(contacts, selectedContactKeys) { contacts.filter { it.contactKey in selectedContactKeys } }
remember(pagedContacts.itemCount, selectedContactKeys) {
(0 until pagedContacts.itemCount)
.mapNotNull { pagedContacts[it] }
.filter { it.contactKey in selectedContactKeys }
}
val selectedCount = remember(selectedContacts) { selectedContacts.sumOf { it.messageCount } }
val isAllMuted = remember(selectedContacts) { selectedContacts.all { it.isMuted } }
@ -209,15 +240,17 @@ fun ContactsScreen(
onDeleteSelected = { showDeleteDialog = true },
onSelectAll = {
selectedContactKeys.clear()
selectedContactKeys.addAll(contacts.map { it.contactKey })
selectedContactKeys.addAll(
(0 until pagedContacts.itemCount).mapNotNull { pagedContacts[it]?.contactKey },
)
},
isAllMuted = isAllMuted, // Pass the derived state
)
}
val channels by viewModel.channels.collectAsStateWithLifecycle()
ContactListView(
contacts = contacts,
ContactListViewPaged(
contacts = pagedContacts,
channelPlaceholders = channelPlaceholders,
selectedList = selectedContactKeys,
onClick = onContactClick,
onLongClick = onContactLongClick,
@ -435,6 +468,14 @@ fun SelectionToolbar(
)
}
/**
* Non-paginated contact list view.
*
* NOTE: This is kept for ShareScreen which displays a simple contact picker. The main ContactsScreen uses
* [ContactListViewPaged] for better performance.
*
* @see ContactListViewPaged for the paginated version
*/
@Composable
fun ContactListView(
contacts: List<Contact>,
@ -464,3 +505,96 @@ fun ContactListView(
}
}
}
@Composable
fun ContactListViewPaged(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier,
channels: AppOnlyProtos.ChannelSet? = null,
) {
val haptics = LocalHapticFeedback.current
// Handle initial loading state
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return
}
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
// Show channel placeholders first
items(
count = visiblePlaceholders.size,
key = { index -> "placeholder_${visiblePlaceholders[index].contactKey}" },
) { index ->
val placeholder = visiblePlaceholders[index]
val selected by remember { derivedStateOf { selectedList.contains(placeholder.contactKey) } }
ContactItem(
contact = placeholder,
selected = selected,
onClick = { onClick(placeholder) },
onLongClick = {
onLongClick(placeholder)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
channels = channels,
onNodeChipClick = { onNodeChipClick(placeholder) },
)
}
// Show paged contacts
items(count = contacts.itemCount, key = contacts.itemKey { it.contactKey }) { index ->
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
ContactItem(
contact = contact,
selected = selected,
onClick = { onClick(contact) },
onLongClick = {
onLongClick(contact)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
},
channels = channels,
onNodeChipClick = { onNodeChipClick(contact) },
)
}
}
// Loading indicator when loading more contacts
contacts.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
}
}
}
}
@Composable
private fun rememberVisiblePlaceholders(
contacts: LazyPagingItems<Contact>,
channelPlaceholders: List<Contact>,
): List<Contact> {
val contactKeys by
remember(contacts.itemCount) {
derivedStateOf { (0 until contacts.itemCount).mapNotNull { contacts[it]?.contactKey }.toSet() }
}
return remember(channelPlaceholders, contactKeys) {
channelPlaceholders.filter { placeholder -> !contactKeys.contains(placeholder.contactKey) }
}
}

View file

@ -19,11 +19,18 @@ package com.geeksville.mesh.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import com.geeksville.mesh.model.Contact
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.PacketRepository
@ -38,7 +45,7 @@ import org.meshtastic.core.strings.channel_name
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.channelSet
import javax.inject.Inject
import kotlin.collections.map
import kotlin.collections.map as collectionsMap
@HiltViewModel
class ContactsViewModel
@ -55,6 +62,14 @@ constructor(
val channels = radioConfigRepository.channelSetFlow.stateInWhileSubscribed(initialValue = channelSet {})
/**
* Non-paginated contact list.
*
* NOTE: This is kept for ShareScreen which needs a simple, non-paginated list of contacts. The main ContactsScreen
* uses [contactListPaged] instead for better performance with large contact lists.
*
* @see contactListPaged for the paginated version used in ContactsScreen
*/
val contactList =
combine(
nodeRepository.myNodeInfo,
@ -71,7 +86,7 @@ constructor(
contactKey to Packet(0L, myNodeNum, 1, contactKey, 0L, true, data)
}
(contacts + (placeholder - contacts.keys)).values.map { packet ->
(contacts + (placeholder - contacts.keys)).values.collectionsMap { packet ->
val data = packet.data
val contactKey = packet.contact_key
@ -112,6 +127,58 @@ constructor(
}
.stateInWhileSubscribed(initialValue = emptyList())
val contactListPaged: Flow<PagingData<Contact>> =
combine(nodeRepository.myNodeInfo, channels, packetRepository.getContactSettings()) {
myNodeInfo,
channelSet,
settings,
->
Triple(myNodeInfo?.myNodeNum, channelSet, settings)
}
.flatMapLatest { (myNodeNum, channelSet, settings) ->
packetRepository.getContactsPaged().map { pagingData ->
pagingData.map { packet ->
val data = packet.data
val contactKey = packet.contact_key
// Determine if this is my message (originated on this device)
val fromLocal = data.from == DataPacket.ID_LOCAL
val toBroadcast = data.to == DataPacket.ID_BROADCAST
// grab usernames from NodeInfo
val user = getUser(if (fromLocal) data.to else data.from)
val node = getNode(if (fromLocal) data.to else data.from)
val shortName = user.shortName
val longName =
if (toBroadcast) {
channelSet.getChannel(data.channel)?.name ?: getString(Res.string.channel_name)
} else {
user.longName
}
Contact(
contactKey = contactKey,
shortName = if (toBroadcast) "${data.channel}" else shortName,
longName = longName,
lastMessageTime = getShortDate(data.time),
lastMessageText = if (fromLocal) data.text else "$shortName: ${data.text}",
unreadCount = runBlocking(Dispatchers.IO) { packetRepository.getUnreadCount(contactKey) },
messageCount = runBlocking(Dispatchers.IO) { packetRepository.getMessageCount(contactKey) },
isMuted = settings[contactKey]?.isMuted == true,
isUnmessageable = user.isUnmessagable,
nodeColors =
if (!toBroadcast) {
node.colors
} else {
null
},
)
}
}
}
.cachedIn(viewModelScope)
fun getNode(userId: String?) = nodeRepository.getNode(userId ?: DataPacket.ID_BROADCAST)
fun deleteContacts(contacts: List<String>) =