Tapping on a task allows edition

Refactoring of the bottom sheet to allow edition
Create a Task ViewModel
This commit is contained in:
Geoffroy Bonneville
2025-09-12 14:12:55 -04:00
parent ef73319613
commit 7939257cd6
9 changed files with 263 additions and 159 deletions

View File

@@ -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)
}
}

View File

@@ -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,
)
)
}
}

View File

@@ -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

View File

@@ -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<TaskListViewModel>()
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
})
}
}
}
}
}
}

View File

@@ -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,

View File

@@ -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")
}
}
}
}
}

View File

@@ -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<List<TaskList>>(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
}
}

View File

@@ -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
}
}