diff --git a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt index 3312c5f..f872021 100644 --- a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt +++ b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt new file mode 100644 index 0000000..d0e1675 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt @@ -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> + + @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) +} diff --git a/app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt new file mode 100644 index 0000000..d126ea3 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt @@ -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> = + 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(it) }.getOrNull() }, + minutesOffset = minutesOffset, + ) + + private fun Reminder.toEntity() = ReminderEntity( + id = id, + taskId = taskId, + type = type.name, + dueDate = dueDate?.let { Json.encodeToString(it) }, + minutesOffset = minutesOffset, + ) +} diff --git a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt index eb79e57..bc9352e 100644 --- a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt +++ b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt @@ -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() } diff --git a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt index 5e84478..8c0977b 100644 --- a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt +++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt new file mode 100644 index 0000000..50d8d4e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt @@ -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> + suspend fun insertReminder(reminder: Reminder) + suspend fun deleteReminder(reminder: Reminder) + suspend fun deleteRemindersByTask(taskId: String) +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt new file mode 100644 index 0000000..f0e731e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt @@ -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) diff --git a/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt b/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt new file mode 100644 index 0000000..c4ba5b1 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt @@ -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 = emptyList(), + val reminders: List = emptyList(), + val subTasks: List = 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) = _state.update { it.copy(labels = labels) } + fun setReminders(reminders: List) = _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() + } + } +}