Add an Empty Recycle Bin action button

Refactor Task Item Screen to include the Card
WIP on fix overdue dates calculation on task items
This commit is contained in:
Geoffroy Bonneville
2025-09-24 21:29:23 -04:00
parent cf770ddb83
commit b71fa4fdb7
14 changed files with 224 additions and 191 deletions

View File

@@ -21,7 +21,7 @@ interface TaskDao {
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<TaskEntity>>
@Query("SELECT * FROM tasks WHERE deleted = 1")
suspend fun getDeletedTasks(): List<TaskEntity>
fun getDeletedTasks(): Flow<List<TaskEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTask(task: TaskEntity)
@@ -40,4 +40,7 @@ interface TaskDao {
@Query("DELETE FROM tasks WHERE id = :taskId")
suspend fun permanentDeleteTask(taskId: Long)
@Query("DELETE FROM tasks WHERE deleted = 1")
suspend fun permanentDeleteAllDeletedTasks()
}

View File

@@ -11,6 +11,7 @@ import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import kotlin.collections.map
class TaskRepositoryImpl @Inject constructor(
private val taskDao: TaskDao,
@@ -24,8 +25,8 @@ class TaskRepositoryImpl @Inject constructor(
return taskDao.getDueTodayTasks(todayStart, todayEnd).map {entity -> entity.map { it.toDomain() }}
}
override suspend fun getDeletedTasks(): List<Task> {
return taskDao.getDeletedTasks().map {entity -> entity.toDomain() }
override fun getDeletedTasks(): Flow<List<Task>> {
return taskDao.getDeletedTasks().map {entity -> entity.map { it.toDomain() }}
}
override suspend fun insertTask(task: Task) {
@@ -48,6 +49,10 @@ class TaskRepositoryImpl @Inject constructor(
taskDao.permanentDeleteTask(taskId)
}
override suspend fun permanentlyDeleteAllDeletedTask() {
taskDao.permanentDeleteAllDeletedTasks()
}
override fun getTaskLists(): Flow<List<TaskList>> {
return taskListDao.getTaskLists().map {entities -> entities.map { it.toDomain() }}
}

View File

@@ -8,12 +8,13 @@ import kotlinx.coroutines.flow.Flow
interface TaskRepository {
fun getTasksForList(listId: Long): Flow<List<Task>>
fun getDueTodayTasks(todayStart: Long, todayEnd: Long): Flow<List<Task>>
suspend fun getDeletedTasks(): List<Task>
fun getDeletedTasks(): Flow<List<Task>>
suspend fun insertTask(task: Task)
suspend fun updateTask(task: Task)
suspend fun toggleTaskDeleted(taskId: Long, isDeleted: Boolean)
suspend fun toggleTaskDone(taskId: Long, isDone: Boolean)
suspend fun permanentlyDeleteTask(taskId: Long)
suspend fun permanentlyDeleteAllDeletedTask()
fun getTaskLists(): Flow<List<TaskList>>
suspend fun insertTaskList(taskList: TaskList)

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 EmptyRecycleBinUseCase @Inject constructor(
private val repository: TaskRepository
) {
suspend operator fun invoke() {
repository.permanentlyDeleteAllDeletedTask()
}
}

View File

@@ -2,8 +2,9 @@ package com.wismna.geoffroy.donext.domain.usecase
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.domain.repository.TaskRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetDeletedTasksUseCase @Inject constructor(private val repository: TaskRepository) {
suspend operator fun invoke(): List<Task> = repository.getDeletedTasks()
operator fun invoke(): Flow<List<Task>> = repository.getDeletedTasks()
}

View File

@@ -6,9 +6,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -42,26 +39,21 @@ fun DueTodayTasksScreen(
modifier = modifier.padding(8.dp)
) {
items(tasks, 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),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
},
onSwipeRight = {
viewModel.deleteTask(task.id!!)
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "Task moved to recycle bin", Toast.LENGTH_SHORT)
.show()
}
)
}
}
}
}
}

View File

@@ -26,6 +26,7 @@ import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState
@@ -136,11 +137,19 @@ fun AppContent(
}
},
actions = {
if (viewModel.currentDestination is AppDestination.ManageLists) {
when (viewModel.currentDestination) {
is AppDestination.ManageLists -> {
IconButton(onClick = { viewModel.showAddListSheet = true }) {
Icon(Icons.Default.Add, contentDescription = "Add List")
}
}
is AppDestination.RecycleBin -> {
TextButton(onClick = { viewModel.emptyRecycleBin() }) {
Text(text = "Empty Recycle Bin", color = MaterialTheme.colorScheme.onPrimary)
}
}
else -> null
}
}
)
},
@@ -179,7 +188,6 @@ fun AppContent(
slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth }, animationSpec = tween(300))
}
) {
//viewModel.destinations.forEach { destination ->
composable(
route = "taskList/{taskListId}",
arguments = listOf(navArgument("taskListId") {
@@ -195,7 +203,6 @@ fun AppContent(
}
)
}
//}
composable(AppDestination.ManageLists.route) {
ManageListsScreen(
@@ -214,7 +221,11 @@ fun AppContent(
}
composable(AppDestination.RecycleBin.route) {
RecycleBinScreen(
modifier = Modifier
modifier = Modifier,
onTaskClick = { task ->
taskViewModel.startEditTask(task)
viewModel.showTaskSheet = true
}
)
}
}

View File

@@ -6,9 +6,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -16,6 +13,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.wismna.geoffroy.donext.domain.model.Task
import com.wismna.geoffroy.donext.presentation.viewmodel.RecycleBinViewModel
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
@@ -23,6 +21,7 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
fun RecycleBinScreen(
modifier: Modifier = Modifier,
viewModel: RecycleBinViewModel = hiltViewModel(),
onTaskClick: (task: Task) -> Unit
) {
val tasks = viewModel.deletedTasks
@@ -40,15 +39,11 @@ fun RecycleBinScreen(
modifier = modifier.padding(8.dp)
) {
items(tasks, 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),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.restore(task.id!!)
Toast.makeText(context, "Task restored", Toast.LENGTH_SHORT).show()
@@ -58,7 +53,7 @@ fun RecycleBinScreen(
Toast.makeText(context, "Task deleted", Toast.LENGTH_SHORT).show()
}
)
}
}
}
}

View File

@@ -16,6 +16,8 @@ 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.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SwipeToDismissBox
@@ -41,6 +43,7 @@ import com.wismna.geoffroy.donext.presentation.viewmodel.TaskItemViewModel
fun TaskItemScreen(
modifier: Modifier = Modifier,
viewModel: TaskItemViewModel,
onTaskClick: (taskId: Long) -> Unit,
onSwipeLeft: () -> Unit,
onSwipeRight: () -> Unit
) {
@@ -69,14 +72,25 @@ fun TaskItemScreen(
},
textDecoration = if (viewModel.isDone) TextDecoration.LineThrough else TextDecoration.None
)
Card(
modifier = modifier,
onClick = { onTaskClick(viewModel.id) },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
SwipeToDismissBox(
state = dismissState,
modifier = modifier,
backgroundContent = { DismissBackground(dismissState, viewModel.isDone, viewModel.isDeleted) },
backgroundContent = {
DismissBackground(
dismissState,
viewModel.isDone,
viewModel.isDeleted
)
},
content = {
Row(
modifier = modifier
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(8.dp)
@@ -146,6 +160,7 @@ fun TaskItemScreen(
}
}
})
}
}
@Composable

View File

@@ -10,12 +10,9 @@ 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
@@ -51,16 +48,10 @@ 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),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, true)
Toast.makeText(context, "Task done", Toast.LENGTH_SHORT).show()
@@ -71,7 +62,6 @@ fun TaskListScreen(
}
)
}
}
// Divider between active and done (optional)
if (done.isNotEmpty() && active.isNotEmpty()) {
@@ -89,16 +79,10 @@ 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),
onTaskClick = { onTaskClick(task) },
onSwipeLeft = {
viewModel.updateTaskDone(task.id!!, false)
Toast.makeText(context, "Task in progress", Toast.LENGTH_SHORT).show()
@@ -109,7 +93,7 @@ fun TaskListScreen(
},
)
}
}
}
}

View File

@@ -42,6 +42,7 @@ import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.presentation.viewmodel.TaskViewModel
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -108,7 +109,7 @@ fun TaskBottomSheet(
var showDatePicker by remember { mutableStateOf(false) }
val formattedDate = viewModel.dueDate?.let {
Instant.ofEpochMilli(it)
.atZone(ZoneOffset.UTC)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM))
} ?: ""

View File

@@ -7,15 +7,18 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavBackStackEntry
import com.wismna.geoffroy.donext.domain.model.AppDestination
import com.wismna.geoffroy.donext.domain.usecase.EmptyRecycleBinUseCase
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
getTaskListsUseCase: GetTaskListsUseCase,
private val emptyRecycleBinUseCase: EmptyRecycleBinUseCase
) : ViewModel() {
var isLoading by mutableStateOf(true)
@@ -34,7 +37,7 @@ class MainViewModel @Inject constructor(
var showAddListSheet by mutableStateOf(false)
init {
getTaskLists()
getTaskListsUseCase()
.onEach { lists ->
destinations = lists.map { taskList ->
AppDestination.TaskList(taskList.id!!, taskList.name)
@@ -61,4 +64,10 @@ class MainViewModel @Inject constructor(
}
} ?: startDestination
}
fun emptyRecycleBin() {
viewModelScope.launch {
emptyRecycleBinUseCase()
}
}
}

View File

@@ -10,6 +10,8 @@ import com.wismna.geoffroy.donext.domain.usecase.GetDeletedTasksUseCase
import com.wismna.geoffroy.donext.domain.usecase.PermanentlyDeleteTaskUseCase
import com.wismna.geoffroy.donext.domain.usecase.ToggleTaskDeletedUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -28,9 +30,11 @@ class RecycleBinViewModel @Inject constructor(
}
fun loadDeletedTasks() {
viewModelScope.launch {
deletedTasks = getDeletedTasks()
getDeletedTasks()
.onEach { tasks ->
deletedTasks = tasks
}
.launchIn(viewModelScope)
}
fun restore(taskId: Long) {

View File

@@ -4,7 +4,7 @@ import com.wismna.geoffroy.donext.domain.model.Priority
import com.wismna.geoffroy.donext.domain.model.Task
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.format.TextStyle
@@ -18,11 +18,11 @@ class TaskItemViewModel(task: Task) {
val isDeleted: Boolean = task.isDeleted
val priority: Priority = task.priority
val today: LocalDate = LocalDate.now(ZoneOffset.UTC)
val today: LocalDate = LocalDate.now(ZoneId.systemDefault())
val isOverdue: Boolean = task.dueDate?.let { millis ->
val dueDate = Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC)
.atZone(ZoneId.systemDefault())
.toLocalDate()
dueDate.isBefore(today)
} ?: false
@@ -30,7 +30,7 @@ class TaskItemViewModel(task: Task) {
val dueDateText: String? = task.dueDate?.let { formatDueDate(it) }
private fun formatDueDate(dueMillis: Long): String {
val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneOffset.UTC).toLocalDate()
val dueDate = Instant.ofEpochMilli(dueMillis).atZone(ZoneId.systemDefault()).toLocalDate()
return when {
dueDate.isEqual(today) -> "Today"