Merge pull request 'Develop' (#37) from develop into main

Reviewed-on: Gato/Planify-mobile#37
This commit is contained in:
2026-06-06 06:45:17 +02:00
17 changed files with 767 additions and 8 deletions
@@ -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,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 }) }
}
}
@@ -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) }
}
}