- 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
422 lines
13 KiB
Kotlin
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)
|
|
}
|
|
}
|
|
}
|