Added swiping moves to the tasks: left for done, right for delete

Rename some DAO functions
This commit is contained in:
Geoffroy Bonneville
2025-09-19 19:17:44 -04:00
parent 336755666b
commit 1c28d9aacb
7 changed files with 213 additions and 105 deletions

View File

@@ -20,11 +20,11 @@ interface TaskDao {
suspend fun updateTask(task: TaskEntity)
@Query("UPDATE tasks SET done = :done WHERE id = :taskId")
suspend fun markTaskDone(taskId: Long, done: Boolean)
suspend fun toggleTaskDone(taskId: Long, done: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE id = :taskId")
suspend fun markTaskDeleted(taskId: Long, deleted: Boolean)
suspend fun toggleTaskDeleted(taskId: Long, deleted: Boolean)
@Query("UPDATE tasks SET deleted = :deleted WHERE task_list_id = :taskListId")
suspend fun deleteAllTasksFromList(taskListId: Long, deleted: Boolean)
suspend fun toggleAllTasksFromListDeleted(taskListId: Long, deleted: Boolean)
}

View File

@@ -29,11 +29,11 @@ class TaskRepositoryImpl @Inject constructor(
}
override suspend fun deleteTask(taskId: Long, isDeleted: Boolean) {
taskDao.markTaskDeleted(taskId, isDeleted)
taskDao.toggleTaskDeleted(taskId, isDeleted)
}
override suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) {
taskDao.markTaskDone(taskId, isDone)
taskDao.toggleTaskDone(taskId, isDone)
}
override fun getTaskLists(): Flow<List<TaskList>> {
@@ -49,7 +49,7 @@ class TaskRepositoryImpl @Inject constructor(
}
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskDao.deleteAllTasksFromList(taskListId, isDeleted)
taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted)
taskListDao.deleteTaskList(taskListId, isDeleted)
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -45,6 +46,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel
@@ -67,7 +69,12 @@ fun ManageListsScreen(
}
)
LazyColumn(modifier = modifier.fillMaxWidth().padding(), state = lazyListState) {
LazyColumn(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 8.dp),
state = lazyListState
) {
itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list ->
var isInEditMode by remember { mutableStateOf(false) }
@@ -81,7 +88,7 @@ fun ManageListsScreen(
onClick = {},
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 5.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.draggableHandle(
onDragStopped = {
@@ -127,8 +134,8 @@ fun ManageListsScreen(
verticalAlignment = Alignment.CenterVertically
) {
AnimatedContent(
//modifier = Modifier.padding(start = 12.dp),
targetState = isInEditMode,
modifier = Modifier.weight(1f),
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
@@ -141,7 +148,12 @@ fun ManageListsScreen(
singleLine = true
)
} else {
Text(list.name)
Text(
modifier = Modifier.padding(start = 8.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = list.name
)
}
}
AnimatedContent(

View File

@@ -17,6 +17,7 @@ import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.AppDestination
@@ -46,7 +47,13 @@ fun MenuScreen(
)
viewModel.taskLists.forEach { list ->
NavigationDrawerItem(
label = { Text(list.name) },
label = {
Text(
text = list.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
icon = { Icon(Icons.Default.List, contentDescription = list.name) },
selected = currentDestination is AppDestination.TaskList &&
currentDestination.taskListId == list.id,

View File

@@ -1,29 +1,35 @@
package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
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.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Done
import androidx.compose.material3.Badge
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxState
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
@@ -35,9 +41,30 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
fun TaskItemScreen(
modifier: Modifier = Modifier,
viewModel: TaskItemViewModel,
onClick: () -> Unit,
onToggleDone: (Boolean) -> Unit
onSwipeDone: () -> Unit,
onSwipeDelete: () -> Unit
) {
val context = LocalContext.current
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = {
when (it) {
SwipeToDismissBoxValue.StartToEnd -> {
onSwipeDelete()
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
}
SwipeToDismissBoxValue.EndToStart -> {
onSwipeDone()
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
}
SwipeToDismissBoxValue.Settled -> return@rememberSwipeToDismissBoxState false
}
return@rememberSwipeToDismissBoxState true
},
// positional threshold of 25%
positionalThreshold = { it * .25f }
)
val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) {
Priority.HIGH -> FontWeight.Bold
@@ -49,25 +76,22 @@ fun TaskItemScreen(
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant
},
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None)
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None
)
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
backgroundContent = { DismissBackground(dismissState, viewModel.isDone) },
content = {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) {
// Done checkbox
Checkbox(
checked = viewModel.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Box(
modifier = Modifier
.weight(1f)
@@ -82,7 +106,9 @@ fun TaskItemScreen(
.align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart
)
),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
// Due date badge
@@ -105,23 +131,56 @@ fun TaskItemScreen(
}
// Optional description
this@Row.AnimatedVisibility(
visible = !viewModel.description.isNullOrBlank(),
modifier = Modifier.align(Alignment.BottomStart),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Text(
text = viewModel.description!!,
style = baseStyle.copy(fontSize = MaterialTheme.typography.bodyMedium.fontSize),
maxLines = 3,
overflow = TextOverflow.Ellipsis,
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(top = 20.dp) // spacing below title
.fillMaxWidth()
.height(40.dp) // 👈 adjust to the typical description height
.padding(top = 20.dp),
contentAlignment = Alignment.TopStart
) {
if (!viewModel.description.isNullOrBlank()) {
Text(
text = viewModel.description,
style = baseStyle.copy(
fontSize = MaterialTheme.typography.bodyMedium.fontSize,
fontStyle = FontStyle.Italic
),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
})
}
}
@Composable
fun DismissBackground(dismissState: SwipeToDismissBoxState, isDone: Boolean) {
val color = when (dismissState.dismissDirection) {
SwipeToDismissBoxValue.StartToEnd -> MaterialTheme.colorScheme.error
SwipeToDismissBoxValue.EndToStart -> Color(0xFF18590D)
SwipeToDismissBoxValue.Settled -> Color.Transparent
}
Row(
modifier = Modifier
.fillMaxSize()
.background(color)
.padding(12.dp, 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
Icons.Default.Delete,
tint = Color.LightGray,
contentDescription = "Delete"
)
Spacer(modifier = Modifier)
Icon(
if (isDone) Icons.Default.Close else Icons.Default.Done,
tint = Color.LightGray,
contentDescription = "Archive"
)
}
}

View File

@@ -9,9 +9,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -45,15 +48,25 @@ fun TaskListScreen(
items = active,
key = { it.id!! }
) { task ->
Card(
onClick = { onTaskClick(task) },
//elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onClick = { onTaskClick(task) },
onToggleDone = { checked ->
viewModel.updateTaskDone(task.id!!, checked)
onSwipeDone = {
viewModel.updateTaskDone(task.id!!, true)
},
onSwipeDelete = {
viewModel.deleteTask(task.id!!)
}
)
}
}
// Divider between active and done (optional)
if (done.isNotEmpty() && active.isNotEmpty()) {
@@ -71,17 +84,27 @@ fun TaskListScreen(
items = done,
key = { it.id!! }
) { task ->
Card(
onClick = { onTaskClick(task) },
//elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
TaskItemScreen(
modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task),
onClick = { onTaskClick(task) },
onToggleDone = { checked ->
viewModel.updateTaskDone(task.id!!, checked)
}
onSwipeDone = {
viewModel.updateTaskDone(task.id!!, false)
},
onSwipeDelete = {
viewModel.deleteTask(task.id!!)
},
)
}
}
}
}
@Composable
fun TaskListFab(

View File

@@ -7,8 +7,9 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import com.wismna.geoffroy.donext.domain.usecase.DeleteTaskListUseCase
import com.wismna.geoffroy.donext.domain.usecase.GetTasksForListUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDoneUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -18,7 +19,8 @@ import javax.inject.Inject
@HiltViewModel
class TaskListViewModel @Inject constructor(
getTasks: GetTasksForListUseCase,
private val toggleTaskDone: ToggleTaskDoneUseCase,
private val toggleTaskDoneUseCase: ToggleTaskDoneUseCase,
private val deleteTaskListUseCase: DeleteTaskListUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@@ -40,7 +42,12 @@ class TaskListViewModel @Inject constructor(
fun updateTaskDone(taskId: Long, isDone: Boolean) {
viewModelScope.launch {
toggleTaskDone(taskId, isDone)
toggleTaskDoneUseCase(taskId, isDone)
}
}
fun deleteTask(taskId: Long) {
viewModelScope.launch {
deleteTaskListUseCase(taskId)
}
}
}