mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Merge pull request #475 from d4rken-org/feat/profile-drag-reorder
Device: Add drag-to-reorder for device profiles
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user