mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-29 05:43:55 +01:00
Add drag and tap gestures to select images
This commit is contained in:
parent
ca30bf18bf
commit
55c09395e4
4 changed files with 174 additions and 80 deletions
|
|
@ -2,5 +2,6 @@ package fr.free.nrw.commons.customselector.ui.screens
|
||||||
|
|
||||||
interface CustomSelectorEvent {
|
interface CustomSelectorEvent {
|
||||||
data class OnFolderClick(val bucketId: Long): CustomSelectorEvent
|
data class OnFolderClick(val bucketId: Long): CustomSelectorEvent
|
||||||
data class OnImageSelect(val imageId: Long): CustomSelectorEvent
|
data class OnImageSelection(val imageId: Long): CustomSelectorEvent
|
||||||
|
data class OnDragImageSelection(val imageIds: Set<Long>): CustomSelectorEvent
|
||||||
}
|
}
|
||||||
|
|
@ -5,5 +5,9 @@ import fr.free.nrw.commons.customselector.model.Image
|
||||||
data class CustomSelectorState(
|
data class CustomSelectorState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val folders: List<Folder> = emptyList(),
|
val folders: List<Folder> = emptyList(),
|
||||||
val filteredImages: List<Image> = emptyList()
|
val filteredImages: List<Image> = emptyList(),
|
||||||
)
|
val selectedImageIds: Set<Long> = emptySet()
|
||||||
|
) {
|
||||||
|
val inSelectionMode: Boolean
|
||||||
|
get() = selectedImageIds.isNotEmpty()
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package fr.free.nrw.commons.customselector.ui.screens
|
package fr.free.nrw.commons.customselector.ui.screens
|
||||||
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import fr.free.nrw.commons.customselector.data.MediaReader
|
import fr.free.nrw.commons.customselector.data.MediaReader
|
||||||
|
|
@ -17,9 +16,6 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel()
|
||||||
|
|
||||||
private val foldersMap = mutableMapOf<Long, MutableList<Image>>()
|
private val foldersMap = mutableMapOf<Long, MutableList<Image>>()
|
||||||
|
|
||||||
private var _selectedImageIds = mutableStateListOf<Long>()
|
|
||||||
val selectedImageIds = _selectedImageIds
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_uiState.update { it.copy(isLoading = true) }
|
_uiState.update { it.copy(isLoading = true) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|
@ -46,12 +42,19 @@ class CustomSelectorViewModel(private val mediaReader: MediaReader): ViewModel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is CustomSelectorEvent.OnImageSelect -> {
|
is CustomSelectorEvent.OnImageSelection -> {
|
||||||
if(_selectedImageIds.contains(e.imageId)) {
|
_uiState.update { state ->
|
||||||
_selectedImageIds.remove(e.imageId)
|
val updatedSelectedIds = if (state.selectedImageIds.contains(e.imageId)) {
|
||||||
} else {
|
state.selectedImageIds - e.imageId // Remove if already selected
|
||||||
_selectedImageIds.add(e.imageId)
|
} else{
|
||||||
|
state.selectedImageIds + e.imageId // Add if not selected
|
||||||
}
|
}
|
||||||
|
state.copy(selectedImageIds = updatedSelectedIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is CustomSelectorEvent.OnDragImageSelection-> {
|
||||||
|
_uiState.update { it.copy(selectedImageIds = e.imageIds) }
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {}
|
else -> {}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package fr.free.nrw.commons.customselector.ui.screens
|
package fr.free.nrw.commons.customselector.ui.screens
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -26,6 +29,7 @@ import androidx.compose.material.icons.rounded.Check
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -41,30 +45,42 @@ import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.round
|
import androidx.compose.ui.unit.round
|
||||||
import androidx.compose.ui.unit.toIntRect
|
import androidx.compose.ui.unit.toIntRect
|
||||||
import androidx.window.core.layout.WindowWidthSizeClass
|
import androidx.window.core.layout.WindowWidthSizeClass
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
|
import fr.free.nrw.commons.R
|
||||||
import fr.free.nrw.commons.customselector.model.Image
|
import fr.free.nrw.commons.customselector.model.Image
|
||||||
|
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorBottomBar
|
||||||
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar
|
import fr.free.nrw.commons.customselector.ui.components.CustomSelectorTopBar
|
||||||
import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
|
import fr.free.nrw.commons.customselector.ui.components.PartialStorageAccessDialog
|
||||||
|
import fr.free.nrw.commons.ui.theme.CommonsTheme
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ImagesPane(
|
fun ImagesPane(
|
||||||
|
uiState: CustomSelectorState,
|
||||||
selectedFolder: Folder,
|
selectedFolder: Folder,
|
||||||
selectedImages: List<Long>,
|
selectedImages: () -> Set<Long>,
|
||||||
imageList: List<Image>,
|
onNavigateBack: () -> Unit,
|
||||||
onNavigateBack: ()-> Unit,
|
onEvent: (CustomSelectorEvent) -> Unit,
|
||||||
onToggleImageSelection: (Long) -> Unit,
|
adaptiveInfo: WindowAdaptiveInfo,
|
||||||
adaptiveInfo: WindowAdaptiveInfo
|
hasPartialAccess: Boolean = false
|
||||||
) {
|
) {
|
||||||
val inSelectionMode by remember { derivedStateOf { selectedImages.isNotEmpty() } }
|
// val inSelectionMode by remember { derivedStateOf { selectedImages().isNotEmpty() } }
|
||||||
val lazyGridState = rememberLazyGridState()
|
val lazyGridState = rememberLazyGridState()
|
||||||
var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
|
var autoScrollSpeed by remember { mutableFloatStateOf(0f) }
|
||||||
|
val isCompatWidth by remember(adaptiveInfo.windowSizeClass) {
|
||||||
|
derivedStateOf {
|
||||||
|
adaptiveInfo.windowSizeClass
|
||||||
|
.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(autoScrollSpeed) {
|
LaunchedEffect(autoScrollSpeed) {
|
||||||
if (autoScrollSpeed != 0f) {
|
if (autoScrollSpeed != 0f) {
|
||||||
|
|
@ -81,17 +97,35 @@ fun ImagesPane(
|
||||||
primaryText = selectedFolder.bucketName,
|
primaryText = selectedFolder.bucketName,
|
||||||
secondaryText = "${selectedFolder.itemsCount} images",
|
secondaryText = "${selectedFolder.itemsCount} images",
|
||||||
onNavigateBack = onNavigateBack,
|
onNavigateBack = onNavigateBack,
|
||||||
showNavigationIcon = adaptiveInfo.windowSizeClass
|
showNavigationIcon = isCompatWidth,
|
||||||
.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
|
showAlertIcon = selectedImages().size > 20,
|
||||||
|
selectionCount = selectedImages().size,
|
||||||
|
showSelectionCount = uiState.inSelectionMode
|
||||||
|
)
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = uiState.inSelectionMode && isCompatWidth,
|
||||||
|
enter = slideInVertically(initialOffsetY = { it }),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { it })
|
||||||
|
) {
|
||||||
|
Surface(tonalElevation = 1.dp) {
|
||||||
|
CustomSelectorBottomBar(
|
||||||
|
onPrimaryAction = { /*TODO("Implement action to upload selected images")*/ },
|
||||||
|
onSecondaryAction = { /*TODO("Implement action to mark/unmark as not for upload")*/ },
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding->
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
Column(modifier = Modifier.padding(innerPadding)) {
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (hasPartialAccess) {
|
||||||
PartialStorageAccessDialog(
|
PartialStorageAccessDialog(
|
||||||
isVisible = true,
|
onManageAction = { /*TODO("Request permission[READ_MEDIA_IMAGES]")*/ },
|
||||||
onManage = { /*TODO*/ },
|
|
||||||
modifier = Modifier.padding(8.dp)
|
modifier = Modifier.padding(8.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(116.dp),
|
columns = GridCells.Adaptive(116.dp),
|
||||||
|
|
@ -99,9 +133,11 @@ fun ImagesPane(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.imageGridDragHandler(
|
.imageGridDragHandler(
|
||||||
gridState = lazyGridState,
|
gridState = lazyGridState,
|
||||||
imageList = imageList,
|
imageList = uiState.filteredImages,
|
||||||
selectedImageIds = { selectedImages },
|
selectedImageIds = selectedImages,
|
||||||
onImageSelect = { onToggleImageSelection(it) },
|
setSelectedImageIds = {
|
||||||
|
onEvent(CustomSelectorEvent.OnDragImageSelection(it))
|
||||||
|
},
|
||||||
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() },
|
autoScrollThreshold = with(LocalDensity.current) { 40.dp.toPx() },
|
||||||
setAutoScrollSpeed = { autoScrollSpeed = it }
|
setAutoScrollSpeed = { autoScrollSpeed = it }
|
||||||
),
|
),
|
||||||
|
|
@ -110,23 +146,23 @@ fun ImagesPane(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
contentPadding = PaddingValues(8.dp)
|
contentPadding = PaddingValues(8.dp)
|
||||||
) {
|
) {
|
||||||
items(imageList, key = { it.id }) { image->
|
items(uiState.filteredImages, key = { it.id }) { image ->
|
||||||
val isSelected by remember {
|
val isSelected by remember {
|
||||||
derivedStateOf { selectedImages.contains(image.id) }
|
derivedStateOf { selectedImages().contains(image.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageItem(
|
ImageItem(
|
||||||
imagePainter = rememberAsyncImagePainter(model = image.uri),
|
imagePainter = rememberAsyncImagePainter(model = image.uri),
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
inSelectionMode = inSelectionMode,
|
inSelectionMode = uiState.inSelectionMode,
|
||||||
modifier = Modifier.combinedClickable(
|
modifier = Modifier.combinedClickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
if(inSelectionMode) {
|
if (uiState.inSelectionMode) {
|
||||||
onToggleImageSelection(image.id)
|
onEvent(CustomSelectorEvent.OnImageSelection(image.id))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
onToggleImageSelection(image.id)
|
onEvent(CustomSelectorEvent.OnImageSelection(image.id))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -153,8 +189,8 @@ fun ImageItem(
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.Crop
|
||||||
)
|
)
|
||||||
|
|
||||||
if(inSelectionMode) {
|
if (inSelectionMode) {
|
||||||
if(isSelected) {
|
if (isSelected) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Check,
|
imageVector = Icons.Rounded.Check,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
|
@ -181,14 +217,44 @@ fun ImageItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ImageItemPreview() {
|
||||||
|
CommonsTheme {
|
||||||
|
Surface {
|
||||||
|
ImageItem(
|
||||||
|
imagePainter = painterResource(id = R.drawable.image_placeholder_96),
|
||||||
|
isSelected = false,
|
||||||
|
inSelectionMode = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.size(116.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modifier that handles drag gestures on an image grid to allow for selecting multiple images.
|
||||||
|
*
|
||||||
|
* This modifier detects drag gestures and updates the selected images based on the drag position.
|
||||||
|
* It also handles auto-scrolling when the drag reaches the edges of the grid.
|
||||||
|
*
|
||||||
|
* @param gridState The state of the lazy grid.
|
||||||
|
* @param imageList The list of images displayed in the grid.
|
||||||
|
* @param selectedImageIds A function that returns the currently selected image IDs.
|
||||||
|
* @param autoScrollThreshold The distance from the edge of the grid at which auto-scrolling should start.
|
||||||
|
* @param setSelectedImageIds A callback function that is invoked when the selected images change.
|
||||||
|
* @param setAutoScrollSpeed A callback function that is invoked to set the auto-scroll speed.
|
||||||
|
*/
|
||||||
fun Modifier.imageGridDragHandler(
|
fun Modifier.imageGridDragHandler(
|
||||||
gridState: LazyGridState,
|
gridState: LazyGridState,
|
||||||
imageList: List<Image>,
|
imageList: List<Image>,
|
||||||
selectedImageIds:()-> List<Long>,
|
selectedImageIds: () -> Set<Long>,
|
||||||
autoScrollThreshold: Float,
|
autoScrollThreshold: Float,
|
||||||
onImageSelect: (Long) -> Unit = { },
|
setSelectedImageIds: (Set<Long>) -> Unit,
|
||||||
setAutoScrollSpeed: (Float) -> Unit = { },
|
setAutoScrollSpeed: (Float) -> Unit,
|
||||||
) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, onImageSelect) {
|
) = pointerInput(autoScrollThreshold, setAutoScrollSpeed, setSelectedImageIds, imageList) {
|
||||||
|
|
||||||
fun imageIndexAtOffset(hitPoint: Offset): Int? =
|
fun imageIndexAtOffset(hitPoint: Offset): Int? =
|
||||||
gridState.layoutInfo.visibleItemsInfo.find { itemInfo ->
|
gridState.layoutInfo.visibleItemsInfo.find { itemInfo ->
|
||||||
|
|
@ -200,33 +266,56 @@ fun Modifier.imageGridDragHandler(
|
||||||
var isSelecting = true
|
var isSelecting = true
|
||||||
|
|
||||||
detectDragGestures(
|
detectDragGestures(
|
||||||
onDragStart = { offset->
|
onDragStart = { offset ->
|
||||||
imageIndexAtOffset(offset)?.let {
|
imageIndexAtOffset(offset)?.let {
|
||||||
val imageId = imageList[it].id
|
val imageId = imageList[it].id
|
||||||
if(!selectedImageIds().contains(imageId)) {
|
|
||||||
dragStartIndex = it
|
dragStartIndex = it
|
||||||
currentDragIndex = it
|
currentDragIndex = it
|
||||||
onImageSelect(imageList[it].id)
|
|
||||||
|
if (!selectedImageIds().contains(imageId)) {
|
||||||
|
isSelecting = true
|
||||||
|
setSelectedImageIds(selectedImageIds().plus(imageId))
|
||||||
|
} else {
|
||||||
|
isSelecting = false
|
||||||
|
setSelectedImageIds(selectedImageIds().minus(imageId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null },
|
onDragEnd = { setAutoScrollSpeed(0f); dragStartIndex = null },
|
||||||
onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null },
|
onDragCancel = { setAutoScrollSpeed(0f); dragStartIndex = null },
|
||||||
onDrag = { change, _->
|
onDrag = { change, _ ->
|
||||||
dragStartIndex?.let { startIndex->
|
dragStartIndex?.let { startIndex ->
|
||||||
currentDragIndex?.let { endIndex->
|
val distFromBottom = gridState.layoutInfo.viewportSize.height - change.position.y
|
||||||
val start = minOf(startIndex, endIndex)
|
val distFromTop = change.position.y
|
||||||
val end = maxOf(start, endIndex)
|
setAutoScrollSpeed(
|
||||||
|
when {
|
||||||
|
distFromBottom < autoScrollThreshold -> autoScrollThreshold - distFromBottom
|
||||||
|
distFromTop < autoScrollThreshold -> -(autoScrollThreshold - distFromTop)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
(start..end).forEach { index->
|
currentDragIndex?.let { currentIndex ->
|
||||||
val imageId = imageList[index].id
|
imageIndexAtOffset(change.position)?.let { pointerIndex ->
|
||||||
val ifContains = selectedImageIds().contains(imageId)
|
if (currentIndex != pointerIndex) {
|
||||||
if (isSelecting && !selectedImageIds().contains(imageId)) {
|
if (isSelecting) {
|
||||||
println("Selecting...")
|
setSelectedImageIds(
|
||||||
println("contains: $ifContains")
|
selectedImageIds().minus(
|
||||||
onImageSelect(imageId)
|
imageList.getImageIdsInRange(startIndex, currentIndex)
|
||||||
} else if (!isSelecting && selectedImageIds().contains(imageId)) {
|
).plus(
|
||||||
onImageSelect(imageId)
|
imageList.getImageIdsInRange(startIndex, pointerIndex)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setSelectedImageIds(
|
||||||
|
selectedImageIds().plus(
|
||||||
|
imageList.getImageIdsInRange(currentIndex, pointerIndex)
|
||||||
|
).minus(
|
||||||
|
imageList.getImageIdsInRange(startIndex, pointerIndex)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
currentDragIndex = pointerIndex
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,26 +324,23 @@ fun Modifier.imageGridDragHandler(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Set<Int>.addUpTo(
|
/**
|
||||||
initialKey: Int?,
|
* Calculates a set of image IDs within a given range of indices in a list of images.
|
||||||
pointerKey: Int?
|
*
|
||||||
): Set<Int> {
|
* @param initialKey The starting index of the range.
|
||||||
return if(initialKey == null || pointerKey == null) {
|
* @param pointerKey The ending index of the range.
|
||||||
this
|
* @return A set of image IDs within the specified range.
|
||||||
} else {
|
*/
|
||||||
this.plus(initialKey..pointerKey)
|
fun List<Image>.getImageIdsInRange(initialKey: Int, pointerKey: Int): Set<Long> {
|
||||||
.plus(pointerKey..initialKey)
|
val setOfKeys = mutableSetOf<Long>()
|
||||||
|
if (initialKey < pointerKey) {
|
||||||
|
(initialKey..pointerKey).forEach {
|
||||||
|
setOfKeys.add(this[it].id)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun Set<Int>.removeUpTo(
|
|
||||||
initialKey: Int?,
|
|
||||||
previousPointerKey: Int?
|
|
||||||
): Set<Int> {
|
|
||||||
return if(initialKey == null || previousPointerKey == null) {
|
|
||||||
this
|
|
||||||
} else {
|
} else {
|
||||||
this.minus(initialKey..previousPointerKey)
|
(pointerKey..initialKey).forEach {
|
||||||
.minus(previousPointerKey..initialKey)
|
setOfKeys.add(this[it].id)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return setOfKeys
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue