Initial implementation of repo list re-ordering

This commit is contained in:
Torsten Grote
2025-10-10 17:26:50 -03:00
parent 43259b5989
commit c4cbf5abbc
9 changed files with 424 additions and 62 deletions

View File

@@ -47,6 +47,9 @@ import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.lists.AppListViewModel
import org.fdroid.ui.repositories.Repositories
import org.fdroid.ui.repositories.RepositoriesViewModel
import org.fdroid.ui.repositories.RepositoryInfo
import org.fdroid.ui.repositories.RepositoryItem
import org.fdroid.ui.repositories.RepositoryModel
import org.fdroid.ui.settings.Settings
import org.fdroid.ui.settings.SettingsViewModel
@@ -190,18 +193,29 @@ fun Main(onListeningForIntent: () -> Unit = {}) {
},
) {
val viewModel = hiltViewModel<RepositoriesViewModel>()
val repos = viewModel.repos.collectAsStateWithLifecycle(null).value
Repositories(
repositories = repos,
currentRepositoryId = if (isBigScreen) {
val info = object : RepositoryInfo {
override val model: RepositoryModel =
viewModel.model.collectAsStateWithLifecycle().value
override val currentRepositoryId: Long? = if (isBigScreen) {
(backStack.last() as? NavigationKey.RepoDetails)?.repoId
} else null,
onRepositorySelected = {
viewModel.setVisibleRepository(it)
backStack.add(NavigationKey.RepoDetails(it.repoId))
},
onAddRepo = viewModel::addRepo,
) {
} else null
override fun onRepositorySelected(repositoryItem: RepositoryItem) {
backStack.add(NavigationKey.RepoDetails(repositoryItem.repoId))
}
override fun onAddRepo() = viewModel.addRepo()
override fun onRepositoryMoved(fromIndex: Int, toIndex: Int) =
viewModel.onRepositoriesMoved(fromIndex, toIndex)
override fun onRepositoriesFinishedMoving(
fromRepoId: Long,
toRepoId: Long,
) = viewModel.onRepositoriesFinishedMoving(fromRepoId, toRepoId)
}
Repositories(info) {
backStack.removeLastOrNull()
}
}

View File

@@ -7,14 +7,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -23,17 +21,12 @@ import androidx.compose.ui.unit.dp
import org.fdroid.fdroid.ui.theme.FDroidContent
import org.fdroid.next.R
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.getRepositoriesInfo
@Composable
@OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class,
ExperimentalMaterial3ExpressiveApi::class
)
@OptIn(ExperimentalMaterial3Api::class)
fun Repositories(
repositories: List<RepositoryItem>?,
currentRepositoryId: Long?,
onRepositorySelected: (RepositoryItem) -> Unit,
onAddRepo: () -> Unit,
info: RepositoryInfo,
onBackClicked: () -> Unit,
) {
Scaffold(
@@ -54,7 +47,7 @@ fun Repositories(
},
floatingActionButton = {
FloatingActionButton(
onClick = onAddRepo,
onClick = info::onAddRepo,
modifier = Modifier.padding(16.dp)
) {
Icon(
@@ -64,13 +57,9 @@ fun Repositories(
}
}
) { paddingValues ->
if (repositories == null) BigLoadingIndicator()
if (info.model.repositories == null) BigLoadingIndicator()
else RepositoriesList(
repositories = repositories,
currentRepositoryId = currentRepositoryId,
onRepositorySelected = {
onRepositorySelected(it)
},
info = info,
modifier = Modifier
.padding(paddingValues),
)
@@ -84,7 +73,9 @@ fun Repositories(
@Composable
fun RepositoriesScaffoldLoadingPreview() {
FDroidContent {
Repositories(null, null, {}, {}) { }
val model = RepositoryModel(null)
val info = getRepositoriesInfo(model)
Repositories(info) {}
}
}
@@ -111,10 +102,12 @@ fun RepositoriesScaffoldPreview() {
timestamp = System.currentTimeMillis(),
lastUpdated = null,
weight = 2,
enabled = true,
enabled = false,
name = "My second repository",
),
)
Repositories(repos, repos[0].repoId, {}, {}) { }
val model = RepositoryModel(repos)
val info = getRepositoriesInfo(model, repos[0].repoId)
Repositories(info) { }
}
}

View File

@@ -1,54 +1,92 @@
package org.fdroid.ui.repositories
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import org.fdroid.utils.DraggableItem
import org.fdroid.utils.dragContainer
import org.fdroid.utils.rememberDragDropState
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
fun RepositoriesList(
repositories: List<RepositoryItem>,
currentRepositoryId: Long?,
onRepositorySelected: (RepositoryItem) -> Unit,
info: RepositoryInfo,
modifier: Modifier = Modifier,
) {
val repositories = info.model.repositories ?: return
val currentRepositoryId = info.currentRepositoryId
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(
lazyListState = listState,
onMove = info::onRepositoryMoved,
onEnd = { from, to ->
from as? Long ?: error("from $from was not a repoId")
to as? Long ?: error("to $to was not a repoId")
info.onRepositoriesFinishedMoving(from, to)
},
)
LazyColumn(
state = listState,
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = spacedBy(8.dp),
modifier = modifier.then(
if (currentRepositoryId == null) Modifier
else Modifier.selectableGroup()
),
modifier = modifier
.then(
if (repositories.size > 1) Modifier.dragContainer(dragDropState)
else Modifier
)
.then(
if (currentRepositoryId == null) Modifier
else Modifier.selectableGroup()
),
) {
items(repositories) { repoItem ->
itemsIndexed(
items = repositories,
key = { _, item -> item.repoId },
) { index, repoItem ->
val isSelected = currentRepositoryId == repoItem.repoId
val interactionModifier = if (currentRepositoryId == null) {
Modifier.clickable(
onClick = { onRepositorySelected(repoItem) }
onClick = { info.onRepositorySelected(repoItem) }
)
} else {
Modifier.selectable(
selected = isSelected,
onClick = { onRepositorySelected(repoItem) }
onClick = { info.onRepositorySelected(repoItem) }
)
}
DraggableItem(dragDropState, index) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
RepositoryRow(
repoItem = repoItem,
isSelected = isSelected,
modifier = Modifier
.fillMaxWidth()
.then(interactionModifier)
.let {
if (isDragging) it.dropShadow(
shape = RoundedCornerShape(4.dp),
shadow = Shadow(
radius = 8.dp,
offset = DpOffset(x = elevation, elevation)
)
) else it
}
)
}
RepositoryRow(
repoItem = repoItem,
isSelected = isSelected,
modifier = Modifier
.fillMaxWidth()
.then(interactionModifier),
)
}
}
}

View File

@@ -0,0 +1,21 @@
package org.fdroid.ui.repositories
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
@Composable
fun RepositoriesPresenter(
repositoriesFlow: Flow<List<RepositoryItem>>,
repoSortingMapFlow: StateFlow<Map<Long, Int>>,
): RepositoryModel {
val repositories = repositoriesFlow.collectAsState(null).value
val repoSortingMap = repoSortingMapFlow.collectAsState().value
return RepositoryModel(
repositories = repositories?.sortedBy { repo ->
// newly added repos will not be in repoSortingMap, so they need fallback
repoSortingMap[repo.repoId] ?: repositories.find { it.repoId == repo.repoId }?.weight
},
)
}

View File

@@ -3,30 +3,74 @@ package org.fdroid.ui.repositories
import android.app.Application
import androidx.core.os.LocaleListCompat
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.cash.molecule.AndroidUiDispatcher
import app.cash.molecule.RecompositionMode.ContextClock
import app.cash.molecule.launchMolecule
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import mu.KotlinLogging
import org.fdroid.index.RepoManager
import javax.inject.Inject
@HiltViewModel
class RepositoriesViewModel @Inject constructor(
app: Application,
repoManager: RepoManager,
private val repoManager: RepoManager,
) : AndroidViewModel(app) {
private val log = KotlinLogging.logger { }
private val localeList = LocaleListCompat.getDefault()
val repos: Flow<List<RepositoryItem>> = repoManager.repositoriesState.map { repos ->
repos.map { RepositoryItem(it, localeList) }
private val moleculeScope =
CoroutineScope(viewModelScope.coroutineContext + AndroidUiDispatcher.Main)
private val repos: Flow<List<RepositoryItem>> = repoManager.repositoriesState.map { repos ->
repos.mapNotNull {
if (it.isArchiveRepo) null
else RepositoryItem(it, localeList)
}
}
private val repoSortingMap: MutableStateFlow<Map<Long, Int>>
init {
// just add repos to sortingMap, because they are already pre-sorted by weight
val sortingMap = mutableMapOf<Long, Int>()
repoManager.getRepositories().forEachIndexed { index, repository ->
sortingMap[repository.repoId] = index
}
repoSortingMap = MutableStateFlow(sortingMap)
}
private val _visibleRepositoryItem = MutableStateFlow<RepositoryItem?>(null)
val visibleRepositoryItem = _visibleRepositoryItem.asStateFlow()
// define below init, because this only defines repoSortingMap
val model: StateFlow<RepositoryModel> = moleculeScope.launchMolecule(mode = ContextClock) {
RepositoriesPresenter(
repositoriesFlow = repos,
repoSortingMapFlow = repoSortingMap,
)
}
fun setVisibleRepository(repositoryItem: RepositoryItem?) {
_visibleRepositoryItem.value = repositoryItem
fun onRepositoriesMoved(fromIndex: Int, toIndex: Int) {
log.info { "onRepositoriesMoved($fromIndex, $toIndex)" }
val repoItems = model.value.repositories ?: error("Model had null repositories")
val fromItem = repoItems[fromIndex]
val toItem = repoItems[toIndex]
repoSortingMap.value = repoSortingMap.value.toMutableMap().apply {
replace(fromItem.repoId, toIndex)
replace(toItem.repoId, fromIndex)
}
}
fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) {
log.info { "onRepositoriesFinishedMoving($fromRepoId, $toRepoId)" }
val fromRepo = repoManager.getRepository(fromRepoId)
?: error("No repo for repoId $fromRepoId")
val toRepo = repoManager.getRepository(toRepoId)
?: error("No repo for repoId $toRepoId")
log.info { " ${fromRepo.address} => ${toRepo.address}" }
repoManager.reorderRepositories(fromRepo, toRepo)
}
fun addRepo() {

View File

@@ -0,0 +1,14 @@
package org.fdroid.ui.repositories
interface RepositoryInfo {
val model: RepositoryModel
val currentRepositoryId: Long?
fun onRepositorySelected(repositoryItem: RepositoryItem)
fun onAddRepo()
fun onRepositoryMoved(fromIndex: Int, toIndex: Int)
fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long)
}
data class RepositoryModel(
val repositories: List<RepositoryItem>?,
)

View File

@@ -8,7 +8,6 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import org.fdroid.ui.utils.AsyncShimmerImage
@@ -39,7 +38,7 @@ fun RepositoryRow(
containerColor = if (isSelected) {
MaterialTheme.colorScheme.surfaceVariant
} else {
Color.Transparent
MaterialTheme.colorScheme.background
}
),
modifier = modifier,

View File

@@ -19,6 +19,9 @@ import org.fdroid.ui.lists.AppListActions
import org.fdroid.ui.lists.AppListInfo
import org.fdroid.ui.lists.AppListModel
import org.fdroid.ui.lists.AppListType
import org.fdroid.ui.repositories.RepositoryInfo
import org.fdroid.ui.repositories.RepositoryItem
import org.fdroid.ui.repositories.RepositoryModel
import java.util.concurrent.TimeUnit.DAYS
object Names {
@@ -219,3 +222,15 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo = object : MyAppsInfo {
) {
}
}
fun getRepositoriesInfo(
model: RepositoryModel,
currentRepositoryId: Long? = null,
): RepositoryInfo = object : RepositoryInfo {
override val model: RepositoryModel = model
override val currentRepositoryId: Long? = currentRepositoryId
override fun onRepositorySelected(repositoryItem: RepositoryItem) {}
override fun onAddRepo() {}
override fun onRepositoryMoved(fromIndex: Int, toIndex: Int) {}
override fun onRepositoriesFinishedMoving(fromRepoId: Long, toRepoId: Long) {}
}

View File

@@ -0,0 +1,224 @@
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Taken from (and then modified to provide onEnd callback):
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt;drc=e6d33dd5d0a60001a5784d84123b05308d35f410
*/
package org.fdroid.utils
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
@Composable
fun rememberDragDropState(
lazyListState: LazyListState,
onMove: (Int, Int) -> Unit,
onEnd: (Any, Any) -> Unit
): DragDropState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropState(state = lazyListState, onMove = onMove, onEnd = onEnd, scope = scope)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
lazyListState.scrollBy(diff)
}
}
return state
}
class DragDropState internal constructor(
private val state: LazyListState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit,
private val onEnd: (Any, Any) -> Unit,
) {
private var movedFrom: Int? = null
private var movedFromKey: Any? = null
private var movedToKey: Any? = null
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
private var draggingItemInitialOffset by mutableIntStateOf(0)
internal val draggingItemOffset: Float
get() =
draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
} ?: 0f
private val draggingItemLayoutInfo: LazyListItemInfo?
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(0f)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also {
movedFrom = it.index
movedFromKey = it.key
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
0f,
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f),
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = 0f
draggingItemIndex = null
draggingItemInitialOffset = 0
movedFrom = null
movedFromKey = null
movedToKey = null
}
internal fun onDragEnd() {
val from = movedFromKey ?: error("Moved from was null")
val to = movedToKey ?: from
if (from != to) onEnd(from, to)
onDragInterrupted()
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset.y
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset + draggingItemOffset
val endOffset = startOffset + draggingItem.size
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem =
state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.toInt() in item.offset..item.offsetEnd &&
draggingItem.index != item.index
}
if (targetItem != null) {
if (
draggingItem.index == state.firstVisibleItemIndex ||
targetItem.index == state.firstVisibleItemIndex
) {
state.requestScrollToItem(
state.firstVisibleItemIndex,
state.firstVisibleItemScrollOffset,
)
}
onMove.invoke(draggingItem.index, targetItem.index)
draggingItemIndex = targetItem.index
movedToKey = targetItem.key
} else {
val overscroll =
when {
draggingItemDraggedDelta > 0 ->
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta < 0 ->
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyListItemInfo.offsetEnd: Int
get() = this.offset + this.size
}
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragEnd() },
onDragCancel = { dragDropState.onDragInterrupted() },
)
}
}
@Composable
fun LazyItemScope.DraggableItem(
dragDropState: DragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit,
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier =
if (dragging) {
LocalHapticFeedback.current.performHapticFeedback(HapticFeedbackType.LongPress)
Modifier
.zIndex(1f)
.graphicsLayer { translationY = dragDropState.draggingItemOffset }
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationY = dragDropState.previousItemOffset.value
}
} else {
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
}
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
}