feat: [#25] drag & drop dans la vue liste du projet (long-press handle, reorderTasks)

This commit is contained in:
2026-06-06 06:39:10 +02:00
parent 5fc6c8a3d4
commit 5d1c69484a
3 changed files with 154 additions and 7 deletions
@@ -0,0 +1,109 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
/** State for a reorderable LazyColumn. */
class ReorderState {
var draggedIndex by mutableStateOf<Int?>(null)
var dragOffsetY by mutableFloatStateOf(0f)
var overIndex by mutableIntStateOf(-1)
fun isDragged(index: Int) = draggedIndex == index
fun isOver(index: Int) = overIndex == index
}
@Composable
fun rememberReorderState() = remember { ReorderState() }
/**
* Modifier for a drag handle icon that drives a [ReorderState].
* Call [onReorder] when the drag ends with the new list order.
*/
@Composable
fun <T> Modifier.reorderDragHandle(
item: T,
index: Int,
items: List<T>,
state: ReorderState,
listState: LazyListState,
onReorder: (List<T>) -> Unit,
): Modifier {
val scope = rememberCoroutineScope()
return this.pointerInput(item, items) {
detectDragGesturesAfterLongPress(
onDragStart = {
state.draggedIndex = index
state.dragOffsetY = 0f
state.overIndex = index
},
onDrag = { change, dragAmount ->
change.consume()
state.dragOffsetY += dragAmount.y
val currentInfo: LazyListItemInfo? = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == (state.draggedIndex ?: -1) }
if (currentInfo != null) {
val currentCenter = currentInfo.offset + currentInfo.size / 2 + state.dragOffsetY.toInt()
val newOver = listState.layoutInfo.visibleItemsInfo
.minByOrNull { kotlin.math.abs(it.offset + it.size / 2 - currentCenter) }
?.index ?: state.overIndex
state.overIndex = newOver
}
},
onDragEnd = {
val from = state.draggedIndex ?: return@detectDragGesturesAfterLongPress
val to = state.overIndex
if (from != to && to >= 0 && to < items.size) {
val mutable = items.toMutableList()
val moved = mutable.removeAt(from)
mutable.add(to, moved)
scope.launch { onReorder(mutable) }
}
state.draggedIndex = null
state.dragOffsetY = 0f
state.overIndex = -1
},
onDragCancel = {
state.draggedIndex = null
state.dragOffsetY = 0f
state.overIndex = -1
},
)
}
}
/** Modifier to apply drag visual feedback (elevation + offset) to a dragged item. */
fun Modifier.draggedItemModifier(isDragged: Boolean, offsetY: Float): Modifier =
if (isDragged) this
.zIndex(1f)
.graphicsLayer { translationY = offsetY; shadowElevation = 8f }
else this
@Composable
fun DragHandleIcon(modifier: Modifier = Modifier) {
Icon(
imageVector = Icons.Outlined.DragHandle,
contentDescription = "Réordonner",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier,
)
}
@@ -2,6 +2,7 @@ package com.planify.mobile.ui.project
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -9,6 +10,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material3.Card
@@ -21,14 +23,20 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle
import com.planify.mobile.ui.components.DragHandleIcon
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.ReorderState
import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow
import com.planify.mobile.ui.components.draggedItemModifier
import com.planify.mobile.ui.components.reorderDragHandle
import com.planify.mobile.ui.components.rememberReorderState
@Composable
fun ProjectScreen(
@@ -62,6 +70,7 @@ fun ProjectScreen(
},
onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) },
onReorder = { viewModel.reorderTasks(it) },
modifier = modifier,
)
ViewStyle.BOARD -> ProjectBoardView(
@@ -80,9 +89,13 @@ private fun ProjectListView(
onToggleSection: (String) -> Unit,
onTaskClick: (Task) -> Unit,
onCheckedChange: (Task) -> Unit,
onReorder: (List<Task>) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier = modifier.fillMaxSize()) {
val listState = rememberLazyListState()
val reorderState = rememberReorderState()
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
state.sections.forEach { group ->
val key = group.section?.id ?: "unsectioned"
val name = group.section?.name ?: "Sans section"
@@ -98,12 +111,33 @@ private fun ProjectListView(
}
if (key !in collapsedSections) {
items(group.tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onCheckedChange = { onCheckedChange(task) },
onClick = { onTaskClick(task) },
)
itemsIndexed(group.tasks, key = { _, t -> t.id }) { index, task ->
Row(
modifier = Modifier
.fillMaxWidth()
.draggedItemModifier(
isDragged = reorderState.isDragged(index),
offsetY = if (reorderState.isDragged(index)) reorderState.dragOffsetY else 0f,
),
verticalAlignment = Alignment.CenterVertically,
) {
DragHandleIcon(
modifier = Modifier.reorderDragHandle(
item = task,
index = index,
items = group.tasks,
state = reorderState,
listState = listState,
onReorder = onReorder,
)
)
TaskRow(
task = task,
onCheckedChange = { onCheckedChange(task) },
onClick = { onTaskClick(task) },
modifier = Modifier.weight(1f),
)
}
}
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
}
@@ -68,4 +68,8 @@ class ProjectViewModel @Inject constructor(
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
fun reorderTasks(reordered: List<Task>) {
viewModelScope.launch { taskRepository.reorderTasks(reordered.map { it.id }) }
}
}