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) suspend fun updateTask(task: TaskEntity)
@Query("UPDATE tasks SET done = :done WHERE id = :taskId") @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") @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") @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) { override suspend fun deleteTask(taskId: Long, isDeleted: Boolean) {
taskDao.markTaskDeleted(taskId, isDeleted) taskDao.toggleTaskDeleted(taskId, isDeleted)
} }
override suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) { override suspend fun toggleTaskDone(taskId: Long, isDone: Boolean) {
taskDao.markTaskDone(taskId, isDone) taskDao.toggleTaskDone(taskId, isDone)
} }
override fun getTaskLists(): Flow<List<TaskList>> { override fun getTaskLists(): Flow<List<TaskList>> {
@@ -49,7 +49,7 @@ class TaskRepositoryImpl @Inject constructor(
} }
override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) { override suspend fun deleteTaskList(taskListId: Long, isDeleted: Boolean) {
taskDao.deleteAllTasksFromList(taskListId, isDeleted) taskDao.toggleAllTasksFromListDeleted(taskListId, isDeleted)
taskListDao.deleteTaskList(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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.CustomAccessibilityAction
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.customActions import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.ManageListsViewModel 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 -> itemsIndexed(lists, key = { _, list -> list.id!! }) { index, list ->
var isInEditMode by remember { mutableStateOf(false) } var isInEditMode by remember { mutableStateOf(false) }
@@ -81,7 +88,7 @@ fun ManageListsScreen(
onClick = {}, onClick = {},
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 5.dp), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 5.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer, containerColor = MaterialTheme.colorScheme.secondaryContainer,
), ),
modifier = Modifier.draggableHandle( modifier = Modifier.draggableHandle(
onDragStopped = { onDragStopped = {
@@ -127,8 +134,8 @@ fun ManageListsScreen(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
AnimatedContent( AnimatedContent(
//modifier = Modifier.padding(start = 12.dp),
targetState = isInEditMode, targetState = isInEditMode,
modifier = Modifier.weight(1f),
transitionSpec = { transitionSpec = {
fadeIn() togetherWith fadeOut() fadeIn() togetherWith fadeOut()
}, },
@@ -141,7 +148,12 @@ fun ManageListsScreen(
singleLine = true singleLine = true
) )
} else { } else {
Text(list.name) Text(
modifier = Modifier.padding(start = 8.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
text = list.name
)
} }
} }
AnimatedContent( AnimatedContent(

View File

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

View File

@@ -1,29 +1,35 @@
package com.wismna.geoffroy.donext.presentation.screen package com.wismna.geoffroy.donext.presentation.screen
import androidx.compose.animation.AnimatedVisibility import android.widget.Toast
import androidx.compose.animation.expandVertically import androidx.compose.foundation.background
import androidx.compose.animation.fadeIn import androidx.compose.foundation.layout.Arrangement
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons
import androidx.compose.foundation.shape.CircleShape 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.Badge
import androidx.compose.material3.Checkbox import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme 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.Text
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.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
@@ -35,9 +41,30 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
fun TaskItemScreen( fun TaskItemScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: TaskItemViewModel, viewModel: TaskItemViewModel,
onClick: () -> Unit, onSwipeDone: () -> Unit,
onToggleDone: (Boolean) -> 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( val baseStyle = MaterialTheme.typography.bodyLarge.copy(
fontWeight = when (viewModel.priority) { fontWeight = when (viewModel.priority) {
Priority.HIGH -> FontWeight.Bold Priority.HIGH -> FontWeight.Bold
@@ -49,25 +76,22 @@ fun TaskItemScreen(
Priority.NORMAL -> MaterialTheme.colorScheme.onSurface Priority.NORMAL -> MaterialTheme.colorScheme.onSurface
Priority.LOW -> MaterialTheme.colorScheme.onSurfaceVariant 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( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick() } .background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp) .padding(8.dp)
.alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f), .alpha(if (viewModel.isDone || viewModel.priority == Priority.LOW) 0.5f else 1f),
verticalAlignment = Alignment.CenterVertically // centers checkbox + content verticalAlignment = Alignment.CenterVertically // centers checkbox + content
) { ) {
// Done checkbox
Checkbox(
checked = viewModel.isDone,
onCheckedChange = onToggleDone,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
@@ -82,7 +106,9 @@ fun TaskItemScreen(
.align( .align(
if (viewModel.description.isNullOrBlank()) Alignment.CenterStart if (viewModel.description.isNullOrBlank()) Alignment.CenterStart
else Alignment.TopStart else Alignment.TopStart
) ),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
) )
// Due date badge // Due date badge
@@ -105,23 +131,56 @@ fun TaskItemScreen(
} }
// Optional description // Optional description
this@Row.AnimatedVisibility( Box(
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,
modifier = Modifier modifier = Modifier
.align(Alignment.BottomStart) .fillMaxWidth()
.padding(top = 20.dp) // spacing below title .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.foundation.lazy.items
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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -45,15 +48,25 @@ fun TaskListScreen(
items = active, items = active,
key = { it.id!! } key = { it.id!! }
) { task -> ) { task ->
Card(
onClick = { onTaskClick(task) },
//elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
TaskItemScreen( TaskItemScreen(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task), viewModel = TaskItemViewModel(task),
onClick = { onTaskClick(task) }, onSwipeDone = {
onToggleDone = { checked -> viewModel.updateTaskDone(task.id!!, true)
viewModel.updateTaskDone(task.id!!, checked) },
onSwipeDelete = {
viewModel.deleteTask(task.id!!)
} }
) )
} }
}
// Divider between active and done (optional) // Divider between active and done (optional)
if (done.isNotEmpty() && active.isNotEmpty()) { if (done.isNotEmpty() && active.isNotEmpty()) {
@@ -71,17 +84,27 @@ fun TaskListScreen(
items = done, items = done,
key = { it.id!! } key = { it.id!! }
) { task -> ) { task ->
Card(
onClick = { onTaskClick(task) },
//elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
TaskItemScreen( TaskItemScreen(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
viewModel = TaskItemViewModel(task), viewModel = TaskItemViewModel(task),
onClick = { onTaskClick(task) }, onSwipeDone = {
onToggleDone = { checked -> viewModel.updateTaskDone(task.id!!, false)
viewModel.updateTaskDone(task.id!!, checked) },
} onSwipeDelete = {
viewModel.deleteTask(task.id!!)
},
) )
} }
} }
} }
}
@Composable @Composable
fun TaskListFab( fun TaskListFab(

View File

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