diff --git a/donextv2/build.gradle.kts b/donextv2/build.gradle.kts index 055710a..34aaf90 100644 --- a/donextv2/build.gradle.kts +++ b/donextv2/build.gradle.kts @@ -47,15 +47,15 @@ android { dependencies { implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3") - implementation("androidx.activity:activity-compose:1.10.1") - implementation(platform("androidx.compose:compose-bom:2025.08.01")) + implementation("androidx.activity:activity-compose:1.11.0") + implementation(platform("androidx.compose:compose-bom:2025.09.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.material3:material3") implementation("androidx.navigation:navigation-compose:2.9.4") - implementation("androidx.hilt:hilt-navigation-compose:1.2.0") - androidTestImplementation(platform("androidx.compose:compose-bom:2025.08.01")) + implementation("androidx.hilt:hilt-navigation-compose:1.3.0") + androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskUseCase.kt new file mode 100644 index 0000000..05d6bf7 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/DeleteTaskUseCase.kt @@ -0,0 +1,12 @@ +package com.wismna.geoffroy.donext.domain.usecase + +import com.wismna.geoffroy.donext.domain.repository.TaskRepository +import javax.inject.Inject + +class DeleteTaskUseCase @Inject constructor( + private val repository: TaskRepository +) { + suspend operator fun invoke(taskId: Long) { + repository.deleteTask(taskId, true) + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt new file mode 100644 index 0000000..c4473be --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/domain/usecase/UpdateTaskUseCase.kt @@ -0,0 +1,24 @@ +package com.wismna.geoffroy.donext.domain.usecase + +import com.wismna.geoffroy.donext.domain.model.Priority +import com.wismna.geoffroy.donext.domain.model.Task +import com.wismna.geoffroy.donext.domain.repository.TaskRepository +import javax.inject.Inject + +class UpdateTaskUseCase @Inject constructor( + private val repository: TaskRepository +) { + suspend operator fun invoke(taskId: Long, taskListId: Long, title: String, description: String?, priority: Priority) { + repository.updateTask( + Task( + id = taskId, + taskListId = taskListId, + name = title, + description = description ?: "", + isDeleted = false, + isDone = false, + priority = priority, + ) + ) + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt index cced042..de65908 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/MainActivity.kt @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.wismna.geoffroy.donext.presentation.screen.MainScreen import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt index d1bfef2..3eb7d6f 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/MainScreen.kt @@ -3,22 +3,14 @@ package com.wismna.geoffroy.donext.presentation.screen import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.PrimaryTabRow import androidx.compose.material3.Scaffold import androidx.compose.material3.SegmentedButton @@ -27,7 +19,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -36,21 +27,16 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavHostController +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.wismna.geoffroy.donext.domain.model.Priority -import com.wismna.geoffroy.donext.domain.model.TaskList import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel -import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel @Composable fun MainScreen( @@ -65,18 +51,21 @@ fun MainScreen( CircularProgressIndicator() } } else { + val taskViewModel: TaskViewModel = hiltViewModel() val startDestination = viewModel.taskLists[0] // TODO: get last opened tab from saved settings var selectedDestination by rememberSaveable { mutableIntStateOf(0) } if (showBottomSheet) { - AddTaskBottomSheet(viewModel, selectedDestination, { showBottomSheet = false }) + TaskBottomSheet(taskViewModel, { showBottomSheet = false }) } Scaffold( modifier = modifier, floatingActionButton = { AddNewTaskButton { + val currentListId = viewModel.taskLists[selectedDestination].id + taskViewModel.startNewTask(currentListId) showBottomSheet = true } }, topBar = { @@ -103,96 +92,25 @@ fun MainScreen( .padding(contentPadding) .fillMaxSize() ) { - AppNavHost(navController, startDestination, viewModel) - } - } - } -} - -@Composable -fun AppNavHost( - navController: NavHostController, - startDestination: TaskList, - viewModel: MainViewModel -) { - NavHost( - navController, - startDestination = "taskList/${startDestination.id}" - ) { - viewModel.taskLists.forEach { destination -> - composable( - route = "taskList/{taskListId}", - arguments = listOf(navArgument("taskListId") { type = NavType.LongType })) { - val viewModel: TaskListViewModel = hiltViewModel() - TaskListScreen(viewModel) - } - } - } -} - -@Composable -fun AddTaskBottomSheet( - viewModel: MainViewModel, - selectedListIndex: Int, - onDismiss: () -> Unit -) { - val titleFocusRequester = remember { FocusRequester() } - - LaunchedEffect(Unit) { - titleFocusRequester.requestFocus() - } - - ModalBottomSheet(onDismissRequest = onDismiss) { - Column(Modifier.padding(16.dp)) { - Text( - "New Task", - style = MaterialTheme.typography.titleLarge - ) - Spacer(Modifier.height(8.dp)) - - // --- Title --- - OutlinedTextField( - value = viewModel.title, - singleLine = true, - onValueChange = { viewModel.onTitleChanged(it) }, - label = { Text("Title") }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(titleFocusRequester), - isError = !viewModel.isTitleValid && viewModel.title.isNotEmpty(), - ) - Spacer(Modifier.height(8.dp)) - - // --- Description --- - OutlinedTextField( - value = viewModel.description, - onValueChange = { viewModel.onDescriptionChanged(it) }, - label = { Text("Description") }, - maxLines = 3, - modifier = Modifier.fillMaxWidth() - ) - Spacer(Modifier.height(8.dp)) - - // --- Priority --- - Text("Priority", style = MaterialTheme.typography.labelLarge) - Spacer(Modifier.height(4.dp)) - SingleChoiceSegmentedButton( - value = viewModel.priority, - onValueChange = { viewModel.onPriorityChanged(it) } - ) - Spacer(Modifier.height(16.dp)) - - // --- Add Button --- - Button( - onClick = { - val currentListId = viewModel.taskLists[selectedListIndex].id - viewModel.createTask(currentListId) - onDismiss() - viewModel.resetTaskForm() - }, - modifier = Modifier.align(Alignment.End) - ) { - Text("Add") + NavHost( + navController, + startDestination = "taskList/${startDestination.id}" + ) { + viewModel.taskLists.forEach { destination -> + composable( + route = "taskList/{taskListId}", + arguments = listOf(navArgument("taskListId") { + type = NavType.LongType + }) + ) { + TaskListScreen( + onTaskClick = { task -> + taskViewModel.startEditTask(task) + showBottomSheet = true + }) + } + } + } } } } diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt index 8fb5f56..9a4ddf6 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskListScreen.kt @@ -1,5 +1,6 @@ package com.wismna.geoffroy.donext.presentation.screen +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -23,13 +24,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel @Composable -fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) { +fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) { val tasks = viewModel.tasks LazyColumn( @@ -58,6 +59,7 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) { TaskItem( task = task, + onClick = { onTaskClick(task) }, onToggleDone = { isChecked -> viewModel.updateTaskDone(task.id!!, isChecked) } @@ -69,6 +71,7 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) { @Composable fun TaskItem( task: Task, + onClick: () -> Unit, onToggleDone: (Boolean) -> Unit ) { val baseStyle = MaterialTheme.typography.bodyLarge.copy( @@ -87,15 +90,10 @@ fun TaskItem( Row( modifier = Modifier .fillMaxWidth() + .clickable { onClick() } .padding(8.dp) .alpha(if (task.isDone) 0.5f else 1f), ) { - /*Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically - ) {*/ Checkbox( checked = task.isDone, onCheckedChange = onToggleDone, diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt new file mode 100644 index 0000000..61d41fd --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/screen/TaskScreen.kt @@ -0,0 +1,102 @@ +package com.wismna.geoffroy.donext.presentation.screen + +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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskBottomSheet( + viewModel: TaskViewModel, + onDismiss: () -> Unit +) { + val titleFocusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + titleFocusRequester.requestFocus() + } + + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.padding(16.dp)) { + Text( + "New Task", + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(8.dp)) + + // --- Title --- + OutlinedTextField( + value = viewModel.title, + singleLine = true, + onValueChange = { viewModel.onTitleChanged(it) }, + label = { Text("Title") }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(titleFocusRequester), + isError = viewModel.title.isEmpty(), + ) + Spacer(Modifier.height(8.dp)) + + // --- Description --- + OutlinedTextField( + value = viewModel.description, + onValueChange = { viewModel.onDescriptionChanged(it) }, + label = { Text("Description") }, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(8.dp)) + + // --- Priority --- + Text("Priority", style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(4.dp)) + SingleChoiceSegmentedButton( + value = viewModel.priority, + onValueChange = { viewModel.onPriorityChanged(it) } + ) + Spacer(Modifier.height(16.dp)) + + Row (modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + // --- Delete Button --- + if (viewModel.isEditing()) { + Button( + onClick = { viewModel.delete(); onDismiss() }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { Text("Delete") } + } + // --- Save Button --- + Button( + onClick = { + viewModel.save() + onDismiss() + }, + enabled = viewModel.title.isNotBlank(), + //modifier = Modifier.align(Alignment.End) + ) { + Text(if (viewModel.isEditing()) "Save" else "Create") + } + } + } + } +} \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt index 0a25503..5e80845 100644 --- a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/MainViewModel.kt @@ -5,20 +5,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.wismna.geoffroy.donext.domain.model.Priority import com.wismna.geoffroy.donext.domain.model.TaskList -import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class MainViewModel @Inject constructor( - getTaskLists: GetTaskListsUseCase, - private val addTask: AddTaskUseCase + getTaskLists: GetTaskListsUseCase ) : ViewModel() { var taskLists by mutableStateOf>(emptyList()) @@ -26,17 +22,6 @@ class MainViewModel @Inject constructor( var isLoading by mutableStateOf(true) private set - // --- Form state --- - var title by mutableStateOf("") - private set - var description by mutableStateOf("") - private set - var priority by mutableStateOf(Priority.NORMAL) - private set - - val isTitleValid: Boolean - get() = title.isNotBlank() - init { getTaskLists() .onEach { lists -> @@ -45,27 +30,4 @@ class MainViewModel @Inject constructor( } .launchIn(viewModelScope) } - - fun createTask(taskListId: Long) { - if (!isTitleValid) return - viewModelScope.launch { - addTask(taskListId, title, description, priority) - } - } - - fun onTitleChanged(newTitle: String) { - title = newTitle - } - fun onDescriptionChanged(newDesc: String) { - description = newDesc - } - fun onPriorityChanged(newPriority: Priority) { - priority = newPriority - } - - fun resetTaskForm() { - title = "" - description = "" - priority = Priority.NORMAL - } } \ No newline at end of file diff --git a/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt new file mode 100644 index 0000000..230a976 --- /dev/null +++ b/donextv2/src/main/java/com/wismna/geoffroy/donext/presentation/viewmodel/TaskViewModel.kt @@ -0,0 +1,88 @@ +package com.wismna.geoffroy.donext.presentation.viewmodel + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wismna.geoffroy.donext.domain.model.Priority +import com.wismna.geoffroy.donext.domain.model.Task +import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase +import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskUseCase +import com.wismna.geoffroy.donext.domain.usecase.UpdateTaskUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TaskViewModel @Inject constructor( + private val createTaskUseCase: AddTaskUseCase, + private val updateTaskUseCase: UpdateTaskUseCase, + private val deleteTaskUseCase: DeleteTaskUseCase +) : ViewModel() { + + var title by mutableStateOf("") + private set + var description by mutableStateOf("") + private set + var priority by mutableStateOf(Priority.NORMAL) + private set + + private var editingTaskId: Long? = null + private var taskListId: Long? = null + + fun isEditing(): Boolean = editingTaskId != null + + fun startNewTask(selectedListId: Long) { + editingTaskId = null + taskListId = selectedListId + title = "" + description = "" + priority = Priority.NORMAL + } + + fun startEditTask(task: Task) { + editingTaskId = task.id + taskListId = task.taskListId + title = task.name + description = task.description ?: "" + priority = task.priority + } + + fun onTitleChanged(value: String) { title = value } + fun onDescriptionChanged(value: String) { description = value } + fun onPriorityChanged(value: Priority) { priority = value } + + fun save(onDone: (() -> Unit)? = null) { + if (title.isBlank()) return + + viewModelScope.launch { + if (isEditing()) { + updateTaskUseCase(editingTaskId!!, taskListId!!, title, description, priority) + } else { + createTaskUseCase(taskListId!!, title, description, priority) + } + // reset state after save + reset() + onDone?.invoke() + } + } + + fun delete() { + editingTaskId?.let { id -> + viewModelScope.launch { + deleteTaskUseCase(id) + reset() + } + } + } + + /** Optional: manual reset */ + fun reset() { + editingTaskId = null + taskListId = null + title = "" + description = "" + priority = Priority.NORMAL + } +} \ No newline at end of file