Bruno Charest bccd5ea2d4 feat: Add comprehensive todo management system with Gemini-powered brain dump analysis
- Add TodoEntity, TodoDao, TodoRepository, and TodoRepositoryImpl for local todo storage with sync support
- Implement TodoViewModel with CRUD operations, group management, and subtask handling
- Create TodoScreen with Kanban-style board view, group-based organization, and drag-to-reorder support
- Add BrainDumpSheet for AI-powered task extraction from natural language input using Gemini API
- Implement TodoNot
2026-02-13 15:51:39 -05:00

422 lines
13 KiB
Kotlin

package com.shaarit.presentation.todo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.data.sync.SyncManager
import com.shaarit.data.worker.TodoNotificationScheduler
import com.shaarit.domain.model.SubTask
import com.shaarit.domain.model.TodoItem
import com.shaarit.domain.repository.GeminiRepository
import com.shaarit.domain.repository.TodoRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject
data class EditableBrainDumpTask(
val localId: String = UUID.randomUUID().toString(),
val title: String,
val dueDate: Long? = null,
val tags: List<String> = emptyList(),
val groupName: String? = null
)
data class BrainDumpDialogUiState(
val input: String = "",
val isAnalyzing: Boolean = false,
val isSaving: Boolean = false,
val parsedTasks: List<EditableBrainDumpTask> = emptyList(),
val errorMessage: String? = null,
val saveCompleted: Boolean = false,
val requestNotificationPermission: Boolean = false
)
data class EditTodoDialogUiState(
val isVisible: Boolean = false,
val todoId: Long = 0,
val content: String = "",
val dueDate: Long? = null,
val tags: List<String> = emptyList(),
val groupName: String = "",
val subtasks: List<SubTask> = emptyList(),
val newSubtaskText: String = "",
val isSaving: Boolean = false,
val errorMessage: String? = null,
val shaarliLinkUrl: String = "",
val isDone: Boolean = false
)
@HiltViewModel
class TodoViewModel @Inject constructor(
private val todoRepository: TodoRepository,
private val geminiRepository: GeminiRepository,
private val syncManager: SyncManager,
private val notificationScheduler: TodoNotificationScheduler
) : ViewModel() {
val todos: StateFlow<List<TodoItem>> =
todoRepository
.getTodosStream()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val groupNames: StateFlow<List<String>> =
todoRepository
.getGroupNamesStream()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private val _selectedGroup = MutableStateFlow<String?>(null)
val selectedGroup: StateFlow<String?> = _selectedGroup.asStateFlow()
private val _dialogState = MutableStateFlow(BrainDumpDialogUiState())
val dialogState: StateFlow<BrainDumpDialogUiState> = _dialogState.asStateFlow()
private val _editDialogState = MutableStateFlow(EditTodoDialogUiState())
val editDialogState: StateFlow<EditTodoDialogUiState> = _editDialogState.asStateFlow()
fun selectGroup(group: String?) {
_selectedGroup.value = group
}
// ====== Brain Dump Dialog ======
fun onBrainDumpInputChanged(value: String) {
_dialogState.update {
it.copy(input = value, errorMessage = null)
}
}
fun appendVoiceInput(transcript: String) {
val cleaned = transcript.trim()
if (cleaned.isBlank()) return
_dialogState.update {
val separator = if (it.input.isBlank()) "" else "\n"
it.copy(input = it.input + separator + cleaned)
}
}
fun analyzeBrainDump() {
val input = _dialogState.value.input.trim()
if (input.isBlank()) {
_dialogState.update {
it.copy(errorMessage = "Le brain dump ne peut pas être vide")
}
return
}
if (!geminiRepository.isApiKeyConfigured()) {
_dialogState.update {
it.copy(errorMessage = "Clé API Gemini non configurée dans les paramètres")
}
return
}
viewModelScope.launch {
_dialogState.update {
it.copy(isAnalyzing = true, errorMessage = null, parsedTasks = emptyList())
}
runCatching {
geminiRepository.analyzeBrainDump(input)
}.onSuccess { result ->
val parsedTasks = result.tasks.map { task ->
EditableBrainDumpTask(
title = task.title,
dueDate = task.dueDate,
tags = task.tags
)
}.ifEmpty {
listOf(
EditableBrainDumpTask(
title = input.take(80),
dueDate = null,
tags = emptyList()
)
)
}
_dialogState.update {
it.copy(
isAnalyzing = false,
parsedTasks = parsedTasks,
errorMessage = null
)
}
}.onFailure { error ->
_dialogState.update {
it.copy(
isAnalyzing = false,
errorMessage = error.message ?: "Erreur pendant l'analyse IA"
)
}
}
}
}
fun updateTaskTitle(taskId: String, title: String) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) task.copy(title = title) else task
}
)
}
}
fun updateTaskDueDate(taskId: String, dueDate: Long?) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) task.copy(dueDate = dueDate) else task
}
)
}
}
fun updateTaskGroup(taskId: String, groupName: String?) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) task.copy(groupName = groupName) else task
}
)
}
}
fun removeTag(taskId: String, tag: String) {
_dialogState.update { state ->
state.copy(
parsedTasks = state.parsedTasks.map { task ->
if (task.localId == taskId) {
task.copy(tags = task.tags.filterNot { it.equals(tag, ignoreCase = true) })
} else {
task
}
}
)
}
}
fun saveParsedTasks() {
val tasksToSave = _dialogState.value.parsedTasks
.map { task ->
task.copy(
title = task.title.trim(),
tags = task.tags
.map { it.trim().trimStart('#').lowercase() }
.filter { it.isNotBlank() }
.distinct()
)
}
.filter { it.title.isNotBlank() }
if (tasksToSave.isEmpty()) {
_dialogState.update {
it.copy(errorMessage = "Aucune tâche valide à sauvegarder")
}
return
}
viewModelScope.launch {
_dialogState.update {
it.copy(isSaving = true, errorMessage = null)
}
var hasError = false
tasksToSave.forEach { task ->
val result = todoRepository.upsertTodo(
TodoItem(
shaarliLinkUrl = "",
content = task.title,
isDone = false,
dueDate = task.dueDate,
tags = task.tags,
isSynced = false,
groupName = task.groupName?.takeIf { it.isNotBlank() }
)
)
if (result.isFailure) {
hasError = true
}
}
syncManager.syncNow()
val shouldAskPermission = tasksToSave.any { it.dueDate != null } &&
notificationScheduler.requiresNotificationPermission()
_dialogState.update {
it.copy(
isSaving = false,
saveCompleted = !hasError,
errorMessage = if (hasError) "Certaines tâches n'ont pas pu être sauvegardées" else null,
requestNotificationPermission = shouldAskPermission
)
}
}
}
fun consumeSaveCompleted() {
_dialogState.update {
it.copy(
saveCompleted = false,
input = "",
parsedTasks = emptyList(),
errorMessage = null
)
}
}
fun clearDialogState() {
_dialogState.update {
BrainDumpDialogUiState()
}
}
fun onNotificationPermissionHandled() {
_dialogState.update {
it.copy(requestNotificationPermission = false)
}
}
// ====== Edit Todo Dialog ======
fun openEditDialog(todoId: Long) {
viewModelScope.launch {
val todo = todoRepository.getTodoById(todoId) ?: return@launch
_editDialogState.value = EditTodoDialogUiState(
isVisible = true,
todoId = todo.id,
content = todo.content,
dueDate = todo.dueDate,
tags = todo.tags,
groupName = todo.groupName ?: "",
subtasks = todo.subtasks,
shaarliLinkUrl = todo.shaarliLinkUrl,
isDone = todo.isDone
)
}
}
fun closeEditDialog() {
_editDialogState.value = EditTodoDialogUiState()
}
fun onEditContentChanged(value: String) {
_editDialogState.update { it.copy(content = value, errorMessage = null) }
}
fun onEditDueDateChanged(dueDate: Long?) {
_editDialogState.update { it.copy(dueDate = dueDate) }
}
fun onEditGroupChanged(groupName: String) {
_editDialogState.update { it.copy(groupName = groupName) }
}
fun onEditNewSubtaskTextChanged(text: String) {
_editDialogState.update { it.copy(newSubtaskText = text) }
}
fun addSubtask() {
val text = _editDialogState.value.newSubtaskText.trim()
if (text.isBlank()) return
_editDialogState.update {
it.copy(
subtasks = it.subtasks + SubTask(content = text),
newSubtaskText = ""
)
}
}
fun removeSubtask(index: Int) {
_editDialogState.update {
it.copy(subtasks = it.subtasks.toMutableList().apply { removeAt(index) })
}
}
fun toggleSubtask(index: Int) {
_editDialogState.update {
val mutable = it.subtasks.toMutableList()
val sub = mutable[index]
mutable[index] = sub.copy(isDone = !sub.isDone)
it.copy(subtasks = mutable)
}
}
fun updateSubtaskContent(index: Int, content: String) {
_editDialogState.update {
val mutable = it.subtasks.toMutableList()
mutable[index] = mutable[index].copy(content = content)
it.copy(subtasks = mutable)
}
}
fun saveEditedTodo() {
val state = _editDialogState.value
val content = state.content.trim()
if (content.isBlank()) {
_editDialogState.update { it.copy(errorMessage = "Le contenu ne peut pas être vide") }
return
}
viewModelScope.launch {
_editDialogState.update { it.copy(isSaving = true, errorMessage = null) }
val result = todoRepository.upsertTodo(
TodoItem(
id = state.todoId,
shaarliLinkUrl = state.shaarliLinkUrl,
content = content,
isDone = state.isDone,
dueDate = state.dueDate,
tags = state.tags,
isSynced = false,
groupName = state.groupName.takeIf { it.isNotBlank() },
subtasks = state.subtasks.filter { it.content.isNotBlank() }
)
)
if (result.isSuccess) {
syncManager.syncNow()
_editDialogState.value = EditTodoDialogUiState()
} else {
_editDialogState.update {
it.copy(
isSaving = false,
errorMessage = "Erreur lors de la sauvegarde"
)
}
}
}
}
// ====== Actions ======
fun toggleTodo(todoId: Long, isDone: Boolean) {
viewModelScope.launch {
todoRepository.toggleDone(todoId, isDone)
}
}
fun snoozeTodo(todoId: Long) {
viewModelScope.launch {
todoRepository.snoozeTodo(todoId)
}
}
fun deleteTodo(todoId: Long) {
viewModelScope.launch {
todoRepository.deleteTodo(todoId)
}
}
}