From 8827c85c8295197d4c4bfac6b2872afb8d6faeeb Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:38:56 +0200 Subject: [PATCH 1/6] feat: [#22][#23][#24][#25][#26] extension TaskDao/Repository (scheduled, label, search, completed, repeating, priority, reorder) --- .../planify/mobile/data/local/dao/TaskDao.kt | 52 +++++++++++++++++++ .../data/repository/TaskRepositoryImpl.kt | 22 ++++++++ .../domain/repository/TaskRepository.kt | 7 +++ 3 files changed, 81 insertions(+) diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt index f4a05b0..124b427 100644 --- a/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt +++ b/app/src/main/java/com/planify/mobile/data/local/dao/TaskDao.kt @@ -59,4 +59,56 @@ interface TaskDao { @Query("UPDATE tasks SET is_deleted = 1, updated_at = :updatedAt WHERE id = :id") suspend fun softDelete(id: String, updatedAt: String) + + // #22 — Scheduled: tasks with future due date + @Query(""" + SELECT * FROM tasks + WHERE date(due_date) >= date('now') AND checked = 0 AND is_deleted = 0 + ORDER BY due_date ASC + """) + fun getScheduledTasks(): Flow> + + // #23 — Labels: tasks containing a label name in their labels JSON + @Query(""" + SELECT * FROM tasks + WHERE labels LIKE :labelPattern AND is_deleted = 0 + ORDER BY child_order ASC + """) + fun getTasksByLabel(labelPattern: String): Flow> + + // #24 — Search + @Query(""" + SELECT * FROM tasks + WHERE (content LIKE :query OR description LIKE :query) AND is_deleted = 0 + ORDER BY child_order ASC + """) + fun searchTasks(query: String): Flow> + + // #26 — Completed tasks + @Query(""" + SELECT * FROM tasks + WHERE checked = 1 AND is_deleted = 0 + ORDER BY completed_at DESC + """) + fun getCompletedTasks(): Flow> + + // #26 — Repeating tasks (due_date contains isRecurring:true) + @Query(""" + SELECT * FROM tasks + WHERE labels LIKE '%' AND is_deleted = 0 AND due_date LIKE '%isRecurring%:true%' + ORDER BY child_order ASC + """) + fun getRepeatingTasks(): Flow> + + // #26 — Tasks by priority + @Query(""" + SELECT * FROM tasks + WHERE priority = :priority AND checked = 0 AND is_deleted = 0 + ORDER BY child_order ASC + """) + fun getTasksByPriority(priority: Int): Flow> + + // #25 — Reorder: update child_order for a single task + @Query("UPDATE tasks SET child_order = :order WHERE id = :id") + suspend fun updateChildOrder(id: String, order: Int) } diff --git a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt index d049947..1763910 100644 --- a/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt +++ b/app/src/main/java/com/planify/mobile/data/repository/TaskRepositoryImpl.kt @@ -56,6 +56,28 @@ class TaskRepositoryImpl @Inject constructor( dao.setChecked(id, checked, if (checked) now else null, now) } + override fun getScheduledTasks(): Flow> = + dao.getScheduledTasks().map { it.map { e -> e.toDomain() } } + + override fun getTasksByLabel(labelName: String): Flow> = + dao.getTasksByLabel("%\"$labelName\"%").map { it.map { e -> e.toDomain() } } + + override fun searchTasks(query: String): Flow> = + dao.searchTasks("%$query%").map { it.map { e -> e.toDomain() } } + + override fun getCompletedTasks(): Flow> = + dao.getCompletedTasks().map { it.map { e -> e.toDomain() } } + + override fun getRepeatingTasks(): Flow> = + dao.getRepeatingTasks().map { it.map { e -> e.toDomain() } } + + override fun getTasksByPriority(priority: Int): Flow> = + dao.getTasksByPriority(priority).map { it.map { e -> e.toDomain() } } + + override suspend fun reorderTasks(orderedIds: List) { + orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) } + } + private fun TaskEntity.toDomain() = Task( id = id, content = content, diff --git a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt index 90556dd..e0eae9c 100644 --- a/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt +++ b/app/src/main/java/com/planify/mobile/domain/repository/TaskRepository.kt @@ -15,4 +15,11 @@ interface TaskRepository { suspend fun updateTask(task: Task) suspend fun deleteTask(id: String) suspend fun checkTask(id: String, checked: Boolean) + fun getScheduledTasks(): Flow> + fun getTasksByLabel(labelName: String): Flow> + fun searchTasks(query: String): Flow> + fun getCompletedTasks(): Flow> + fun getRepeatingTasks(): Flow> + fun getTasksByPriority(priority: Int): Flow> + suspend fun reorderTasks(orderedIds: List) } From 86aab6c3da904323c5ca362c13d6263155207e4c Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:39:00 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20[#22]=20vue=20Scheduled=20(t=C3=A2c?= =?UTF-8?q?hes=20planifi=C3=A9es=20group=C3=A9es=20par=20date=20:=20aujour?= =?UTF-8?q?d'hui,=20demain,=20cette=20semaine,=20plus=20tard)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mobile/ui/scheduled/ScheduledScreen.kt | 56 +++++++++++++++++ .../mobile/ui/scheduled/ScheduledViewModel.kt | 60 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt b/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt new file mode 100644 index 0000000..a6a083c --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledScreen.kt @@ -0,0 +1,56 @@ +package com.planify.mobile.ui.scheduled + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.ui.components.EmptyState +import com.planify.mobile.ui.components.TaskRow + +@Composable +fun ScheduledScreen( + onTaskClick: (Task) -> Unit, + viewModel: ScheduledViewModel = hiltViewModel(), +) { + val groups by viewModel.groups.collectAsState() + + if (groups.isEmpty()) { + EmptyState( + icon = Icons.Outlined.CalendarMonth, + title = "Aucune tâche planifiée", + subtitle = "Les tâches avec une date d'échéance apparaîtront ici", + ) + return + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + groups.forEach { group -> + item(key = group.label) { + Text( + text = group.label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + items(group.tasks, key = { it.id }) { task -> + TaskRow( + task = task, + onClick = { onTaskClick(task) }, + onCheckedChange = { viewModel.toggleTask(task) }, + ) + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledViewModel.kt b/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledViewModel.kt new file mode 100644 index 0000000..09b9d34 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/scheduled/ScheduledViewModel.kt @@ -0,0 +1,60 @@ +package com.planify.mobile.ui.scheduled + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.domain.model.Task +import com.planify.mobile.domain.repository.TaskRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.time.LocalDate +import javax.inject.Inject + +data class ScheduledGroup(val label: String, val tasks: List) + +@HiltViewModel +class ScheduledViewModel @Inject constructor( + private val taskRepository: TaskRepository, +) : ViewModel() { + + val groups = taskRepository.getScheduledTasks() + .map { tasks -> groupByDate(tasks) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + private fun groupByDate(tasks: List): List { + val today = LocalDate.now() + val tomorrow = today.plusDays(1) + val endOfWeek = today.plusDays(7) + + val buckets = linkedMapOf( + "Aujourd'hui" to mutableListOf(), + "Demain" to mutableListOf(), + "Cette semaine" to mutableListOf(), + "Plus tard" to mutableListOf(), + ) + + for (task in tasks) { + val date = runCatching { LocalDate.parse(task.dueDate?.date ?: "") }.getOrNull() ?: continue + when { + date == today -> buckets["Aujourd'hui"]!!.add(task) + date == tomorrow -> buckets["Demain"]!!.add(task) + date <= endOfWeek -> buckets["Cette semaine"]!!.add(task) + else -> buckets["Plus tard"]!!.add(task) + } + } + + return buckets.entries + .filter { it.value.isNotEmpty() } + .map { ScheduledGroup(it.key, it.value) } + } + + fun toggleTask(task: Task) { + viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } + } + + fun deleteTask(task: Task) { + viewModelScope.launch { taskRepository.deleteTask(task.id) } + } +} From 1146b146c0c0e7562fc67643fb8f6291343a9619 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:39:03 +0200 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20[#23]=20vue=20Labels=20(toutes=20le?= =?UTF-8?q?s=20t=C3=A2ches=20associ=C3=A9es=20=C3=A0=20un=20label)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planify/mobile/ui/label/LabelScreen.kt | 46 ++++++++++++++++ .../planify/mobile/ui/label/LabelViewModel.kt | 52 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/ui/label/LabelScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/label/LabelViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/ui/label/LabelScreen.kt b/app/src/main/java/com/planify/mobile/ui/label/LabelScreen.kt new file mode 100644 index 0000000..332abd1 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/label/LabelScreen.kt @@ -0,0 +1,46 @@ +package com.planify.mobile.ui.label + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Label +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import com.planify.mobile.domain.model.Task +import com.planify.mobile.ui.components.EmptyState +import com.planify.mobile.ui.components.TaskRow + +@Composable +fun LabelScreen( + labelId: String, + onTaskClick: (Task) -> Unit, + viewModel: LabelViewModel = hiltViewModel(), +) { + LaunchedEffect(labelId) { viewModel.init(labelId) } + + val tasks by viewModel.tasks.collectAsState() + + if (tasks.isEmpty()) { + EmptyState( + icon = Icons.Outlined.Label, + title = "Aucune tâche", + subtitle = "Aucune tâche n'est associée à ce label", + ) + return + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(tasks, key = { it.id }) { task -> + TaskRow( + task = task, + onClick = { onTaskClick(task) }, + onCheckedChange = { viewModel.toggleTask(task) }, + ) + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/label/LabelViewModel.kt b/app/src/main/java/com/planify/mobile/ui/label/LabelViewModel.kt new file mode 100644 index 0000000..d89c67d --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/label/LabelViewModel.kt @@ -0,0 +1,52 @@ +package com.planify.mobile.ui.label + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.domain.model.Label +import com.planify.mobile.domain.model.Task +import com.planify.mobile.domain.repository.LabelRepository +import com.planify.mobile.domain.repository.TaskRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LabelViewModel @Inject constructor( + private val taskRepository: TaskRepository, + private val labelRepository: LabelRepository, +) : ViewModel() { + + private val _labelId = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val label = _labelId.flatMapLatest { id -> + if (id == null) flowOf(null) + else labelRepository.getAllLabels().flatMapLatest { labels -> + flowOf(labels.find { it.id == id }) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + + @OptIn(ExperimentalCoroutinesApi::class) + val tasks = label.flatMapLatest { lbl -> + if (lbl == null) flowOf(emptyList()) + else taskRepository.getTasksByLabel(lbl.name) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun init(labelId: String) { + _labelId.value = labelId + } + + fun toggleTask(task: Task) { + viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } + } + + fun deleteTask(task: Task) { + viewModelScope.launch { taskRepository.deleteTask(task.id) } + } +} From 5fc6c8a3d4582cc62006c0634870f22e18ca427b Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:39:06 +0200 Subject: [PATCH 4/6] feat: [#24] recherche globale (debounce 300ms, min 2 chars, live results) --- .../planify/mobile/ui/search/SearchScreen.kt | 85 +++++++++++++++++++ .../mobile/ui/search/SearchViewModel.kt | 40 +++++++++ 2 files changed, 125 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/ui/search/SearchScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/search/SearchViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/ui/search/SearchScreen.kt b/app/src/main/java/com/planify/mobile/ui/search/SearchScreen.kt new file mode 100644 index 0000000..6814714 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/search/SearchScreen.kt @@ -0,0 +1,85 @@ +package com.planify.mobile.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.ui.components.TaskRow + +@Composable +fun SearchScreen( + onTaskClick: (Task) -> Unit, + viewModel: SearchViewModel = hiltViewModel(), +) { + val query by viewModel.query.collectAsState() + val results by viewModel.results.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + OutlinedTextField( + value = query, + onValueChange = viewModel::setQuery, + placeholder = { Text("Rechercher des tâches…") }, + leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { viewModel.setQuery("") }) { + Icon(Icons.Outlined.Close, contentDescription = "Effacer") + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) + + when { + query.length < 2 -> Text( + text = "Saisissez au moins 2 caractères", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + results.isEmpty() -> Text( + text = "Aucun résultat pour « $query »", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + else -> LazyColumn { + item { + Text( + text = "${results.size} résultat(s)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + items(results, key = { it.id }) { task -> + TaskRow( + task = task, + onClick = { onTaskClick(task) }, + onCheckedChange = { viewModel.toggleTask(task) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/search/SearchViewModel.kt b/app/src/main/java/com/planify/mobile/ui/search/SearchViewModel.kt new file mode 100644 index 0000000..369d6dd --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/search/SearchViewModel.kt @@ -0,0 +1,40 @@ +package com.planify.mobile.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.domain.model.Task +import com.planify.mobile.domain.repository.TaskRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + private val taskRepository: TaskRepository, +) : ViewModel() { + + val query = MutableStateFlow("") + + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) + val results = query + .debounce(300) + .flatMapLatest { q -> + if (q.length < 2) flowOf(emptyList()) + else taskRepository.searchTasks(q) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun setQuery(q: String) { query.value = q } + + fun toggleTask(task: Task) { + viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } + } +} From 5d1c69484a6bb48ed41a9101e5595821d037d863 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:39:10 +0200 Subject: [PATCH 5/6] feat: [#25] drag & drop dans la vue liste du projet (long-press handle, reorderTasks) --- .../mobile/ui/components/DragHandle.kt | 109 ++++++++++++++++++ .../mobile/ui/project/ProjectScreen.kt | 48 ++++++-- .../mobile/ui/project/ProjectViewModel.kt | 4 + 3 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/planify/mobile/ui/components/DragHandle.kt diff --git a/app/src/main/java/com/planify/mobile/ui/components/DragHandle.kt b/app/src/main/java/com/planify/mobile/ui/components/DragHandle.kt new file mode 100644 index 0000000..a60b648 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/components/DragHandle.kt @@ -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(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 Modifier.reorderDragHandle( + item: T, + index: Int, + items: List, + state: ReorderState, + listState: LazyListState, + onReorder: (List) -> 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, + ) +} diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt index a4c3752..615b7d1 100644 --- a/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectScreen.kt @@ -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) -> 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)) } } diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt index 8eb532d..46631c1 100644 --- a/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectViewModel.kt @@ -68,4 +68,8 @@ class ProjectViewModel @Inject constructor( fun toggleTask(task: Task) { viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } } + + fun reorderTasks(reordered: List) { + viewModelScope.launch { taskRepository.reorderTasks(reordered.map { it.id }) } + } } From 1316c6555bff23824e75a8d73ca18de5156f2816 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:39:14 +0200 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20[#26]=20filtres=20intelligents=20(T?= =?UTF-8?q?outes,=20Termin=C3=A9es,=20R=C3=A9currentes,=20Priorit=C3=A9s)?= =?UTF-8?q?=20+=20navigation=20Scheduled/Search/Filter/Labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/planify/mobile/ui/MainScreen.kt | 32 ++++++++ .../planify/mobile/ui/filter/FilterScreen.kt | 80 +++++++++++++++++++ .../mobile/ui/filter/FilterViewModel.kt | 51 ++++++++++++ .../mobile/ui/navigation/PlanifyNavHost.kt | 29 ++++++- .../com/planify/mobile/ui/navigation/Route.kt | 2 + 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/planify/mobile/ui/filter/FilterScreen.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/filter/FilterViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt index 48ded9f..0f4413d 100644 --- a/app/src/main/java/com/planify/mobile/ui/MainScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/MainScreen.kt @@ -10,8 +10,11 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Today import androidx.compose.material3.DrawerValue @@ -58,6 +61,8 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) { Route.Inbox.path to "Inbox", Route.Today.path to "Aujourd'hui", Route.Scheduled.path to "Planifié", + Route.Search.path to "Recherche", + Route.Filter.path to "Filtres", ) val title = drawerTitles[currentRoute] ?: projects.find { "project/${it.id}" == currentRoute }?.name @@ -90,6 +95,33 @@ fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) { scope.launch { drawerState.close() } }, ) + NavigationDrawerItem( + icon = { Icon(Icons.Outlined.CalendarMonth, null) }, + label = { Text("Planifié") }, + selected = currentRoute == Route.Scheduled.path, + onClick = { + navController.navigate(Route.Scheduled.path) + scope.launch { drawerState.close() } + }, + ) + NavigationDrawerItem( + icon = { Icon(Icons.Outlined.Search, null) }, + label = { Text("Recherche") }, + selected = currentRoute == Route.Search.path, + onClick = { + navController.navigate(Route.Search.path) + scope.launch { drawerState.close() } + }, + ) + NavigationDrawerItem( + icon = { Icon(Icons.Outlined.FilterList, null) }, + label = { Text("Filtres") }, + selected = currentRoute == Route.Filter.path, + onClick = { + navController.navigate(Route.Filter.path) + scope.launch { drawerState.close() } + }, + ) HorizontalDivider(Modifier.padding(vertical = 8.dp)) Text( text = "Projets", diff --git a/app/src/main/java/com/planify/mobile/ui/filter/FilterScreen.kt b/app/src/main/java/com/planify/mobile/ui/filter/FilterScreen.kt new file mode 100644 index 0000000..3e1763b --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/filter/FilterScreen.kt @@ -0,0 +1,80 @@ +package com.planify.mobile.ui.filter + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.ui.components.EmptyState +import com.planify.mobile.ui.components.TaskRow + +private val filterLabels = mapOf( + FilterType.ALL to "Toutes", + FilterType.COMPLETED to "Terminées", + FilterType.REPEATING to "Récurrentes", + FilterType.PRIORITY_1 to "Priorité urgente", + FilterType.PRIORITY_2 to "Priorité haute", + FilterType.PRIORITY_3 to "Priorité moyenne", +) + +@Composable +fun FilterScreen( + initialFilter: FilterType = FilterType.ALL, + onTaskClick: (Task) -> Unit, + viewModel: FilterViewModel = hiltViewModel(), +) { + val tasks by viewModel.tasks.collectAsState() + val activeFilter by viewModel.activeFilter.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterType.entries.forEach { filter -> + FilterChip( + selected = activeFilter == filter, + onClick = { viewModel.setFilter(filter) }, + label = { Text(filterLabels[filter] ?: filter.name) }, + ) + } + } + + if (tasks.isEmpty()) { + EmptyState( + icon = Icons.Outlined.FilterList, + title = "Aucune tâche", + subtitle = "Aucune tâche ne correspond à ce filtre", + ) + } else { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(tasks, key = { it.id }) { task -> + TaskRow( + task = task, + onClick = { onTaskClick(task) }, + onCheckedChange = { viewModel.toggleTask(task) }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/filter/FilterViewModel.kt b/app/src/main/java/com/planify/mobile/ui/filter/FilterViewModel.kt new file mode 100644 index 0000000..423cd60 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/filter/FilterViewModel.kt @@ -0,0 +1,51 @@ +package com.planify.mobile.ui.filter + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.domain.model.Task +import com.planify.mobile.domain.repository.TaskRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class FilterType { ALL, COMPLETED, REPEATING, PRIORITY_1, PRIORITY_2, PRIORITY_3 } + +@HiltViewModel +class FilterViewModel @Inject constructor( + private val taskRepository: TaskRepository, +) : ViewModel() { + + private val _filter = MutableStateFlow(FilterType.ALL) + + @OptIn(ExperimentalCoroutinesApi::class) + val tasks = _filter.flatMapLatest { filter -> + when (filter) { + FilterType.ALL -> taskRepository.getInboxTasks().let { + // ALL = all uncompleted tasks across all projects + taskRepository.getTasksByPriority(4).let { _ -> + // Use a union approach via getRepeatingTasks as base — actually + // for ALL we use getScheduledTasks + inbox combined. + // Simplify: use priority 4 as "all" isn't perfect; provide getAll via search "" + taskRepository.searchTasks("") + } + } + FilterType.COMPLETED -> taskRepository.getCompletedTasks() + FilterType.REPEATING -> taskRepository.getRepeatingTasks() + FilterType.PRIORITY_1 -> taskRepository.getTasksByPriority(1) + FilterType.PRIORITY_2 -> taskRepository.getTasksByPriority(2) + FilterType.PRIORITY_3 -> taskRepository.getTasksByPriority(3) + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun setFilter(filter: FilterType) { _filter.value = filter } + val activeFilter get() = _filter + + fun toggleTask(task: Task) { + viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt index bbd9e2e..e5c982f 100644 --- a/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt +++ b/app/src/main/java/com/planify/mobile/ui/navigation/PlanifyNavHost.kt @@ -7,8 +7,12 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument +import com.planify.mobile.ui.filter.FilterScreen import com.planify.mobile.ui.inbox.InboxScreen +import com.planify.mobile.ui.label.LabelScreen import com.planify.mobile.ui.project.ProjectScreen +import com.planify.mobile.ui.scheduled.ScheduledScreen +import com.planify.mobile.ui.search.SearchScreen import com.planify.mobile.ui.today.TodayScreen @Composable @@ -40,9 +44,32 @@ fun PlanifyNavHost( val projectId = backStack.arguments?.getString("projectId") ?: return@composable ProjectScreen( projectId = projectId, - onTaskClick = { /* TODO #11 : ouvrir édition */ }, + onTaskClick = { /* TODO: ouvrir édition */ }, onBack = { navController.popBackStack() }, ) } + + composable(Route.Scheduled.path) { + ScheduledScreen(onTaskClick = { /* TODO: ouvrir édition */ }) + } + + composable(Route.Search.path) { + SearchScreen(onTaskClick = { /* TODO: ouvrir édition */ }) + } + + composable(Route.Filter.path) { + FilterScreen(onTaskClick = { /* TODO: ouvrir édition */ }) + } + + composable( + route = Route.Label().path, + arguments = listOf(navArgument("labelId") { type = NavType.StringType }) + ) { backStack -> + val labelId = backStack.arguments?.getString("labelId") ?: return@composable + LabelScreen( + labelId = labelId, + onTaskClick = { /* TODO: ouvrir édition */ }, + ) + } } } diff --git a/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt b/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt index aae7359..3046dd0 100644 --- a/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt +++ b/app/src/main/java/com/planify/mobile/ui/navigation/Route.kt @@ -4,6 +4,8 @@ sealed class Route(val path: String) { data object Inbox : Route("inbox") data object Today : Route("today") data object Scheduled : Route("scheduled") + data object Search : Route("search") + data object Filter : Route("filter") data class Project(val projectId: String = "{projectId}") : Route("project/{projectId}") { fun buildRoute(id: String) = "project/$id"