feat: [#11] fiche tâche complète (TaskEditSheet + TaskEditViewModel + ReminderRepository)
This commit is contained in:
@@ -4,6 +4,7 @@ import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import com.planify.mobile.data.local.dao.LabelDao
|
||||
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.TaskDao
|
||||
import com.planify.mobile.data.local.entity.LabelEntity
|
||||
@@ -30,4 +31,5 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun projectDao(): ProjectDao
|
||||
abstract fun sectionDao(): SectionDao
|
||||
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 provideSectionDao(db: AppDatabase) = db.sectionDao()
|
||||
@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.ProjectRepositoryImpl
|
||||
import com.planify.mobile.data.repository.ReminderRepositoryImpl
|
||||
import com.planify.mobile.data.repository.SectionRepositoryImpl
|
||||
import com.planify.mobile.data.repository.TaskRepositoryImpl
|
||||
import com.planify.mobile.domain.repository.LabelRepository
|
||||
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.TaskRepository
|
||||
import dagger.Binds
|
||||
@@ -29,4 +31,7 @@ abstract class RepositoryModule {
|
||||
|
||||
@Binds @Singleton
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user