feat: [#25] drag & drop dans la vue liste du projet (long-press handle, reorderTasks)
This commit is contained in:
@@ -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.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.FolderOpen
|
import androidx.compose.material.icons.outlined.FolderOpen
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -21,14 +23,20 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.planify.mobile.domain.model.Task
|
import com.planify.mobile.domain.model.Task
|
||||||
import com.planify.mobile.domain.model.ViewStyle
|
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.EmptyState
|
||||||
|
import com.planify.mobile.ui.components.ReorderState
|
||||||
import com.planify.mobile.ui.components.SectionHeader
|
import com.planify.mobile.ui.components.SectionHeader
|
||||||
import com.planify.mobile.ui.components.TaskRow
|
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
|
@Composable
|
||||||
fun ProjectScreen(
|
fun ProjectScreen(
|
||||||
@@ -62,6 +70,7 @@ fun ProjectScreen(
|
|||||||
},
|
},
|
||||||
onTaskClick = onTaskClick,
|
onTaskClick = onTaskClick,
|
||||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||||
|
onReorder = { viewModel.reorderTasks(it) },
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
ViewStyle.BOARD -> ProjectBoardView(
|
ViewStyle.BOARD -> ProjectBoardView(
|
||||||
@@ -80,9 +89,13 @@ private fun ProjectListView(
|
|||||||
onToggleSection: (String) -> Unit,
|
onToggleSection: (String) -> Unit,
|
||||||
onTaskClick: (Task) -> Unit,
|
onTaskClick: (Task) -> Unit,
|
||||||
onCheckedChange: (Task) -> Unit,
|
onCheckedChange: (Task) -> Unit,
|
||||||
|
onReorder: (List<Task>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
val listState = rememberLazyListState()
|
||||||
|
val reorderState = rememberReorderState()
|
||||||
|
|
||||||
|
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
|
||||||
state.sections.forEach { group ->
|
state.sections.forEach { group ->
|
||||||
val key = group.section?.id ?: "unsectioned"
|
val key = group.section?.id ?: "unsectioned"
|
||||||
val name = group.section?.name ?: "Sans section"
|
val name = group.section?.name ?: "Sans section"
|
||||||
@@ -98,13 +111,34 @@ private fun ProjectListView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (key !in collapsedSections) {
|
if (key !in collapsedSections) {
|
||||||
items(group.tasks, key = { it.id }) { 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(
|
TaskRow(
|
||||||
task = task,
|
task = task,
|
||||||
onCheckedChange = { onCheckedChange(task) },
|
onCheckedChange = { onCheckedChange(task) },
|
||||||
onClick = { onTaskClick(task) },
|
onClick = { onTaskClick(task) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,8 @@ class ProjectViewModel @Inject constructor(
|
|||||||
fun toggleTask(task: Task) {
|
fun toggleTask(task: Task) {
|
||||||
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
|
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun reorderTasks(reordered: List<Task>) {
|
||||||
|
viewModelScope.launch { taskRepository.reorderTasks(reordered.map { it.id }) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user