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

@@ -47,15 +47,15 @@ android {
dependencies { dependencies {
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.3")
implementation("androidx.activity:activity-compose:1.10.1") implementation("androidx.activity:activity-compose:1.11.0")
implementation(platform("androidx.compose:compose-bom:2025.08.01")) implementation(platform("androidx.compose:compose-bom:2025.09.00"))
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3") implementation("androidx.compose.material3:material3")
implementation("androidx.navigation:navigation-compose:2.9.4") implementation("androidx.navigation:navigation-compose:2.9.4")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0") implementation("androidx.hilt:hilt-navigation-compose:1.3.0")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.08.01")) androidTestImplementation(platform("androidx.compose:compose-bom:2025.09.00"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")

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.foundation.layout.padding
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier 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.screen.MainScreen
import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme import com.wismna.geoffroy.donext.presentation.ui.theme.DoNextTheme
import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.MainViewModel

View File

@@ -3,22 +3,14 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon 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.PrimaryTabRow
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
@@ -27,7 +19,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -36,21 +27,16 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.wismna.geoffroy.donext.domain.model.Priority 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.MainViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
@Composable @Composable
fun MainScreen( fun MainScreen(
@@ -65,18 +51,21 @@ fun MainScreen(
CircularProgressIndicator() CircularProgressIndicator()
} }
} else { } else {
val taskViewModel: TaskViewModel = hiltViewModel()
val startDestination = viewModel.taskLists[0] val startDestination = viewModel.taskLists[0]
// TODO: get last opened tab from saved settings // TODO: get last opened tab from saved settings
var selectedDestination by rememberSaveable { mutableIntStateOf(0) } var selectedDestination by rememberSaveable { mutableIntStateOf(0) }
if (showBottomSheet) { if (showBottomSheet) {
AddTaskBottomSheet(viewModel, selectedDestination, { showBottomSheet = false }) TaskBottomSheet(taskViewModel, { showBottomSheet = false })
} }
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
floatingActionButton = { floatingActionButton = {
AddNewTaskButton { AddNewTaskButton {
val currentListId = viewModel.taskLists[selectedDestination].id
taskViewModel.startNewTask(currentListId)
showBottomSheet = true showBottomSheet = true
} }
}, topBar = { }, topBar = {
@@ -103,96 +92,25 @@ fun MainScreen(
.padding(contentPadding) .padding(contentPadding)
.fillMaxSize() .fillMaxSize()
) { ) {
AppNavHost(navController, startDestination, viewModel) NavHost(
} navController,
} startDestination = "taskList/${startDestination.id}"
} ) {
} viewModel.taskLists.forEach { destination ->
composable(
@Composable route = "taskList/{taskListId}",
fun AppNavHost( arguments = listOf(navArgument("taskListId") {
navController: NavHostController, type = NavType.LongType
startDestination: TaskList, })
viewModel: MainViewModel ) {
) { TaskListScreen(
NavHost( onTaskClick = { task ->
navController, taskViewModel.startEditTask(task)
startDestination = "taskList/${startDestination.id}" showBottomSheet = true
) { })
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")
} }
} }
} }

View File

@@ -1,5 +1,6 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.Priority
import com.wismna.geoffroy.donext.domain.model.Task import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel import com.wismna.geoffroy.donext.presentation.viewmodel.TaskListViewModel
@Composable @Composable
fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) { fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel(), onTaskClick: (Task) -> Unit) {
val tasks = viewModel.tasks val tasks = viewModel.tasks
LazyColumn( LazyColumn(
@@ -58,6 +59,7 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
TaskItem( TaskItem(
task = task, task = task,
onClick = { onTaskClick(task) },
onToggleDone = { isChecked -> onToggleDone = { isChecked ->
viewModel.updateTaskDone(task.id!!, isChecked) viewModel.updateTaskDone(task.id!!, isChecked)
} }
@@ -69,6 +71,7 @@ fun TaskListScreen(viewModel: TaskListViewModel = hiltViewModel()) {
@Composable @Composable
fun TaskItem( fun TaskItem(
task: Task, task: Task,
onClick: () -> Unit,
onToggleDone: (Boolean) -> Unit onToggleDone: (Boolean) -> Unit
) { ) {
val baseStyle = MaterialTheme.typography.bodyLarge.copy( val baseStyle = MaterialTheme.typography.bodyLarge.copy(
@@ -87,15 +90,10 @@ fun TaskItem(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() }
.padding(8.dp) .padding(8.dp)
.alpha(if (task.isDone) 0.5f else 1f), .alpha(if (task.isDone) 0.5f else 1f),
) { ) {
/*Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {*/
Checkbox( Checkbox(
checked = task.isDone, checked = task.isDone,
onCheckedChange = onToggleDone, 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.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.model.TaskList
import com.wismna.geoffroy.donext.domain.usecase.AddTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase import com.wismna.geoffroy.donext.domain.usecase.GetTaskListsUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(
getTaskLists: GetTaskListsUseCase, getTaskLists: GetTaskListsUseCase
private val addTask: AddTaskUseCase
) : ViewModel() { ) : ViewModel() {
var taskLists by mutableStateOf<List<TaskList>>(emptyList()) var taskLists by mutableStateOf<List<TaskList>>(emptyList())
@@ -26,17 +22,6 @@ class MainViewModel @Inject constructor(
var isLoading by mutableStateOf(true) var isLoading by mutableStateOf(true)
private set 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 { init {
getTaskLists() getTaskLists()
.onEach { lists -> .onEach { lists ->
@@ -45,27 +30,4 @@ class MainViewModel @Inject constructor(
} }
.launchIn(viewModelScope) .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
}
}