mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat: Add Jetpack Paging 3 support for messages and threads/contacts (#3795)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
552097888f
commit
5e8c9794eb
12 changed files with 551 additions and 250 deletions
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) =
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue