From 1f5f406b336b5a3fce576e7700d1ba1cb9c29e84 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 2 Apr 2026 18:17:00 +0200 Subject: [PATCH] feat(profiles): Add drag-to-reorder for profile list Wire up the missing drag-to-reorder UI that the priority hint already advertises. Long-press a profile row to reorder. Extracts a reusable ReorderableState component into common/compose. Backend (repo + ViewModel) was already in place; this adds the gesture handling, auto-scroll, visual feedback, and ID-based reorder validation. --- .../capod/common/compose/Reorderable.kt | 191 ++++++++++++++++++ .../capod/profiles/core/DeviceProfilesRepo.kt | 14 +- .../capod/profiles/ui/DeviceManagerScreen.kt | 59 +++++- .../profiles/ui/DeviceManagerViewModel.kt | 7 +- .../core/DeviceProfilesRepoReorderTest.kt | 81 ++++++++ 5 files changed, 338 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/common/compose/Reorderable.kt create mode 100644 app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt diff --git a/app/src/main/java/eu/darken/capod/common/compose/Reorderable.kt b/app/src/main/java/eu/darken/capod/common/compose/Reorderable.kt new file mode 100644 index 00000000..96359b5d --- /dev/null +++ b/app/src/main/java/eu/darken/capod/common/compose/Reorderable.kt @@ -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( + val lazyListState: LazyListState, + private val hapticFeedback: HapticFeedback, + private val key: (T) -> Any, +) { + private val _items = mutableStateListOf() + val items: List get() = _items + + private var originalItems: List = 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) { + 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) -> 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 MutableList.swap(from: Int, to: Int) { + val temp = this[from] + this[from] = this[to] + this[to] = temp +} + +@Composable +fun rememberReorderableState( + lazyListState: LazyListState, + items: List, + key: (T) -> Any, +): ReorderableState { + val hapticFeedback = LocalHapticFeedback.current + return remember(lazyListState) { + ReorderableState(lazyListState, hapticFeedback, key).also { + it.syncItems(items) + } + } +} + +@Composable +fun ReorderableAutoScroll(state: ReorderableState) { + 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 reorderableItemModifier( + state: ReorderableState, + index: Int, + item: T, + enabled: Boolean = true, + onReorder: (List) -> 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() + } + } +} diff --git a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt index 438d13a2..acea2b96 100644 --- a/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt +++ b/app/src/main/java/eu/darken/capod/profiles/core/DeviceProfilesRepo.kt @@ -88,12 +88,18 @@ class DeviceProfilesRepo @Inject constructor( deviceStateCache.delete(profileId) } - suspend fun reorderProfiles(profiles: List) = mutex.withLock { - settings.profiles.valueBlocking = DeviceProfilesContainer(profiles.toList()) - log(VERBOSE) { "Reordered ${profiles.size} device profiles" } + suspend fun reorderProfilesById(orderedIds: List) = 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() } diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt index c8ac6edd..b752cd77 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerScreen.kt @@ -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) -> 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, + ) + } } } diff --git a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt index 51fba592..0ce52bf7 100644 --- a/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt +++ b/app/src/main/java/eu/darken/capod/profiles/ui/DeviceManagerViewModel.kt @@ -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) = launch { - log(TAG) { "onReorder(): ${profiles.size} profiles" } - deviceProfilesRepo.reorderProfiles(profiles) + fun onReorder(profileIds: List) = launch { + log(TAG) { "onReorder(): ${profileIds.size} profiles" } + deviceProfilesRepo.reorderProfilesById(profileIds) } companion object { diff --git a/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt b/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt new file mode 100644 index 00000000..0344128c --- /dev/null +++ b/app/src/test/java/eu/darken/capod/profiles/core/DeviceProfilesRepoReorderTest.kt @@ -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, + scope: CoroutineScope, + ): Pair> { + val fakeProfiles = FakeDataStoreValue(DeviceProfilesContainer(initialProfiles)) + + val settings = mockk { + 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")) + } +}