Merge pull request #475 from d4rken-org/feat/profile-drag-reorder

Device: Add drag-to-reorder for device profiles
This commit is contained in:
Matthias Urhahn
2026-04-02 18:18:08 +02:00
committed by GitHub
5 changed files with 338 additions and 14 deletions
@@ -0,0 +1,191 @@
package eu.darken.capod.common.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
class ReorderableState<T>(
val lazyListState: LazyListState,
private val hapticFeedback: HapticFeedback,
private val key: (T) -> Any,
) {
private val _items = mutableStateListOf<T>()
val items: List<T> get() = _items
private var originalItems: List<T> = emptyList()
var draggedIndex: Int? by mutableStateOf(null)
private set
var dragOffsetY: Float by mutableFloatStateOf(0f)
private set
val isDragging: Boolean get() = draggedIndex != null
fun syncItems(newItems: List<T>) {
if (isDragging) return
_items.clear()
_items.addAll(newItems)
}
fun onDragStart(item: T) {
val itemKey = key(item)
val index = _items.indexOfFirst { key(it) == itemKey }
if (index == -1) return
originalItems = _items.toList()
draggedIndex = index
dragOffsetY = 0f
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
fun onDrag(delta: Float) {
val currentIndex = draggedIndex ?: return
dragOffsetY += delta
val layoutInfo = lazyListState.layoutInfo
val draggedItemInfo = layoutInfo.visibleItemsInfo
.firstOrNull { it.index == currentIndex } ?: return
val draggedCenter = draggedItemInfo.offset + draggedItemInfo.size / 2 + dragOffsetY.toInt()
val targetInfo = layoutInfo.visibleItemsInfo.firstOrNull { itemInfo ->
itemInfo.index != currentIndex &&
itemInfo.index < _items.size &&
draggedCenter in itemInfo.offset..(itemInfo.offset + itemInfo.size)
} ?: return
val targetIndex = targetInfo.index
dragOffsetY += (draggedItemInfo.offset - targetInfo.offset).toFloat()
_items.swap(currentIndex, targetIndex)
draggedIndex = targetIndex
}
fun onDragEnd(onReorder: (List<T>) -> Unit) {
val reordered = _items.toList()
draggedIndex = null
dragOffsetY = 0f
originalItems = emptyList()
onReorder(reordered)
}
fun onDragCancel() {
if (originalItems.isNotEmpty()) {
_items.clear()
_items.addAll(originalItems)
}
draggedIndex = null
dragOffsetY = 0f
originalItems = emptyList()
}
}
private fun <T> MutableList<T>.swap(from: Int, to: Int) {
val temp = this[from]
this[from] = this[to]
this[to] = temp
}
@Composable
fun <T> rememberReorderableState(
lazyListState: LazyListState,
items: List<T>,
key: (T) -> Any,
): ReorderableState<T> {
val hapticFeedback = LocalHapticFeedback.current
return remember(lazyListState) {
ReorderableState(lazyListState, hapticFeedback, key).also {
it.syncItems(items)
}
}
}
@Composable
fun <T> ReorderableAutoScroll(state: ReorderableState<T>) {
val scrollThresholdPx = with(LocalDensity.current) { 80.dp.toPx() }
LaunchedEffect(state.isDragging) {
if (!state.isDragging) return@LaunchedEffect
while (isActive) {
val layoutInfo = state.lazyListState.layoutInfo
val viewportStart = layoutInfo.viewportStartOffset
val viewportEnd = layoutInfo.viewportEndOffset
val draggedIdx = state.draggedIndex ?: break
val draggedItem = layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggedIdx }
if (draggedItem != null) {
val itemCenter = draggedItem.offset + draggedItem.size / 2 + state.dragOffsetY
val scrollSpeed = 8f
val delta = when {
itemCenter < viewportStart + scrollThresholdPx -> -scrollSpeed
itemCenter > viewportEnd - scrollThresholdPx -> scrollSpeed
else -> 0f
}
if (delta != 0f) state.lazyListState.scrollBy(delta)
}
delay(16)
}
}
}
@Composable
fun <T> reorderableItemModifier(
state: ReorderableState<T>,
index: Int,
item: T,
enabled: Boolean = true,
onReorder: (List<T>) -> Unit = {},
): Modifier {
val isDragging = state.draggedIndex == index
val dragOffsetY = if (isDragging) state.dragOffsetY else 0f
val currentItem by rememberUpdatedState(item)
val currentOnReorder by rememberUpdatedState(onReorder)
val surfaceColor = MaterialTheme.colorScheme.surface
return Modifier
.zIndex(if (isDragging) 1f else 0f)
.background(surfaceColor)
.then(
if (enabled) {
Modifier.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = { state.onDragStart(currentItem) },
onDrag = { change, dragAmount ->
change.consume()
state.onDrag(dragAmount.y)
},
onDragEnd = { state.onDragEnd(currentOnReorder) },
onDragCancel = { state.onDragCancel() },
)
}
} else {
Modifier
}
)
.graphicsLayer {
if (isDragging) {
translationY = dragOffsetY
shadowElevation = 8.dp.toPx()
}
}
}
@@ -88,12 +88,18 @@ class DeviceProfilesRepo @Inject constructor(
deviceStateCache.delete(profileId)
}
suspend fun reorderProfiles(profiles: List<DeviceProfile>) = mutex.withLock {
settings.profiles.valueBlocking = DeviceProfilesContainer(profiles.toList())
log(VERBOSE) { "Reordered ${profiles.size} device profiles" }
suspend fun reorderProfilesById(orderedIds: List<ProfileId>) = mutex.withLock {
val current = settings.profiles.valueBlocking.profiles
require(orderedIds.size == current.size && orderedIds.toSet() == current.map { it.id }.toSet()) {
"Reorder IDs must match current profile IDs exactly"
}
val byId = current.associateBy { it.id }
val reordered = orderedIds.map { byId.getValue(it) }
settings.profiles.valueBlocking = DeviceProfilesContainer(reordered)
log(VERBOSE) { "Reordered ${reordered.size} device profiles by ID" }
}
suspend fun clear() {
suspend fun clear() = mutex.withLock {
settings.profiles.valueBlocking = DeviceProfilesContainer(emptyList())
deviceStateCache.deleteAll()
}
@@ -12,11 +12,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
import androidx.compose.material.icons.twotone.Add
import androidx.compose.material.icons.twotone.DragHandle
import androidx.compose.material3.Card
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -27,6 +29,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -38,11 +41,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.ReorderableAutoScroll
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.compose.reorderableItemModifier
import eu.darken.capod.common.compose.rememberReorderableState
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.ProfileId
@Composable
fun DeviceManagerScreenHost(vm: DeviceManagerViewModel = hiltViewModel()) {
@@ -56,6 +63,7 @@ fun DeviceManagerScreenHost(vm: DeviceManagerViewModel = hiltViewModel()) {
onBack = { vm.navUp() },
onAddDevice = { vm.onAddDevice() },
onEditProfile = { profile -> vm.onEditProfile(profile) },
onReorder = { ids -> vm.onReorder(ids) },
)
}
}
@@ -66,7 +74,17 @@ fun DeviceManagerScreen(
onBack: () -> Unit,
onAddDevice: () -> Unit,
onEditProfile: (DeviceProfile) -> Unit,
onReorder: (List<ProfileId>) -> Unit = {},
) {
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableState(lazyListState, state.profiles) { it.id }
LaunchedEffect(state.profiles) {
reorderableState.syncItems(state.profiles)
}
ReorderableAutoScroll(reorderableState)
Scaffold(
topBar = {
TopAppBar(
@@ -98,18 +116,33 @@ fun DeviceManagerScreen(
onAddDevice = onAddDevice,
)
} else {
val showDragHandles = reorderableState.items.size >= 2
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
items(
items = state.profiles,
key = { it.id },
) { profile ->
itemsIndexed(
items = reorderableState.items,
key = { _, profile -> profile.id },
) { index, profile ->
val isDraggingThis = reorderableState.draggedIndex == index
ProfileRow(
modifier = Modifier
.then(if (!isDraggingThis) Modifier.animateItem() else Modifier)
.then(
reorderableItemModifier(
state = reorderableState,
index = index,
item = profile,
enabled = showDragHandles,
onReorder = { reordered -> onReorder(reordered.map { it.id }) },
)
),
profile = profile,
onClick = { onEditProfile(profile) },
onClick = { if (!reorderableState.isDragging) onEditProfile(profile) },
showDragHandle = showDragHandles,
)
}
@@ -194,11 +227,13 @@ private fun EmptyState(
@Composable
private fun ProfileRow(
modifier: Modifier = Modifier,
profile: DeviceProfile,
onClick: () -> Unit,
showDragHandle: Boolean = false,
) {
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
@@ -221,6 +256,16 @@ private fun ProfileRow(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (showDragHandle) {
Icon(
imageVector = Icons.TwoTone.DragHandle,
contentDescription = stringResource(R.string.profiles_drag_handle_description),
modifier = Modifier
.size(48.dp)
.padding(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.navigation.Nav
import eu.darken.capod.common.uix.ViewModel4
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.profiles.core.ProfileId
import kotlinx.coroutines.flow.map
import javax.inject.Inject
@@ -36,9 +37,9 @@ class DeviceManagerViewModel @Inject constructor(
navTo(Nav.Main.DeviceProfileCreation(profileId = profile.id))
}
fun onReorder(profiles: List<DeviceProfile>) = launch {
log(TAG) { "onReorder(): ${profiles.size} profiles" }
deviceProfilesRepo.reorderProfiles(profiles)
fun onReorder(profileIds: List<ProfileId>) = launch {
log(TAG) { "onReorder(): ${profileIds.size} profiles" }
deviceProfilesRepo.reorderProfilesById(profileIds)
}
companion object {
@@ -0,0 +1,81 @@
package eu.darken.capod.profiles.core
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.flowOf
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import testhelpers.coroutine.runTest2
import testhelpers.datastore.FakeDataStoreValue
class DeviceProfilesRepoReorderTest : BaseTest() {
private fun createRepo(
initialProfiles: List<DeviceProfile>,
scope: CoroutineScope,
): Pair<DeviceProfilesRepo, FakeDataStoreValue<DeviceProfilesContainer>> {
val fakeProfiles = FakeDataStoreValue(DeviceProfilesContainer(initialProfiles))
val settings = mockk<DeviceProfilesSettings> {
every { profiles } returns fakeProfiles.mock
every { defaultProfileCreated } returns FakeDataStoreValue(true).mock
}
val repo = DeviceProfilesRepo(
scope = scope,
context = mockk(relaxed = true),
generalSettings = mockk(relaxed = true),
settings = settings,
deviceStateCache = mockk(relaxed = true),
)
return repo to fakeProfiles
}
@Test
fun `reorderProfilesById reorders profiles correctly`() = runTest2 {
val p1 = AppleDeviceProfile(id = "1", label = "First")
val p2 = AppleDeviceProfile(id = "2", label = "Second")
val p3 = AppleDeviceProfile(id = "3", label = "Third")
val (repo, fakeProfiles) = createRepo(listOf(p1, p2, p3), this)
repo.reorderProfilesById(listOf("3", "1", "2"))
fakeProfiles.value.profiles.map { it.id } shouldBe listOf("3", "1", "2")
}
@Test
fun `reorderProfilesById preserves profile data`() = runTest2 {
val p1 = AppleDeviceProfile(id = "1", label = "First", minimumSignalQuality = 0.5f)
val p2 = AppleDeviceProfile(id = "2", label = "Second", minimumSignalQuality = 0.8f)
val (repo, fakeProfiles) = createRepo(listOf(p1, p2), this)
repo.reorderProfilesById(listOf("2", "1"))
fakeProfiles.value.profiles shouldBe listOf(p2, p1)
}
@Test
fun `reorderProfilesById rejects mismatched IDs`() = runTest2(expectedError = IllegalArgumentException::class) {
val p1 = AppleDeviceProfile(id = "1", label = "First")
val p2 = AppleDeviceProfile(id = "2", label = "Second")
val (repo, _) = createRepo(listOf(p1, p2), this)
repo.reorderProfilesById(listOf("1", "3"))
}
@Test
fun `reorderProfilesById rejects wrong count`() = runTest2(expectedError = IllegalArgumentException::class) {
val p1 = AppleDeviceProfile(id = "1", label = "First")
val p2 = AppleDeviceProfile(id = "2", label = "Second")
val (repo, _) = createRepo(listOf(p1, p2), this)
repo.reorderProfilesById(listOf("1"))
}
}