feat: [#11] fiche tâche complète (TaskEditSheet + TaskEditViewModel + ReminderRepository)

This commit is contained in:
2026-06-06 06:19:37 +02:00
parent 6db1222ff7
commit 5049d4d681
8 changed files with 453 additions and 0 deletions
@@ -4,6 +4,7 @@ import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import com.planify.mobile.data.local.dao.LabelDao import com.planify.mobile.data.local.dao.LabelDao
import com.planify.mobile.data.local.dao.ProjectDao import com.planify.mobile.data.local.dao.ProjectDao
import com.planify.mobile.data.local.dao.ReminderDao
import com.planify.mobile.data.local.dao.SectionDao import com.planify.mobile.data.local.dao.SectionDao
import com.planify.mobile.data.local.dao.TaskDao import com.planify.mobile.data.local.dao.TaskDao
import com.planify.mobile.data.local.entity.LabelEntity import com.planify.mobile.data.local.entity.LabelEntity
@@ -30,4 +31,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun projectDao(): ProjectDao abstract fun projectDao(): ProjectDao
abstract fun sectionDao(): SectionDao abstract fun sectionDao(): SectionDao
abstract fun labelDao(): LabelDao abstract fun labelDao(): LabelDao
abstract fun reminderDao(): ReminderDao
} }
@@ -0,0 +1,24 @@
package com.planify.mobile.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.planify.mobile.data.local.entity.ReminderEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface ReminderDao {
@Query("SELECT * FROM reminders WHERE task_id = :taskId")
fun getRemindersByTask(taskId: String): Flow<List<ReminderEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(reminder: ReminderEntity)
@Delete
suspend fun delete(reminder: ReminderEntity)
@Query("DELETE FROM reminders WHERE task_id = :taskId")
suspend fun deleteByTask(taskId: String)
}
@@ -0,0 +1,43 @@
package com.planify.mobile.data.repository
import com.planify.mobile.data.local.dao.ReminderDao
import com.planify.mobile.data.local.entity.ReminderEntity
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Reminder
import com.planify.mobile.domain.model.ReminderType
import com.planify.mobile.domain.repository.ReminderRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
class ReminderRepositoryImpl @Inject constructor(
private val dao: ReminderDao,
) : ReminderRepository {
override fun getRemindersByTask(taskId: String): Flow<List<Reminder>> =
dao.getRemindersByTask(taskId).map { list -> list.map { it.toDomain() } }
override suspend fun insertReminder(reminder: Reminder) = dao.insert(reminder.toEntity())
override suspend fun deleteReminder(reminder: Reminder) = dao.delete(reminder.toEntity())
override suspend fun deleteRemindersByTask(taskId: String) = dao.deleteByTask(taskId)
private fun ReminderEntity.toDomain() = Reminder(
id = id,
taskId = taskId,
type = ReminderType.valueOf(type),
dueDate = dueDate?.let { runCatching { Json.decodeFromString<DueDate>(it) }.getOrNull() },
minutesOffset = minutesOffset,
)
private fun Reminder.toEntity() = ReminderEntity(
id = id,
taskId = taskId,
type = type.name,
dueDate = dueDate?.let { Json.encodeToString(it) },
minutesOffset = minutesOffset,
)
}
@@ -25,4 +25,5 @@ object DatabaseModule {
@Provides fun provideProjectDao(db: AppDatabase) = db.projectDao() @Provides fun provideProjectDao(db: AppDatabase) = db.projectDao()
@Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao() @Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao()
@Provides fun provideLabelDao(db: AppDatabase) = db.labelDao() @Provides fun provideLabelDao(db: AppDatabase) = db.labelDao()
@Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao()
} }
@@ -2,10 +2,12 @@ package com.planify.mobile.di
import com.planify.mobile.data.repository.LabelRepositoryImpl import com.planify.mobile.data.repository.LabelRepositoryImpl
import com.planify.mobile.data.repository.ProjectRepositoryImpl import com.planify.mobile.data.repository.ProjectRepositoryImpl
import com.planify.mobile.data.repository.ReminderRepositoryImpl
import com.planify.mobile.data.repository.SectionRepositoryImpl import com.planify.mobile.data.repository.SectionRepositoryImpl
import com.planify.mobile.data.repository.TaskRepositoryImpl import com.planify.mobile.data.repository.TaskRepositoryImpl
import com.planify.mobile.domain.repository.LabelRepository import com.planify.mobile.domain.repository.LabelRepository
import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.ReminderRepository
import com.planify.mobile.domain.repository.SectionRepository import com.planify.mobile.domain.repository.SectionRepository
import com.planify.mobile.domain.repository.TaskRepository import com.planify.mobile.domain.repository.TaskRepository
import dagger.Binds import dagger.Binds
@@ -29,4 +31,7 @@ abstract class RepositoryModule {
@Binds @Singleton @Binds @Singleton
abstract fun bindLabelRepository(impl: LabelRepositoryImpl): LabelRepository abstract fun bindLabelRepository(impl: LabelRepositoryImpl): LabelRepository
@Binds @Singleton
abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository
} }
@@ -0,0 +1,11 @@
package com.planify.mobile.domain.repository
import com.planify.mobile.domain.model.Reminder
import kotlinx.coroutines.flow.Flow
interface ReminderRepository {
fun getRemindersByTask(taskId: String): Flow<List<Reminder>>
suspend fun insertReminder(reminder: Reminder)
suspend fun deleteReminder(reminder: Reminder)
suspend fun deleteRemindersByTask(taskId: String)
}
@@ -0,0 +1,210 @@
package com.planify.mobile.ui.task
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarToday
import androidx.compose.material.icons.outlined.Flag
import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.List
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.ui.components.PriorityBadge
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskEditSheet(
projectId: String,
taskId: String? = null,
sectionId: String? = null,
parentId: String? = null,
onDismiss: () -> Unit,
viewModel: TaskEditViewModel = hiltViewModel(),
) {
LaunchedEffect(taskId, projectId) {
viewModel.init(taskId, projectId, sectionId, parentId)
}
val state by viewModel.state.collectAsState()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var showDueDatePicker by remember { mutableStateOf(false) }
var showPriorityPicker by remember { mutableStateOf(false) }
var showLabelPicker by remember { mutableStateOf(false) }
var showReminderPicker by remember { mutableStateOf(false) }
var showSubTasks by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(Modifier.padding(horizontal = 16.dp)) {
Text(
text = if (taskId == null) "Nouvelle tâche" else "Modifier la tâche",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
OutlinedTextField(
value = state.content,
onValueChange = viewModel::setContent,
placeholder = { Text("Nom de la tâche") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.description,
onValueChange = viewModel::setDescription,
placeholder = { Text("Description (optionnelle)") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4,
)
Spacer(Modifier.height(12.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterChip(
selected = state.dueDate != null,
onClick = { showDueDatePicker = true },
label = { Text(state.dueDate?.date?.let { formatDateShort(it) } ?: "Date") },
leadingIcon = { Icon(Icons.Outlined.CalendarToday, contentDescription = null) },
)
FilterChip(
selected = state.priority != 4,
onClick = { showPriorityPicker = true },
label = { Text("P${state.priority}") },
leadingIcon = { PriorityBadge(priority = state.priority) },
)
FilterChip(
selected = state.labels.isNotEmpty(),
onClick = { showLabelPicker = true },
label = {
Text(if (state.labels.isNotEmpty()) "${state.labels.size} label(s)" else "Labels")
},
leadingIcon = { Icon(Icons.Outlined.Label, contentDescription = null) },
)
FilterChip(
selected = state.reminders.isNotEmpty(),
onClick = { showReminderPicker = true },
label = {
Text(if (state.reminders.isNotEmpty()) "${state.reminders.size} rappel(s)" else "Rappels")
},
leadingIcon = { Icon(Icons.Outlined.Notifications, contentDescription = null) },
)
FilterChip(
selected = state.subTasks.isNotEmpty() || showSubTasks,
onClick = { showSubTasks = !showSubTasks },
label = {
Text(if (state.subTasks.isNotEmpty()) "${state.subTasks.size} sous-tâche(s)" else "Sous-tâches")
},
leadingIcon = { Icon(Icons.Outlined.List, contentDescription = null) },
)
}
if (showSubTasks) {
Spacer(Modifier.height(8.dp))
SubTaskSection(
subTasks = state.subTasks,
onAddSubTask = viewModel::addSubTask,
onToggleSubTask = viewModel::toggleSubTask,
onDeleteSubTask = viewModel::deleteSubTask,
)
}
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(8.dp))
Button(
onClick = { viewModel.save(onDone = onDismiss) },
enabled = state.content.isNotBlank() && !state.isSaving,
) {
Text(if (taskId == null) "Créer" else "Enregistrer")
}
}
Spacer(Modifier.height(16.dp))
}
}
if (showDueDatePicker) {
DueDatePickerSheet(
currentDueDate = state.dueDate,
onConfirm = { viewModel.setDueDate(it); showDueDatePicker = false },
onDismiss = { showDueDatePicker = false },
)
}
if (showPriorityPicker) {
PriorityPickerSheet(
current = state.priority,
onSelect = viewModel::setPriority,
onDismiss = { showPriorityPicker = false },
)
}
if (showLabelPicker) {
LabelPickerSheet(
selectedLabels = state.labels,
onConfirm = { viewModel.setLabels(it); showLabelPicker = false },
onDismiss = { showLabelPicker = false },
)
}
if (showReminderPicker) {
ReminderPickerSheet(
taskId = state.taskId ?: "",
currentReminders = state.reminders,
onUpdate = viewModel::setReminders,
onDismiss = { showReminderPicker = false },
)
}
}
private fun formatDateShort(dateIso: String): String = runCatching {
val date = LocalDate.parse(dateIso)
val today = LocalDate.now()
when (date) {
today -> "Aujourd'hui"
today.plusDays(1) -> "Demain"
else -> date.format(DateTimeFormatter.ofPattern("d MMM", Locale.FRENCH))
}
}.getOrDefault(dateIso)
@@ -0,0 +1,157 @@
package com.planify.mobile.ui.task
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.notification.ReminderScheduler
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Reminder
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.ReminderRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import javax.inject.Inject
data class TaskEditState(
val taskId: String? = null,
val projectId: String = "",
val sectionId: String? = null,
val parentId: String? = null,
val content: String = "",
val description: String = "",
val priority: Int = 4,
val dueDate: DueDate? = null,
val labels: List<String> = emptyList(),
val reminders: List<Reminder> = emptyList(),
val subTasks: List<Task> = emptyList(),
val isSaving: Boolean = false,
)
@HiltViewModel
class TaskEditViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val reminderRepository: ReminderRepository,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditState())
val state = _state.asStateFlow()
fun init(taskId: String?, projectId: String, sectionId: String? = null, parentId: String? = null) {
if (taskId != null) {
viewModelScope.launch {
val task = taskRepository.getTaskById(taskId) ?: return@launch
val subTasks = taskRepository.getSubTasks(taskId).first()
val reminders = reminderRepository.getRemindersByTask(taskId).first()
_state.update {
it.copy(
taskId = taskId,
projectId = task.projectId,
sectionId = task.sectionId,
parentId = task.parentId,
content = task.content,
description = task.description,
priority = task.priority,
dueDate = task.dueDate,
labels = task.labels,
reminders = reminders,
subTasks = subTasks,
)
}
}
} else {
_state.update { it.copy(projectId = projectId, sectionId = sectionId, parentId = parentId) }
}
}
fun setContent(content: String) = _state.update { it.copy(content = content) }
fun setDescription(desc: String) = _state.update { it.copy(description = desc) }
fun setPriority(priority: Int) = _state.update { it.copy(priority = priority) }
fun setDueDate(dueDate: DueDate?) = _state.update { it.copy(dueDate = dueDate) }
fun setLabels(labels: List<String>) = _state.update { it.copy(labels = labels) }
fun setReminders(reminders: List<Reminder>) = _state.update { it.copy(reminders = reminders) }
fun addSubTask(content: String) {
val sub = Task(
id = UUID.randomUUID().toString(),
content = content,
projectId = _state.value.projectId,
parentId = _state.value.taskId ?: "pending",
)
_state.update { it.copy(subTasks = it.subTasks + sub) }
}
fun toggleSubTask(subTask: Task) {
_state.update {
it.copy(subTasks = it.subTasks.map { t ->
if (t.id == subTask.id) t.copy(checked = !t.checked) else t
})
}
}
fun deleteSubTask(subTask: Task) {
_state.update { it.copy(subTasks = it.subTasks.filter { t -> t.id != subTask.id }) }
}
fun save(onDone: () -> Unit) {
val st = _state.value
if (st.content.isBlank()) return
_state.update { it.copy(isSaving = true) }
viewModelScope.launch {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val id = st.taskId ?: UUID.randomUUID().toString()
val task = Task(
id = id,
content = st.content,
description = st.description,
projectId = st.projectId,
sectionId = st.sectionId,
parentId = st.parentId,
priority = st.priority,
dueDate = st.dueDate,
labels = st.labels,
addedAt = if (st.taskId == null) now else "",
updatedAt = now,
)
if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task)
// Sub-tasks: delete removed ones, then upsert remaining
if (st.taskId != null) {
val existingIds = taskRepository.getSubTasks(id).first().map { it.id }.toSet()
val currentIds = st.subTasks.map { it.id }.toSet()
(existingIds - currentIds).forEach { taskRepository.deleteTask(it) }
}
st.subTasks.forEach { sub ->
val actualSub = sub.copy(
parentId = id,
projectId = st.projectId,
addedAt = if (sub.addedAt.isBlank()) now else sub.addedAt,
updatedAt = now,
)
if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub)
else taskRepository.insertTask(actualSub)
}
// Reminders: replace all, reschedule
reminderRepository.deleteRemindersByTask(id)
st.reminders.forEach { reminder ->
val actual = reminder.copy(taskId = id)
reminderRepository.insertReminder(actual)
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
}
_state.update { it.copy(isSaving = false) }
onDone()
}
}
}