fix: duplicate LazyColumn keys for broadcast contacts (#3840)

This commit is contained in:
Mac DeCourcy 2025-11-28 09:47:27 -08:00 committed by GitHub
parent 1c0dc486e2
commit af9a837511
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 126 additions and 91 deletions

View file

@ -24,8 +24,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.icons.Icons
@ -59,6 +59,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
@ -68,7 +69,6 @@ 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
@ -289,7 +289,7 @@ fun ContactsScreen(
@Suppress("LongMethod")
@Composable
fun MuteNotificationsDialog(
private fun MuteNotificationsDialog(
showDialog: Boolean,
selectedContactKeys: List<String>,
contactSettings: Map<String, ContactSettings>,
@ -384,7 +384,7 @@ fun MuteNotificationsDialog(
}
@Composable
fun DeleteConfirmationDialog(
private fun DeleteConfirmationDialog(
showDialog: Boolean,
selectedCount: Int, // Number of items to be deleted
onDismiss: () -> Unit,
@ -426,7 +426,7 @@ fun DeleteConfirmationDialog(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SelectionToolbar(
private fun SelectionToolbar(
selectedCount: Int,
onCloseSelection: () -> Unit,
onMuteSelected: () -> Unit,
@ -468,27 +468,127 @@ 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>,
private 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
LazyColumn(modifier = Modifier.fillMaxSize(), state = listState) {
items(contacts, key = { it.contactKey }) { contact ->
if (contacts.loadState.refresh is LoadState.Loading && contacts.itemCount == 0) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
return
}
val visiblePlaceholders = rememberVisiblePlaceholders(contacts, channelPlaceholders)
ContactListContentInternal(
contacts = contacts,
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
listState = listState,
modifier = modifier,
channels = channels,
haptics = haptics,
)
}
@Composable
private fun ContactListContentInternal(
contacts: LazyPagingItems<Contact>,
visiblePlaceholders: List<Contact>,
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
listState: LazyListState,
channels: AppOnlyProtos.ChannelSet?,
haptics: HapticFeedback,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize(), state = listState) {
contactListPlaceholdersItems(
visiblePlaceholders = visiblePlaceholders,
selectedList = selectedList,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptics = haptics,
)
contactListPagedItems(
contacts = contacts,
selectedList = selectedList,
onClick = onClick,
onLongClick = onLongClick,
onNodeChipClick = onNodeChipClick,
channels = channels,
haptics = haptics,
)
contactListAppendLoadingItem(contacts = contacts)
}
}
private fun LazyListScope.contactListPlaceholdersItems(
visiblePlaceholders: List<Contact>,
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: AppOnlyProtos.ChannelSet?,
haptics: HapticFeedback,
) {
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) },
)
}
}
private fun LazyListScope.contactListPagedItems(
contacts: LazyPagingItems<Contact>,
selectedList: List<String>,
onClick: (Contact) -> Unit,
onLongClick: (Contact) -> Unit,
onNodeChipClick: (Contact) -> Unit,
channels: AppOnlyProtos.ChannelSet?,
haptics: HapticFeedback,
) {
items(
count = contacts.itemCount,
key = { index ->
val contact = contacts[index]
contact?.let { "${it.contactKey}#$index" } ?: "contact_placeholder_$index"
},
) { index ->
val contact = contacts[index]
if (contact != null) {
val selected by remember { derivedStateOf { selectedList.contains(contact.contactKey) } }
ContactItem(
@ -506,78 +606,13 @@ 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()
}
private fun LazyListScope.contactListAppendLoadingItem(contacts: LazyPagingItems<Contact>) {
contacts.apply {
when {
loadState.append is LoadState.Loading -> {
item(key = "append_loading") {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}

View file

@ -22,7 +22,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.Button
@ -82,7 +82,7 @@ fun ShareScreen(contacts: List<Contact>, onConfirm: (String) -> Unit, onNavigate
contentPadding = PaddingValues(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(contacts, key = { it.contactKey }) { contact ->
itemsIndexed(contacts, key = { index, contact -> "${contact.contactKey}#$index" }) { _, contact ->
val selected = contact.contactKey == selectedContact
ContactItem(
contact = contact,