Merge pull request 'Milestone/lot 5 avance' (#36) from milestone/lot-5-avance into develop
Reviewed-on: Gato/Planify-mobile#36
This commit is contained in:
@@ -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<List<TaskEntity>>
|
||||
|
||||
// #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<List<TaskEntity>>
|
||||
|
||||
// #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<List<TaskEntity>>
|
||||
|
||||
// #26 — Completed tasks
|
||||
@Query("""
|
||||
SELECT * FROM tasks
|
||||
WHERE checked = 1 AND is_deleted = 0
|
||||
ORDER BY completed_at DESC
|
||||
""")
|
||||
fun getCompletedTasks(): Flow<List<TaskEntity>>
|
||||
|
||||
// #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<List<TaskEntity>>
|
||||
|
||||
// #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<List<TaskEntity>>
|
||||
|
||||
// #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)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,28 @@ class TaskRepositoryImpl @Inject constructor(
|
||||
dao.setChecked(id, checked, if (checked) now else null, now)
|
||||
}
|
||||
|
||||
override fun getScheduledTasks(): Flow<List<Task>> =
|
||||
dao.getScheduledTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getTasksByLabel(labelName: String): Flow<List<Task>> =
|
||||
dao.getTasksByLabel("%\"$labelName\"%").map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun searchTasks(query: String): Flow<List<Task>> =
|
||||
dao.searchTasks("%$query%").map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getCompletedTasks(): Flow<List<Task>> =
|
||||
dao.getCompletedTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getRepeatingTasks(): Flow<List<Task>> =
|
||||
dao.getRepeatingTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getTasksByPriority(priority: Int): Flow<List<Task>> =
|
||||
dao.getTasksByPriority(priority).map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override suspend fun reorderTasks(orderedIds: List<String>) {
|
||||
orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) }
|
||||
}
|
||||
|
||||
private fun TaskEntity.toDomain() = Task(
|
||||
id = id,
|
||||
content = content,
|
||||
|
||||
@@ -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<List<Task>>
|
||||
fun getTasksByLabel(labelName: String): Flow<List<Task>>
|
||||
fun searchTasks(query: String): Flow<List<Task>>
|
||||
fun getCompletedTasks(): Flow<List<Task>>
|
||||
fun getRepeatingTasks(): Flow<List<Task>>
|
||||
fun getTasksByPriority(priority: Int): Flow<List<Task>>
|
||||
suspend fun reorderTasks(orderedIds: List<String>)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(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) }
|
||||
}
|
||||
}
|
||||
@@ -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 */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,13 +111,34 @@ private fun ProjectListView(
|
||||
}
|
||||
|
||||
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(
|
||||
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 }) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Task>)
|
||||
|
||||
@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<Task>): List<ScheduledGroup> {
|
||||
val today = LocalDate.now()
|
||||
val tomorrow = today.plusDays(1)
|
||||
val endOfWeek = today.plusDays(7)
|
||||
|
||||
val buckets = linkedMapOf(
|
||||
"Aujourd'hui" to mutableListOf<Task>(),
|
||||
"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) }
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user